feat(wecom): add user feedback support for WeChat Work AI Bot (#2078)

* feat(wecom): add user feedback support for WeChat Work AI Bot

This commit implements user feedback functionality (like/dislike) for
WeChat Work AI Bot conversations, including:

Backend changes:
- Add feedback_id and stream_id fields to WecomBotEvent
- Implement feedback event handling in WecomBotClient (api.py)
- Add StreamSessionManager._feedback_index for feedback_id lookup
- Add on_feedback decorator for custom feedback handlers
- Create MonitoringFeedback entity for database persistence
- Add dbm025 migration for monitoring_feedback table
- Implement FeedbackMonitor helper class
- Update all platform adapters with ap parameter support
- Update botmgr to pass bot_info for monitoring context

Frontend changes:
- Add FeedbackCard and FeedbackList components
- Add useFeedbackData hook for feedback data fetching
- Add feedback tab to monitoring page
- Add feedback types and interfaces
- Add i18n translations (zh-Hans, en-US)

Other changes:
- Update Dockerfile with Chinese mirror for faster builds
- Update docker-compose.yaml with network configuration
- Update .gitignore for docker data and backup files

Note: Known issues that need future improvement:
- feedback_type=3 (cancel) is recorded but not properly handled
- Duplicate feedback records are not deduplicated

* chore: remove unnecessary migration for new table will be created automatically

* chore: ruff format

* chore: prettier

* feat: add feedback handling support across multiple platform adapters

* fix(web): remove unused imports and variables in monitoring module

---------

Co-authored-by: 6mvp6 <13727783693@163.com>
Co-authored-by: Junyan Qin <rockchinq@gmail.com>
This commit is contained in:
6mvp6
2026-03-30 20:23:52 +08:00
committed by GitHub
parent 921d12f596
commit 6e37aae636
18 changed files with 4721 additions and 1110 deletions

8
.gitignore vendored
View File

@@ -52,3 +52,11 @@ src/langbot/web/
/dist /dist
/build /build
*.egg-info *.egg-info
# Docker 部署产生的本地文件
docker/data/
docker/docker-compose.override.yaml
# 备份目录
LangBot_backup_*/
*.bak

View File

@@ -10,14 +10,18 @@ FROM python:3.12.7-slim
WORKDIR /app WORKDIR /app
# Use Chinese mirror for faster and more reliable package downloads
RUN sed -i 's|deb.debian.org|mirrors.aliyun.com|g' /etc/apt/sources.list.d/debian.sources 2>/dev/null || \
sed -i 's|deb.debian.org|mirrors.aliyun.com|g' /etc/apt/sources.list 2>/dev/null || true
COPY . . COPY . .
COPY --from=node /app/web/out ./web/out COPY --from=node /app/web/out ./web/out
RUN apt update \ RUN apt update \
&& apt install gcc -y \ && apt install gcc -y \
&& python -m pip install --no-cache-dir uv \ && python -m pip install --no-cache-dir -i https://pypi.tuna.tsinghua.edu.cn/simple uv \
&& uv sync \ && uv sync --index-url https://pypi.tuna.tsinghua.edu.cn/simple \
&& touch /.dockerenv && touch /.dockerenv
CMD [ "uv", "run", "--no-sync", "main.py" ] CMD [ "uv", "run", "--no-sync", "main.py" ]

View File

@@ -64,6 +64,9 @@ class StreamSession:
# 缓存最近一次片段,处理重试或超时兜底 # 缓存最近一次片段,处理重试或超时兜底
last_chunk: Optional[StreamChunk] = None last_chunk: Optional[StreamChunk] = None
# 反馈 ID用于接收用户点赞/点踩反馈
feedback_id: Optional[str] = None
class StreamSessionManager: class StreamSessionManager:
"""管理 stream 会话的生命周期,并负责队列的生产消费。""" """管理 stream 会话的生命周期,并负责队列的生产消费。"""
@@ -74,6 +77,7 @@ class StreamSessionManager:
self.ttl = ttl # 超时时间(秒),超过该时间未被访问的会话会被清理由 cleanup self.ttl = ttl # 超时时间(秒),超过该时间未被访问的会话会被清理由 cleanup
self._sessions: dict[str, StreamSession] = {} # stream_id -> StreamSession 映射 self._sessions: dict[str, StreamSession] = {} # stream_id -> StreamSession 映射
self._msg_index: dict[str, str] = {} # msgid -> stream_id 映射,便于流水线根据消息 ID 找到会话 self._msg_index: dict[str, str] = {} # msgid -> stream_id 映射,便于流水线根据消息 ID 找到会话
self._feedback_index: dict[str, str] = {} # feedback_id -> stream_id 映射
def get_stream_id_by_msg(self, msg_id: str) -> Optional[str]: def get_stream_id_by_msg(self, msg_id: str) -> Optional[str]:
if not msg_id: if not msg_id:
@@ -83,6 +87,32 @@ class StreamSessionManager:
def get_session(self, stream_id: str) -> Optional[StreamSession]: def get_session(self, stream_id: str) -> Optional[StreamSession]:
return self._sessions.get(stream_id) return self._sessions.get(stream_id)
def get_session_by_feedback_id(self, feedback_id: str) -> Optional[StreamSession]:
"""根据 feedback_id 查找会话。
Args:
feedback_id: 企业微信反馈事件中的反馈 ID。
Returns:
Optional[StreamSession]: 找到的会话实例,未找到返回 None。
"""
if not feedback_id:
return None
stream_id = self._feedback_index.get(feedback_id)
if stream_id:
return self._sessions.get(stream_id)
return None
def register_feedback_id(self, stream_id: str, feedback_id: str) -> None:
"""注册 feedback_id 与 stream_id 的映射。
Args:
stream_id: 企业微信流式会话 ID。
feedback_id: 反馈 ID。
"""
if feedback_id and stream_id:
self._feedback_index[feedback_id] = stream_id
def create_or_get(self, msg_json: dict[str, Any]) -> tuple[StreamSession, bool]: def create_or_get(self, msg_json: dict[str, Any]) -> tuple[StreamSession, bool]:
"""根据企业微信回调创建或获取会话。 """根据企业微信回调创建或获取会话。
@@ -597,14 +627,27 @@ class WecomBotClient:
self.stream_sessions = StreamSessionManager(logger=logger) self.stream_sessions = StreamSessionManager(logger=logger)
self.stream_poll_timeout = 0.5 self.stream_poll_timeout = 0.5
self._feedback_callback: Optional[Callable] = None
def set_feedback_callback(self, callback: Callable) -> None:
"""设置反馈回调函数。
Args:
callback: 反馈回调函数,签名: async def callback(feedback_id, feedback_type, feedback_content, inaccurate_reasons, session)
"""
self._feedback_callback = callback
@staticmethod @staticmethod
def _build_stream_payload(stream_id: str, content: str, finish: bool) -> dict[str, Any]: def _build_stream_payload(
stream_id: str, content: str, finish: bool, feedback_id: Optional[str] = None
) -> dict[str, Any]:
"""按照企业微信协议拼装返回报文。 """按照企业微信协议拼装返回报文。
Args: Args:
stream_id: 企业微信会话 ID。 stream_id: 企业微信会话 ID。
content: 推送的文本内容。 content: 推送的文本内容。
finish: 是否为最终片段。 finish: 是否为最终片段。
feedback_id: 反馈 ID用于接收用户点赞/点踩反馈。
Returns: Returns:
dict[str, Any]: 可直接加密返回的 payload。 dict[str, Any]: 可直接加密返回的 payload。
@@ -612,13 +655,16 @@ class WecomBotClient:
Example: Example:
组装 `{'msgtype': 'stream', 'stream': {'id': 'sid', ...}}` 结构。 组装 `{'msgtype': 'stream', 'stream': {'id': 'sid', ...}}` 结构。
""" """
return { stream_payload = {
'msgtype': 'stream',
'stream': {
'id': stream_id, 'id': stream_id,
'finish': finish, 'finish': finish,
'content': content, 'content': content,
}, }
if feedback_id:
stream_payload['feedback'] = {'id': feedback_id}
return {
'msgtype': 'stream',
'stream': stream_payload,
} }
async def _encrypt_and_reply(self, payload: dict[str, Any], nonce: str) -> tuple[Response, int]: async def _encrypt_and_reply(self, payload: dict[str, Any], nonce: str) -> tuple[Response, int]:
@@ -674,9 +720,14 @@ class WecomBotClient:
""" """
session, is_new = self.stream_sessions.create_or_get(msg_json) session, is_new = self.stream_sessions.create_or_get(msg_json)
feedback_id = str(uuid.uuid4())
session.feedback_id = feedback_id
self.stream_sessions.register_feedback_id(session.stream_id, feedback_id)
message_data = await self.get_message(msg_json) message_data = await self.get_message(msg_json)
if message_data: if message_data:
message_data['stream_id'] = session.stream_id message_data['stream_id'] = session.stream_id
message_data['feedback_id'] = feedback_id
try: try:
event = wecombotevent.WecomBotEvent(message_data) event = wecombotevent.WecomBotEvent(message_data)
except Exception: except Exception:
@@ -685,7 +736,7 @@ class WecomBotClient:
if is_new: if is_new:
asyncio.create_task(self._dispatch_event(event)) asyncio.create_task(self._dispatch_event(event))
payload = self._build_stream_payload(session.stream_id, '', False) payload = self._build_stream_payload(session.stream_id, '', False, feedback_id)
return await self._encrypt_and_reply(payload, nonce) return await self._encrypt_and_reply(payload, nonce)
async def _handle_post_followup_response(self, msg_json: dict[str, Any], nonce: str) -> tuple[Response, int]: async def _handle_post_followup_response(self, msg_json: dict[str, Any], nonce: str) -> tuple[Response, int]:
@@ -810,11 +861,78 @@ class WecomBotClient:
msg_json = json.loads(decrypted_xml) msg_json = json.loads(decrypted_xml)
event = msg_json.get('event', {})
event_type = event.get('eventtype', '')
if event_type == 'feedback_event':
return await self._handle_feedback_event(msg_json, nonce)
if msg_json.get('msgtype') == 'stream': if msg_json.get('msgtype') == 'stream':
return await self._handle_post_followup_response(msg_json, nonce) return await self._handle_post_followup_response(msg_json, nonce)
return await self._handle_post_initial_response(msg_json, nonce) return await self._handle_post_initial_response(msg_json, nonce)
async def _handle_feedback_event(self, msg_json: dict[str, Any], nonce: str) -> tuple[Response, int]:
"""处理企业微信用户反馈事件(点赞/点踩)。
Args:
msg_json: 解密后的企业微信反馈事件 JSON。
nonce: 企业微信回调参数 nonce。
Returns:
Tuple[Response, int]: Quart Response 及状态码。
Note:
企业微信协议要求:反馈事件目前仅支持回复空包。
"""
try:
feedback_event = msg_json.get('event', {}).get('feedback_event', {})
feedback_id = feedback_event.get('id', '')
feedback_type = feedback_event.get('type', 0)
feedback_content = feedback_event.get('content', '')
inaccurate_reasons = feedback_event.get('inaccurate_reason_list', [])
await self.logger.info(
f'收到用户反馈事件: feedback_id={feedback_id}, type={feedback_type}, '
f'content={feedback_content}, reasons={inaccurate_reasons}'
)
session = self.stream_sessions.get_session_by_feedback_id(feedback_id)
if session:
await self.logger.info(
f'反馈关联到会话: stream_id={session.stream_id}, msg_id={session.msg_id}, user_id={session.user_id}'
)
for handler in self._message_handlers.get('feedback', []):
try:
await handler(
feedback_id=feedback_id,
feedback_type=feedback_type,
feedback_content=feedback_content,
inaccurate_reasons=inaccurate_reasons,
session=session,
)
except Exception:
await self.logger.error(traceback.format_exc())
if self._feedback_callback:
try:
await self._feedback_callback(
feedback_id=feedback_id,
feedback_type=feedback_type,
feedback_content=feedback_content,
inaccurate_reasons=inaccurate_reasons,
session=session,
)
except Exception:
await self.logger.error(traceback.format_exc())
else:
await self.logger.warning(f'未找到 feedback_id={feedback_id} 对应的会话')
except Exception:
await self.logger.error(traceback.format_exc())
return await self._encrypt_and_reply({}, nonce)
async def get_message(self, msg_json): async def get_message(self, msg_json):
return await parse_wecom_bot_message(msg_json, self.EnCodingAESKey, self.logger) return await parse_wecom_bot_message(msg_json, self.EnCodingAESKey, self.logger)
@@ -883,6 +1001,15 @@ class WecomBotClient:
return decorator return decorator
def on_feedback(self):
def decorator(func: Callable):
if 'feedback' not in self._message_handlers:
self._message_handlers['feedback'] = []
self._message_handlers['feedback'].append(func)
return func
return decorator
async def download_url_to_base64(self, download_url, encoding_aes_key): async def download_url_to_base64(self, download_url, encoding_aes_key):
data, _filename = await download_encrypted_file(download_url, encoding_aes_key, self.logger) data, _filename = await download_encrypted_file(download_url, encoding_aes_key, self.logger)
if data: if data:

View File

@@ -133,3 +133,17 @@ class WecomBotEvent(dict):
AI Bot ID AI Bot ID
""" """
return self.get('aibotid', '') return self.get('aibotid', '')
@property
def feedback_id(self) -> str:
"""
反馈 ID用于关联用户点赞/点踩反馈
"""
return self.get('feedback_id', '')
@property
def stream_id(self) -> str:
"""
流式消息 ID
"""
return self.get('stream_id', '')

View File

@@ -456,6 +456,31 @@ class MonitoringRouterGroup(group.RouterGroup):
'platform', 'platform',
'user_id', 'user_id',
] ]
elif export_type == 'feedback':
data = await self.ap.monitoring_service.export_feedback(
bot_ids=bot_ids if bot_ids else None,
pipeline_ids=pipeline_ids if pipeline_ids else None,
start_time=start_time,
end_time=end_time,
limit=limit,
)
headers = [
'id',
'timestamp',
'feedback_id',
'feedback_type',
'feedback_content',
'inaccurate_reasons',
'bot_id',
'bot_name',
'pipeline_id',
'pipeline_name',
'session_id',
'message_id',
'stream_id',
'user_id',
'platform',
]
else: else:
return self.error(message=f'Invalid export type: {export_type}', code=400) return self.error(message=f'Invalid export type: {export_type}', code=400)
@@ -486,3 +511,63 @@ class MonitoringRouterGroup(group.RouterGroup):
) )
return response, 200 return response, 200
@self.route('/feedback/stats', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
async def get_feedback_stats() -> str:
"""Get feedback statistics"""
# Parse query parameters
bot_ids = quart.request.args.getlist('botId')
pipeline_ids = quart.request.args.getlist('pipelineId')
start_time_str = quart.request.args.get('startTime')
end_time_str = quart.request.args.get('endTime')
# Parse datetime
start_time = parse_iso_datetime(start_time_str)
end_time = parse_iso_datetime(end_time_str)
stats = await self.ap.monitoring_service.get_feedback_stats(
bot_ids=bot_ids if bot_ids else None,
pipeline_ids=pipeline_ids if pipeline_ids else None,
start_time=start_time,
end_time=end_time,
)
return self.success(data=stats)
@self.route('/feedback', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
async def get_feedback() -> str:
"""Get feedback list"""
# Parse query parameters
bot_ids = quart.request.args.getlist('botId')
pipeline_ids = quart.request.args.getlist('pipelineId')
feedback_type_str = quart.request.args.get('feedbackType')
start_time_str = quart.request.args.get('startTime')
end_time_str = quart.request.args.get('endTime')
limit = int(quart.request.args.get('limit', 100))
offset = int(quart.request.args.get('offset', 0))
# Parse datetime
start_time = parse_iso_datetime(start_time_str)
end_time = parse_iso_datetime(end_time_str)
# Parse feedback type
feedback_type = int(feedback_type_str) if feedback_type_str else None
feedback_list, total = await self.ap.monitoring_service.get_feedback_list(
bot_ids=bot_ids if bot_ids else None,
pipeline_ids=pipeline_ids if pipeline_ids else None,
feedback_type=feedback_type,
start_time=start_time,
end_time=end_time,
limit=limit,
offset=offset,
)
return self.success(
data={
'feedback': feedback_list,
'total': total,
'limit': limit,
'offset': offset,
}
)

View File

@@ -1183,3 +1183,261 @@ class MonitoringService:
} }
for row in rows for row in rows
] ]
# ========== Feedback Methods ==========
async def record_feedback(
self,
feedback_id: str,
feedback_type: int,
feedback_content: str | None = None,
inaccurate_reasons: list[str] | None = None,
bot_id: str | None = None,
bot_name: str | None = None,
pipeline_id: str | None = None,
pipeline_name: str | None = None,
session_id: str | None = None,
message_id: str | None = None,
stream_id: str | None = None,
user_id: str | None = None,
platform: str | None = None,
) -> str:
"""Record user feedback (like/dislike) from AI Bot conversation.
Args:
feedback_id: Unique feedback identifier from platform (e.g., WeChat Work)
feedback_type: 1 = like (thumbs up), 2 = dislike (thumbs down)
feedback_content: Optional user feedback text
inaccurate_reasons: List of reasons for inaccurate response (for dislike)
bot_id: Bot ID
bot_name: Bot name
pipeline_id: Pipeline ID
pipeline_name: Pipeline name
session_id: Session ID
message_id: Message ID
stream_id: Stream ID (for WeChat Work streaming messages)
user_id: User ID
platform: Platform name (e.g., 'wecom')
Returns:
The record ID
"""
import json
record_id = str(uuid.uuid4())
record_data = {
'id': record_id,
'timestamp': datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None),
'feedback_id': feedback_id,
'feedback_type': feedback_type,
'feedback_content': feedback_content,
'inaccurate_reasons': json.dumps(inaccurate_reasons, ensure_ascii=False) if inaccurate_reasons else None,
'bot_id': bot_id,
'bot_name': bot_name,
'pipeline_id': pipeline_id,
'pipeline_name': pipeline_name,
'session_id': session_id,
'message_id': message_id,
'stream_id': stream_id,
'user_id': user_id,
'platform': platform,
}
await self.ap.persistence_mgr.execute_async(
sqlalchemy.insert(persistence_monitoring.MonitoringFeedback).values(record_data)
)
return record_id
async def get_feedback_stats(
self,
bot_ids: list[str] | None = None,
pipeline_ids: list[str] | None = None,
start_time: datetime.datetime | None = None,
end_time: datetime.datetime | None = None,
) -> dict:
"""Get feedback statistics.
Returns:
Dictionary with total likes, dislikes, and breakdown by bot/pipeline
"""
conditions = []
if bot_ids:
conditions.append(persistence_monitoring.MonitoringFeedback.bot_id.in_(bot_ids))
if pipeline_ids:
conditions.append(persistence_monitoring.MonitoringFeedback.pipeline_id.in_(pipeline_ids))
if start_time:
conditions.append(persistence_monitoring.MonitoringFeedback.timestamp >= start_time)
if end_time:
conditions.append(persistence_monitoring.MonitoringFeedback.timestamp <= end_time)
# Get total likes (feedback_type = 1)
likes_query = sqlalchemy.select(sqlalchemy.func.count(persistence_monitoring.MonitoringFeedback.id)).where(
persistence_monitoring.MonitoringFeedback.feedback_type == 1
)
if conditions:
likes_query = likes_query.where(sqlalchemy.and_(*conditions))
likes_result = await self.ap.persistence_mgr.execute_async(likes_query)
total_likes = likes_result.scalar() or 0
# Get total dislikes (feedback_type = 2)
dislikes_query = sqlalchemy.select(sqlalchemy.func.count(persistence_monitoring.MonitoringFeedback.id)).where(
persistence_monitoring.MonitoringFeedback.feedback_type == 2
)
if conditions:
dislikes_query = dislikes_query.where(sqlalchemy.and_(*conditions))
dislikes_result = await self.ap.persistence_mgr.execute_async(dislikes_query)
total_dislikes = dislikes_result.scalar() or 0
# Get total feedback count
total_query = sqlalchemy.select(sqlalchemy.func.count(persistence_monitoring.MonitoringFeedback.id))
if conditions:
total_query = total_query.where(sqlalchemy.and_(*conditions))
total_result = await self.ap.persistence_mgr.execute_async(total_query)
total_feedback = total_result.scalar() or 0
# Calculate satisfaction rate
satisfaction_rate = (total_likes / total_feedback * 100) if total_feedback > 0 else 0
# Get feedback by bot
bot_stats_query = sqlalchemy.select(
persistence_monitoring.MonitoringFeedback.bot_id,
persistence_monitoring.MonitoringFeedback.bot_name,
sqlalchemy.func.count(persistence_monitoring.MonitoringFeedback.id).label('total'),
sqlalchemy.func.sum(
sqlalchemy.case((persistence_monitoring.MonitoringFeedback.feedback_type == 1, 1), else_=0)
).label('likes'),
sqlalchemy.func.sum(
sqlalchemy.case((persistence_monitoring.MonitoringFeedback.feedback_type == 2, 1), else_=0)
).label('dislikes'),
).group_by(
persistence_monitoring.MonitoringFeedback.bot_id,
persistence_monitoring.MonitoringFeedback.bot_name,
)
if conditions:
bot_stats_query = bot_stats_query.where(sqlalchemy.and_(*conditions))
bot_stats_result = await self.ap.persistence_mgr.execute_async(bot_stats_query)
bot_stats = [
{
'bot_id': row.bot_id,
'bot_name': row.bot_name,
'total': row.total,
'likes': row.likes or 0,
'dislikes': row.dislikes or 0,
}
for row in bot_stats_result.all()
]
return {
'total_feedback': total_feedback,
'total_likes': total_likes,
'total_dislikes': total_dislikes,
'satisfaction_rate': round(satisfaction_rate, 2),
'by_bot': bot_stats,
}
async def get_feedback_list(
self,
bot_ids: list[str] | None = None,
pipeline_ids: list[str] | None = None,
feedback_type: int | None = None,
start_time: datetime.datetime | None = None,
end_time: datetime.datetime | None = None,
limit: int = 100,
offset: int = 0,
) -> tuple[list[dict], int]:
"""Get feedback list with filters."""
conditions = []
if bot_ids:
conditions.append(persistence_monitoring.MonitoringFeedback.bot_id.in_(bot_ids))
if pipeline_ids:
conditions.append(persistence_monitoring.MonitoringFeedback.pipeline_id.in_(pipeline_ids))
if feedback_type is not None:
conditions.append(persistence_monitoring.MonitoringFeedback.feedback_type == feedback_type)
if start_time:
conditions.append(persistence_monitoring.MonitoringFeedback.timestamp >= start_time)
if end_time:
conditions.append(persistence_monitoring.MonitoringFeedback.timestamp <= end_time)
# Get total count
count_query = sqlalchemy.select(sqlalchemy.func.count(persistence_monitoring.MonitoringFeedback.id))
if conditions:
count_query = count_query.where(sqlalchemy.and_(*conditions))
count_result = await self.ap.persistence_mgr.execute_async(count_query)
total = count_result.scalar() or 0
# Get feedback list
query = sqlalchemy.select(persistence_monitoring.MonitoringFeedback).order_by(
persistence_monitoring.MonitoringFeedback.timestamp.desc()
)
if conditions:
query = query.where(sqlalchemy.and_(*conditions))
query = query.limit(limit).offset(offset)
result = await self.ap.persistence_mgr.execute_async(query)
rows = result.all()
return (
[
self.ap.persistence_mgr.serialize_model(
persistence_monitoring.MonitoringFeedback, row[0] if isinstance(row, tuple) else row
)
for row in rows
],
total,
)
async def export_feedback(
self,
bot_ids: list[str] | None = None,
pipeline_ids: list[str] | None = None,
start_time: datetime.datetime | None = None,
end_time: datetime.datetime | None = None,
limit: int = 100000,
) -> list[dict]:
"""Export feedback as list of dictionaries for CSV conversion."""
conditions = []
if bot_ids:
conditions.append(persistence_monitoring.MonitoringFeedback.bot_id.in_(bot_ids))
if pipeline_ids:
conditions.append(persistence_monitoring.MonitoringFeedback.pipeline_id.in_(pipeline_ids))
if start_time:
conditions.append(persistence_monitoring.MonitoringFeedback.timestamp >= start_time)
if end_time:
conditions.append(persistence_monitoring.MonitoringFeedback.timestamp <= end_time)
query = sqlalchemy.select(persistence_monitoring.MonitoringFeedback).order_by(
persistence_monitoring.MonitoringFeedback.timestamp.desc()
)
if conditions:
query = query.where(sqlalchemy.and_(*conditions))
query = query.limit(limit)
result = await self.ap.persistence_mgr.execute_async(query)
rows = result.all()
return [
{
'id': row[0].id if isinstance(row, tuple) else row.id,
'timestamp': self._format_timestamp(row[0].timestamp if isinstance(row, tuple) else row.timestamp),
'feedback_id': row[0].feedback_id if isinstance(row, tuple) else row.feedback_id,
'feedback_type': 'like'
if (row[0].feedback_type if isinstance(row, tuple) else row.feedback_type) == 1
else 'dislike',
'feedback_content': row[0].feedback_content if isinstance(row, tuple) else row.feedback_content,
'inaccurate_reasons': row[0].inaccurate_reasons if isinstance(row, tuple) else row.inaccurate_reasons,
'bot_id': row[0].bot_id if isinstance(row, tuple) else row.bot_id,
'bot_name': row[0].bot_name if isinstance(row, tuple) else row.bot_name,
'pipeline_id': row[0].pipeline_id if isinstance(row, tuple) else row.pipeline_id,
'pipeline_name': row[0].pipeline_name if isinstance(row, tuple) else row.pipeline_name,
'session_id': row[0].session_id if isinstance(row, tuple) else row.session_id,
'message_id': row[0].message_id if isinstance(row, tuple) else row.message_id,
'stream_id': row[0].stream_id if isinstance(row, tuple) else row.stream_id,
'user_id': row[0].user_id if isinstance(row, tuple) else row.user_id,
'platform': row[0].platform if isinstance(row, tuple) else row.platform,
}
for row in rows
]

View File

@@ -106,3 +106,26 @@ class MonitoringEmbeddingCall(Base):
session_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True, index=True) session_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True, index=True)
message_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True, index=True) message_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True, index=True)
call_type = sqlalchemy.Column(sqlalchemy.String(50), nullable=True) # embedding, retrieve call_type = sqlalchemy.Column(sqlalchemy.String(50), nullable=True) # embedding, retrieve
class MonitoringFeedback(Base):
"""User feedback records (like/dislike) from AI Bot conversations"""
__tablename__ = 'monitoring_feedback'
id = sqlalchemy.Column(sqlalchemy.String(255), primary_key=True)
timestamp = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, index=True)
feedback_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=False, unique=True, index=True)
feedback_type = sqlalchemy.Column(sqlalchemy.Integer, nullable=False) # 1=like, 2=dislike
feedback_content = sqlalchemy.Column(sqlalchemy.Text, nullable=True) # User feedback text
inaccurate_reasons = sqlalchemy.Column(sqlalchemy.Text, nullable=True) # JSON list of inaccurate reasons
# Context fields
bot_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True, index=True)
bot_name = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
pipeline_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True, index=True)
pipeline_name = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
session_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True, index=True)
message_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True, index=True)
stream_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True, index=True)
user_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
platform = sqlalchemy.Column(sqlalchemy.String(255), nullable=True) # e.g., wecom

View File

@@ -9,6 +9,7 @@ from ..core import app, entities as core_entities, taskmgr
from ..discover import engine from ..discover import engine
from ..entity.persistence import bot as persistence_bot from ..entity.persistence import bot as persistence_bot
from ..entity.persistence import pipeline as persistence_pipeline
from ..entity.errors import platform as platform_errors from ..entity.errors import platform as platform_errors
@@ -141,6 +142,50 @@ class RuntimeBot:
self.adapter.register_listener(platform_events.FriendMessage, on_friend_message) self.adapter.register_listener(platform_events.FriendMessage, on_friend_message)
self.adapter.register_listener(platform_events.GroupMessage, on_group_message) self.adapter.register_listener(platform_events.GroupMessage, on_group_message)
# Register feedback listener (only effective on adapters that support it)
async def on_feedback(
event: platform_events.FeedbackEvent,
adapter: abstract_platform_adapter.AbstractMessagePlatformAdapter,
):
try:
# Resolve pipeline name
pipeline_name = ''
if self.bot_entity.use_pipeline_uuid:
try:
pipeline_result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(persistence_pipeline.LegacyPipeline.name).where(
persistence_pipeline.LegacyPipeline.uuid == self.bot_entity.use_pipeline_uuid
)
)
pipeline_row = pipeline_result.first()
if pipeline_row:
pipeline_name = pipeline_row[0]
except Exception:
pass
await self.ap.monitoring_service.record_feedback(
feedback_id=event.feedback_id,
feedback_type=event.feedback_type,
feedback_content=event.feedback_content,
inaccurate_reasons=event.inaccurate_reasons,
bot_id=self.bot_entity.uuid,
bot_name=self.bot_entity.name,
pipeline_id=self.bot_entity.use_pipeline_uuid or '',
pipeline_name=pipeline_name,
session_id=event.session_id,
message_id=event.message_id,
stream_id=event.stream_id,
user_id=event.user_id,
platform=adapter.__class__.__name__,
)
await self.logger.info(
f'Recorded feedback: feedback_id={event.feedback_id}, type={event.feedback_type}'
)
except Exception:
await self.logger.error(f'Failed to record feedback: {traceback.format_exc()}')
self.adapter.register_listener(platform_events.FeedbackEvent, on_feedback)
async def run(self): async def run(self):
async def exception_wrapper(): async def exception_wrapper():
try: try:

View File

@@ -311,6 +311,9 @@ class WecomBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
self.bot.on_message('single')(self.on_message) self.bot.on_message('single')(self.on_message)
elif event_type == platform_events.GroupMessage: elif event_type == platform_events.GroupMessage:
self.bot.on_message('group')(self.on_message) self.bot.on_message('group')(self.on_message)
elif event_type == platform_events.FeedbackEvent:
if hasattr(self.bot, 'on_feedback'):
self.bot.on_feedback()(self._on_feedback)
except Exception: except Exception:
print(traceback.format_exc()) print(traceback.format_exc())
@@ -318,6 +321,45 @@ 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_feedback(self, **kwargs):
"""Handle feedback event from WeChat Work AI Bot SDK and dispatch as FeedbackEvent."""
try:
feedback_id = kwargs.get('feedback_id', '')
feedback_type = kwargs.get('feedback_type', 0)
feedback_content = kwargs.get('feedback_content', '') or None
inaccurate_reasons = kwargs.get('inaccurate_reasons', []) or None
session = kwargs.get('session')
session_id = None
user_id = None
message_id = None
stream_id = None
if session:
if session.chat_id:
session_id = f'group_{session.chat_id}'
elif session.user_id:
session_id = f'person_{session.user_id}'
user_id = session.user_id
message_id = session.msg_id
stream_id = session.stream_id
event = platform_events.FeedbackEvent(
feedback_id=feedback_id,
feedback_type=feedback_type,
feedback_content=feedback_content,
inaccurate_reasons=inaccurate_reasons,
user_id=user_id,
session_id=session_id,
message_id=message_id,
stream_id=stream_id,
source_platform_object=session,
)
if platform_events.FeedbackEvent in self.listeners:
await self.listeners[platform_events.FeedbackEvent](event, self)
except Exception:
await self.logger.error(f'Error in wecombot feedback callback: {traceback.format_exc()}')
async def handle_unified_webhook(self, bot_uuid: str, path: str, request): async def handle_unified_webhook(self, bot_uuid: str, path: str, request):
_ws_mode = not self.config.get('enable-webhook', False) _ws_mode = not self.config.get('enable-webhook', False)
if _ws_mode: if _ws_mode:

4375
web/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,187 @@
'use client';
import React from 'react';
import { useTranslation } from 'react-i18next';
import {
ThumbsUp,
ThumbsDown,
TrendingUp,
TrendingDown,
Minus,
} from 'lucide-react';
interface FeedbackCardProps {
title: string;
value: number | string;
subtitle?: string;
icon: React.ReactNode;
trend?: {
value: number;
direction: 'up' | 'down' | 'neutral';
};
variant?: 'default' | 'success' | 'warning' | 'danger';
loading?: boolean;
}
export function FeedbackCard({
title,
value,
subtitle,
icon,
trend,
variant = 'default',
loading = false,
}: FeedbackCardProps) {
const variantStyles = {
default: 'bg-white dark:bg-[#2a2a2e] border-gray-200 dark:border-gray-700',
success:
'bg-green-50 dark:bg-green-900/20 border-green-200 dark:border-green-800',
warning:
'bg-yellow-50 dark:bg-yellow-900/20 border-yellow-200 dark:border-yellow-800',
danger: 'bg-red-50 dark:bg-red-900/20 border-red-200 dark:border-red-800',
};
const iconStyles = {
default: 'text-gray-500 dark:text-gray-400',
success: 'text-green-500 dark:text-green-400',
warning: 'text-yellow-500 dark:text-yellow-400',
danger: 'text-red-500 dark:text-red-400',
};
const trendStyles = {
up: 'text-green-500',
down: 'text-red-500',
neutral: 'text-gray-500',
};
if (loading) {
return (
<div
className={`p-6 rounded-xl border shadow-sm ${variantStyles.default} animate-pulse`}
>
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-20 mb-2" />
<div className="h-8 bg-gray-200 dark:bg-gray-700 rounded w-16 mb-1" />
<div className="h-3 bg-gray-200 dark:bg-gray-700 rounded w-24" />
</div>
<div className="w-10 h-10 bg-gray-200 dark:bg-gray-700 rounded-lg" />
</div>
</div>
);
}
return (
<div
className={`p-6 rounded-xl border shadow-sm ${variantStyles[variant]}`}
>
<div className="flex items-start justify-between">
<div className="flex-1">
<p className="text-sm font-medium text-gray-500 dark:text-gray-400 mb-1">
{title}
</p>
<p className="text-2xl font-bold text-gray-900 dark:text-white">
{value}
</p>
{subtitle && (
<p className="text-xs text-gray-400 dark:text-gray-500 mt-1">
{subtitle}
</p>
)}
{trend && (
<div
className={`flex items-center mt-2 text-sm ${trendStyles[trend.direction]}`}
>
{trend.direction === 'up' && (
<TrendingUp className="w-4 h-4 mr-1" />
)}
{trend.direction === 'down' && (
<TrendingDown className="w-4 h-4 mr-1" />
)}
{trend.direction === 'neutral' && (
<Minus className="w-4 h-4 mr-1" />
)}
<span>
{trend.value > 0 ? '+' : ''}
{trend.value}%
</span>
</div>
)}
</div>
<div
className={`p-3 rounded-lg bg-gray-100 dark:bg-gray-800 ${iconStyles[variant]}`}
>
{icon}
</div>
</div>
</div>
);
}
interface FeedbackStatsProps {
stats: {
totalFeedback: number;
totalLikes: number;
totalDislikes: number;
satisfactionRate: number;
} | null;
loading?: boolean;
}
export function FeedbackStatsCards({ stats, loading }: FeedbackStatsProps) {
const { t } = useTranslation();
const cards = [
{
title: t('monitoring.feedback.totalFeedback'),
value: stats?.totalFeedback ?? 0,
icon: (
<svg className="w-6 h-6" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z" />
</svg>
),
variant: 'default' as const,
},
{
title: t('monitoring.feedback.totalLikes'),
value: stats?.totalLikes ?? 0,
icon: <ThumbsUp className="w-6 h-6" />,
variant: 'success' as const,
},
{
title: t('monitoring.feedback.totalDislikes'),
value: stats?.totalDislikes ?? 0,
icon: <ThumbsDown className="w-6 h-6" />,
variant: 'danger' as const,
},
{
title: t('monitoring.feedback.satisfactionRate'),
value: stats ? `${stats.satisfactionRate}%` : '0%',
icon: (
<svg className="w-6 h-6" viewBox="0 0 24 24" fill="currentColor">
<path d="M11.99 2C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zM12 20c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8zm3.5-9c.83 0 1.5-.67 1.5-1.5S16.33 8 15.5 8 14 8.67 14 9.5s.67 1.5 1.5 1.5zm-7 0c.83 0 1.5-.67 1.5-1.5S9.33 8 8.5 8 7 8.67 7 9.5 7.67 11 8.5 11zm3.5 6.5c2.33 0 4.31-1.46 5.11-3.5H6.89c.8 2.04 2.78 3.5 5.11 3.5z" />
</svg>
),
variant: (stats && stats.satisfactionRate >= 80
? 'success'
: stats && stats.satisfactionRate >= 50
? 'warning'
: 'danger') as 'default' | 'success' | 'warning' | 'danger',
},
];
return (
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-6">
{cards.map((card, index) => (
<FeedbackCard
key={index}
title={card.title}
value={card.value}
icon={card.icon}
variant={card.variant}
loading={loading}
/>
))}
</div>
);
}

View File

@@ -0,0 +1,275 @@
'use client';
import React from 'react';
import { useTranslation } from 'react-i18next';
import {
ThumbsUp,
ThumbsDown,
ChevronRight,
ChevronDown,
ExternalLink,
} from 'lucide-react';
import { FeedbackRecord } from '../types/monitoring';
import { Button } from '@/components/ui/button';
import { LoadingSpinner } from '@/components/ui/loading-spinner';
interface FeedbackListProps {
feedback: FeedbackRecord[];
loading?: boolean;
onViewMessage?: (messageId: string) => void;
}
export function FeedbackList({
feedback,
loading,
onViewMessage,
}: FeedbackListProps) {
const { t } = useTranslation();
const [expandedId, setExpandedId] = React.useState<string | null>(null);
const toggleExpand = (id: string) => {
setExpandedId(expandedId === id ? null : id);
};
if (loading) {
return (
<div className="py-12 flex justify-center">
<LoadingSpinner text={t('common.loading')} />
</div>
);
}
if (!feedback || feedback.length === 0) {
return (
<div className="text-center text-gray-500 dark:text-gray-400 py-16">
<svg
className="w-16 h-16 mx-auto mb-4 text-gray-300 dark:text-gray-600"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z"
/>
</svg>
<p className="text-base font-medium mb-2">
{t('monitoring.feedback.noFeedback')}
</p>
<p className="text-sm">
{t('monitoring.feedback.noFeedbackDescription')}
</p>
</div>
);
}
return (
<div className="space-y-4">
{feedback.map((item) => (
<div
key={item.id}
className={`border rounded-xl overflow-hidden hover:shadow-md transition-all duration-200 ${
item.feedbackType === 'like'
? 'border-green-200 dark:border-green-900'
: 'border-red-200 dark:border-red-900'
}`}
>
{/* Header */}
<div
className={`p-5 cursor-pointer transition-colors ${
item.feedbackType === 'like'
? 'hover:bg-green-50 dark:hover:bg-green-950/50 bg-green-50/50 dark:bg-green-950/30'
: 'hover:bg-red-50 dark:hover:bg-red-950/50 bg-red-50/50 dark:bg-red-950/30'
}`}
onClick={() => toggleExpand(item.id)}
>
<div className="flex items-start justify-between">
<div className="flex items-start flex-1">
{/* Expand Icon */}
<div className="mr-3 mt-0.5">
{expandedId === item.id ? (
<ChevronDown
className={`w-5 h-5 ${item.feedbackType === 'like' ? 'text-green-500' : 'text-red-500'}`}
/>
) : (
<ChevronRight
className={`w-5 h-5 ${item.feedbackType === 'like' ? 'text-green-500' : 'text-red-500'}`}
/>
)}
</div>
{/* Content */}
<div className="flex-1">
<div className="flex items-center gap-2 mb-2">
{/* Feedback Type Icon */}
{item.feedbackType === 'like' ? (
<ThumbsUp className="w-5 h-5 text-green-500" />
) : (
<ThumbsDown className="w-5 h-5 text-red-500" />
)}
<span
className={`text-sm font-medium ${item.feedbackType === 'like' ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400'}`}
>
{item.feedbackType === 'like'
? t('monitoring.feedback.like')
: t('monitoring.feedback.dislike')}
</span>
{item.botName && (
<>
<span className="text-gray-400"></span>
<span className="text-sm text-gray-600 dark:text-gray-400">
{item.botName}
</span>
</>
)}
{item.platform && (
<span className="text-xs px-2 py-0.5 rounded bg-gray-100 dark:bg-gray-800 text-gray-500 dark:text-gray-400">
{item.platform}
</span>
)}
</div>
{item.feedbackContent && (
<p className="text-sm text-gray-600 dark:text-gray-400 line-clamp-2">
{item.feedbackContent}
</p>
)}
{item.inaccurateReasons &&
item.inaccurateReasons.length > 0 && (
<div className="flex flex-wrap gap-1 mt-2">
{item.inaccurateReasons.map((reason, idx) => (
<span
key={idx}
className="text-xs px-2 py-0.5 rounded bg-red-100 dark:bg-red-900/30 text-red-600 dark:text-red-400"
>
{reason}
</span>
))}
</div>
)}
</div>
</div>
{/* Timestamp */}
<div className="flex flex-col items-end gap-2 ml-4">
<span className="text-xs text-gray-500 dark:text-gray-400 whitespace-nowrap">
{item.timestamp.toLocaleString()}
</span>
</div>
</div>
</div>
{/* Expanded Details */}
{expandedId === item.id && (
<div
className={`border-t p-5 bg-white dark:bg-gray-900 ${
item.feedbackType === 'like'
? 'border-green-200 dark:border-green-900'
: 'border-red-200 dark:border-red-900'
}`}
>
<div className="space-y-4 pl-8 border-l-2 border-gray-200 dark:border-gray-700 ml-4">
{/* Context Info */}
<div className="bg-gray-50 dark:bg-gray-800 rounded-lg p-3">
<h4 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3">
{t('monitoring.feedback.contextInfo')}
</h4>
<div className="grid grid-cols-2 md:grid-cols-3 gap-2 text-xs">
{item.botName && (
<div className="bg-white dark:bg-gray-900 rounded p-2">
<div className="text-gray-500 dark:text-gray-400">
{t('monitoring.messageList.bot')}
</div>
<div className="font-medium text-gray-900 dark:text-white truncate">
{item.botName}
</div>
</div>
)}
{item.pipelineName && (
<div className="bg-white dark:bg-gray-900 rounded p-2">
<div className="text-gray-500 dark:text-gray-400">
{t('monitoring.messageList.pipeline')}
</div>
<div className="font-medium text-gray-900 dark:text-white truncate">
{item.pipelineName}
</div>
</div>
)}
{item.sessionId && (
<div className="bg-white dark:bg-gray-900 rounded p-2">
<div className="text-gray-500 dark:text-gray-400">
{t('monitoring.sessions.sessionId')}
</div>
<div className="font-medium text-gray-900 dark:text-white truncate">
{item.sessionId}
</div>
</div>
)}
{item.userId && (
<div className="bg-white dark:bg-gray-900 rounded p-2">
<div className="text-gray-500 dark:text-gray-400">
{t('monitoring.feedback.userId')}
</div>
<div className="font-medium text-gray-900 dark:text-white truncate">
{item.userId}
</div>
</div>
)}
{item.messageId && (
<div className="bg-white dark:bg-gray-900 rounded p-2">
<div className="text-gray-500 dark:text-gray-400">
{t('monitoring.feedback.messageId')}
</div>
<div className="font-medium text-gray-900 dark:text-white truncate flex items-center gap-1">
<span className="truncate">{item.messageId}</span>
{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>
)}
{item.streamId && (
<div className="bg-white dark:bg-gray-900 rounded p-2">
<div className="text-gray-500 dark:text-gray-400">
{t('monitoring.feedback.streamId')}
</div>
<div className="font-medium text-gray-900 dark:text-white truncate">
{item.streamId}
</div>
</div>
)}
</div>
</div>
{/* Feedback Content */}
{item.feedbackContent && (
<div className="bg-gray-50 dark:bg-gray-800 rounded-lg p-3">
<h4 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3">
{t('monitoring.feedback.feedbackContent')}
</h4>
<p className="text-sm text-gray-600 dark:text-gray-400 whitespace-pre-wrap">
{item.feedbackContent}
</p>
</div>
)}
</div>
</div>
)}
</div>
))}
</div>
);
}

View File

@@ -0,0 +1,192 @@
import { useState, useEffect, useCallback, useMemo } from 'react';
import { httpClient } from '@/app/infra/http';
import { FeedbackRecord, FeedbackStats } from '../types/monitoring';
interface UseFeedbackDataParams {
botIds?: string[];
pipelineIds?: string[];
startTime?: string;
endTime?: string;
feedbackType?: 'like' | 'dislike';
limit?: number;
offset?: number;
}
interface RawFeedbackRecord {
id: string;
timestamp: string;
feedback_id: string;
feedback_type: number;
feedback_content?: string;
inaccurate_reasons?: string;
bot_id?: string;
bot_name?: string;
pipeline_id?: string;
pipeline_name?: string;
session_id?: string;
message_id?: string;
stream_id?: string;
user_id?: string;
platform?: string;
}
interface RawFeedbackStats {
total_feedback: number;
total_likes: number;
total_dislikes: number;
satisfaction_rate: number;
by_bot?: Array<{
bot_id: string;
bot_name: string;
total: number;
likes: number;
dislikes: number;
}>;
}
/**
* Custom hook for fetching and managing feedback data
*/
export function useFeedbackData(params: UseFeedbackDataParams = {}) {
const [feedback, setFeedback] = useState<FeedbackRecord[]>([]);
const [stats, setStats] = useState<FeedbackStats | null>(null);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<Error | null>(null);
const paramsStr = useMemo(() => JSON.stringify(params), [params]);
const fetchStats = useCallback(async () => {
try {
const queryParams = new URLSearchParams();
if (params.botIds) {
params.botIds.forEach((id) => queryParams.append('botId', id));
}
if (params.pipelineIds) {
params.pipelineIds.forEach((id) =>
queryParams.append('pipelineId', id),
);
}
if (params.startTime) {
queryParams.append('startTime', params.startTime);
}
if (params.endTime) {
queryParams.append('endTime', params.endTime);
}
const result = await httpClient.get<RawFeedbackStats>(
`/api/v1/monitoring/feedback/stats?${queryParams.toString()}`,
);
if (result) {
setStats({
totalFeedback: result.total_feedback,
totalLikes: result.total_likes,
totalDislikes: result.total_dislikes,
satisfactionRate: result.satisfaction_rate,
byBot: result.by_bot?.map((bot) => ({
botId: bot.bot_id,
botName: bot.bot_name,
totalFeedback: bot.total,
totalLikes: bot.likes,
totalDislikes: bot.dislikes,
satisfactionRate:
bot.total > 0 ? Math.round((bot.likes / bot.total) * 100) : 0,
})),
});
}
} catch (err) {
console.error('Failed to fetch feedback stats:', err);
}
}, [params.botIds, params.pipelineIds, params.startTime, params.endTime]);
const fetchFeedback = useCallback(async () => {
setLoading(true);
setError(null);
try {
const queryParams = new URLSearchParams();
if (params.botIds) {
params.botIds.forEach((id) => queryParams.append('botId', id));
}
if (params.pipelineIds) {
params.pipelineIds.forEach((id) =>
queryParams.append('pipelineId', id),
);
}
if (params.startTime) {
queryParams.append('startTime', params.startTime);
}
if (params.endTime) {
queryParams.append('endTime', params.endTime);
}
if (params.feedbackType) {
queryParams.append(
'feedbackType',
params.feedbackType === 'like' ? '1' : '2',
);
}
if (params.limit) {
queryParams.append('limit', params.limit.toString());
}
if (params.offset) {
queryParams.append('offset', params.offset.toString());
}
const result = await httpClient.get<{
feedback: RawFeedbackRecord[];
total: number;
}>(`/api/v1/monitoring/feedback?${queryParams.toString()}`);
if (result) {
const transformedFeedback: FeedbackRecord[] = result.feedback.map(
(item) => ({
id: item.id,
timestamp: new Date(item.timestamp),
feedbackId: item.feedback_id,
feedbackType: item.feedback_type === 1 ? 'like' : 'dislike',
feedbackContent: item.feedback_content,
inaccurateReasons: item.inaccurate_reasons
? JSON.parse(item.inaccurate_reasons)
: undefined,
botId: item.bot_id,
botName: item.bot_name,
pipelineId: item.pipeline_id,
pipelineName: item.pipeline_name,
sessionId: item.session_id,
messageId: item.message_id,
streamId: item.stream_id,
userId: item.user_id,
platform: item.platform,
}),
);
setFeedback(transformedFeedback);
setTotal(result.total);
}
} catch (err) {
setError(err as Error);
console.error('Failed to fetch feedback:', err);
} finally {
setLoading(false);
}
}, [params]);
const refetch = useCallback(() => {
fetchStats();
fetchFeedback();
}, [fetchStats, fetchFeedback]);
useEffect(() => {
refetch();
}, [paramsStr]);
return {
feedback,
stats,
total,
loading,
error,
refetch,
};
}

View File

@@ -1,6 +1,6 @@
'use client'; 'use client';
import React, { Suspense, useState } from 'react'; import React, { Suspense, useState, useMemo } 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';
@@ -10,8 +10,11 @@ import MonitoringFilters from './components/filters/MonitoringFilters';
import { ExportDropdown } from './components/ExportDropdown'; import { ExportDropdown } from './components/ExportDropdown';
import { useMonitoringFilters } from './hooks/useMonitoringFilters'; import { useMonitoringFilters } from './hooks/useMonitoringFilters';
import { useMonitoringData } from './hooks/useMonitoringData'; import { useMonitoringData } from './hooks/useMonitoringData';
import { useFeedbackData } from './hooks/useFeedbackData';
import { MessageDetailsCard } from './components/MessageDetailsCard'; import { MessageDetailsCard } from './components/MessageDetailsCard';
import { MessageContentRenderer } from './components/MessageContentRenderer'; import { MessageContentRenderer } from './components/MessageContentRenderer';
import { FeedbackStatsCards } from './components/FeedbackCard';
import { FeedbackList } from './components/FeedbackList';
import { MessageDetails } from './types/monitoring'; import { MessageDetails } from './types/monitoring';
import { httpClient } from '@/app/infra/http/HttpClient'; import { httpClient } from '@/app/infra/http/HttpClient';
import { LoadingSpinner, LoadingPage } from '@/components/ui/loading-spinner'; import { LoadingSpinner, LoadingPage } from '@/components/ui/loading-spinner';
@@ -68,6 +71,64 @@ function MonitoringPageContent() {
useMonitoringFilters(); useMonitoringFilters();
const { data, loading, refetch } = useMonitoringData(filterState); const { data, loading, refetch } = useMonitoringData(filterState);
// Get time range for feedback data
const feedbackTimeRange = useMemo(() => {
const now = new Date();
let startTime: Date | null = null;
switch (filterState.timeRange) {
case 'lastHour':
startTime = new Date(now.getTime() - 60 * 60 * 1000);
break;
case 'last6Hours':
startTime = new Date(now.getTime() - 6 * 60 * 60 * 1000);
break;
case 'last24Hours':
startTime = new Date(now.getTime() - 24 * 60 * 60 * 1000);
break;
case 'last7Days':
startTime = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
break;
case 'last30Days':
startTime = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
break;
case 'custom':
if (filterState.customDateRange) {
startTime = filterState.customDateRange.from;
}
break;
}
const endTime =
filterState.timeRange === 'custom' && filterState.customDateRange
? filterState.customDateRange.to
: now;
return {
startTime: startTime?.toISOString(),
endTime: endTime.toISOString(),
};
}, [filterState.timeRange, filterState.customDateRange]);
// Feedback data hook
const {
feedback: feedbackList,
stats: feedbackStats,
loading: feedbackLoading,
} = useFeedbackData({
botIds:
filterState.selectedBots.length > 0
? filterState.selectedBots
: undefined,
pipelineIds:
filterState.selectedPipelines.length > 0
? filterState.selectedPipelines
: undefined,
startTime: feedbackTimeRange.startTime,
endTime: feedbackTimeRange.endTime,
limit: 50,
});
const [expandedMessageId, setExpandedMessageId] = useState<string | null>( const [expandedMessageId, setExpandedMessageId] = useState<string | null>(
null, null,
); );
@@ -249,6 +310,9 @@ function MonitoringPageContent() {
<TabsTrigger value="modelCalls" className="px-6 py-2"> <TabsTrigger value="modelCalls" className="px-6 py-2">
{t('monitoring.tabs.modelCalls')} {t('monitoring.tabs.modelCalls')}
</TabsTrigger> </TabsTrigger>
<TabsTrigger value="feedback" className="px-6 py-2">
{t('monitoring.tabs.feedback')}
</TabsTrigger>
<TabsTrigger value="errors" className="px-6 py-2"> <TabsTrigger value="errors" className="px-6 py-2">
{t('monitoring.tabs.errors')} {t('monitoring.tabs.errors')}
</TabsTrigger> </TabsTrigger>
@@ -609,6 +673,38 @@ function MonitoringPageContent() {
</div> </div>
</TabsContent> </TabsContent>
<TabsContent value="feedback" className="p-6 m-0">
<div>
{loading && (
<div className="py-12 flex justify-center">
<LoadingSpinner text={t('common.loading')} />
</div>
)}
{!loading && (
<>
{/* Feedback Stats Cards */}
<div className="mb-6">
<FeedbackStatsCards
stats={feedbackStats}
loading={feedbackLoading}
/>
</div>
{/* Feedback List */}
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
{t('monitoring.feedback.feedbackList')}
</h3>
<FeedbackList
feedback={feedbackList}
loading={feedbackLoading}
onViewMessage={jumpToMessage}
/>
</>
)}
</div>
</TabsContent>
<TabsContent value="errors" className="p-6 m-0"> <TabsContent value="errors" className="p-6 m-0">
<div> <div>
{loading && ( {loading && (

View File

@@ -162,6 +162,39 @@ export interface DateRange {
to: Date; to: Date;
} }
export interface FeedbackRecord {
id: string;
timestamp: Date;
feedbackId: string;
feedbackType: 'like' | 'dislike';
feedbackContent?: string;
inaccurateReasons?: string[];
botId?: string;
botName?: string;
pipelineId?: string;
pipelineName?: string;
sessionId?: string;
messageId?: string;
streamId?: string;
userId?: string;
platform?: string;
}
export interface FeedbackStats {
totalFeedback: number;
totalLikes: number;
totalDislikes: number;
satisfactionRate: number;
byBot?: Array<{
botId: string;
botName: string;
totalFeedback: number;
totalLikes: number;
totalDislikes: number;
satisfactionRate: number;
}>;
}
export interface MonitoringData { export interface MonitoringData {
overview: OverviewMetrics; overview: OverviewMetrics;
messages: MonitoringMessage[]; messages: MonitoringMessage[];
@@ -170,11 +203,14 @@ export interface MonitoringData {
modelCalls: ModelCall[]; modelCalls: ModelCall[];
sessions: SessionInfo[]; sessions: SessionInfo[];
errors: ErrorLog[]; errors: ErrorLog[];
feedback?: FeedbackRecord[];
feedbackStats?: FeedbackStats;
totalCount: { totalCount: {
messages: number; messages: number;
llmCalls: number; llmCalls: number;
embeddingCalls: number; embeddingCalls: number;
sessions: number; sessions: number;
errors: number; errors: number;
feedback?: number;
}; };
} }

View File

@@ -1031,6 +1031,7 @@ const enUS = {
llmCalls: 'LLM Calls', llmCalls: 'LLM Calls',
embeddingCalls: 'Embedding Calls', embeddingCalls: 'Embedding Calls',
modelCalls: 'Model Calls', modelCalls: 'Model Calls',
feedback: 'User Feedback',
sessions: 'Session Analysis', sessions: 'Session Analysis',
errors: 'Error Logs', errors: 'Error Logs',
}, },
@@ -1110,6 +1111,26 @@ const enUS = {
noErrors: 'No errors found', noErrors: 'No errors found',
stackTrace: 'Stack Trace', stackTrace: 'Stack Trace',
}, },
feedback: {
title: 'User Feedback',
totalFeedback: 'Total Feedback',
totalLikes: 'Likes',
totalDislikes: 'Dislikes',
satisfactionRate: 'Satisfaction Rate',
like: 'Like',
dislike: 'Dislike',
noFeedback: 'No feedback yet',
noFeedbackDescription: 'User feedback will appear here',
feedbackList: 'Feedback List',
feedbackContent: 'Feedback Content',
contextInfo: 'Context Info',
userId: 'User ID',
messageId: 'Message ID',
streamId: 'Stream ID',
inaccurateReasons: 'Inaccurate Reasons',
platform: 'Platform',
exportFeedback: 'Export Feedback',
},
queries: { queries: {
title: 'Queries', title: 'Queries',
}, },

View File

@@ -977,6 +977,7 @@ const zhHans = {
llmCalls: 'LLM调用', llmCalls: 'LLM调用',
embeddingCalls: 'Embedding调用', embeddingCalls: 'Embedding调用',
modelCalls: '模型调用', modelCalls: '模型调用',
feedback: '用户反馈',
sessions: '会话分析', sessions: '会话分析',
errors: '错误日志', errors: '错误日志',
}, },
@@ -1056,6 +1057,26 @@ const zhHans = {
noErrors: '未找到错误', noErrors: '未找到错误',
stackTrace: '堆栈追踪', stackTrace: '堆栈追踪',
}, },
feedback: {
title: '用户反馈',
totalFeedback: '总反馈数',
totalLikes: '点赞数',
totalDislikes: '点踩数',
satisfactionRate: '满意度',
like: '点赞',
dislike: '点踩',
noFeedback: '暂无反馈',
noFeedbackDescription: '用户反馈将在此显示',
feedbackList: '反馈列表',
feedbackContent: '反馈内容',
contextInfo: '上下文信息',
userId: '用户ID',
messageId: '消息ID',
streamId: '流ID',
inaccurateReasons: '不准确原因',
platform: '平台',
exportFeedback: '导出反馈',
},
queries: { queries: {
title: '查询记录', title: '查询记录',
}, },