mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-06-02 03:55:55 +00:00
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:
8
.gitignore
vendored
8
.gitignore
vendored
@@ -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
|
||||||
|
|||||||
@@ -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" ]
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -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', '')
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|||||||
@@ -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
|
||||||
|
]
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
3631
web/pnpm-lock.yaml
generated
3631
web/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
187
web/src/app/home/monitoring/components/FeedbackCard.tsx
Normal file
187
web/src/app/home/monitoring/components/FeedbackCard.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
275
web/src/app/home/monitoring/components/FeedbackList.tsx
Normal file
275
web/src/app/home/monitoring/components/FeedbackList.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
192
web/src/app/home/monitoring/hooks/useFeedbackData.ts
Normal file
192
web/src/app/home/monitoring/hooks/useFeedbackData.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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 && (
|
||||||
|
|||||||
@@ -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;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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: '查询记录',
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user