Compare commits

...

1 Commits

Author SHA1 Message Date
6mvp6
6bb73297e0 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
2026-03-30 00:05:27 +08:00
28 changed files with 1545 additions and 21 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', ...}}` 结构。
""" """
stream_payload = {
'id': stream_id,
'finish': finish,
'content': content,
}
if feedback_id:
stream_payload['feedback'] = {'id': feedback_id}
return { return {
'msgtype': 'stream', 'msgtype': 'stream',
'stream': { 'stream': stream_payload,
'id': stream_id,
'finish': finish,
'content': content,
},
} }
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,79 @@ 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}, '
f'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 +1002,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,268 @@ 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

@@ -0,0 +1,75 @@
import sqlalchemy
from .. import migration
@migration.migration_class(25)
class DBMigrateFeedbackStats(migration.DBMigration):
"""Add monitoring_feedback table for storing user feedback from AI Bot conversations"""
async def _table_exists(self, table_name: str) -> bool:
"""Check if a table exists (works for both SQLite and PostgreSQL)."""
if self.ap.persistence_mgr.db.name == 'postgresql':
result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.text(
'SELECT EXISTS (SELECT FROM information_schema.tables WHERE table_name = :table_name);'
).bindparams(table_name=table_name)
)
return bool(result.scalar())
else:
result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.text("SELECT name FROM sqlite_master WHERE type='table' AND name=:table_name;").bindparams(
table_name=table_name
)
)
return result.first() is not None
async def upgrade(self):
"""Create monitoring_feedback table."""
if await self._table_exists('monitoring_feedback'):
self.ap.logger.debug('monitoring_feedback table already exists, skipping migration.')
return
# Create monitoring_feedback table with all columns
create_table_sql = '''
CREATE TABLE monitoring_feedback (
id VARCHAR(255) PRIMARY KEY,
timestamp DATETIME NOT NULL,
feedback_id VARCHAR(255) NOT NULL UNIQUE,
feedback_type INTEGER NOT NULL,
feedback_content TEXT,
inaccurate_reasons TEXT,
bot_id VARCHAR(255),
bot_name VARCHAR(255),
pipeline_id VARCHAR(255),
pipeline_name VARCHAR(255),
session_id VARCHAR(255),
message_id VARCHAR(255),
stream_id VARCHAR(255),
user_id VARCHAR(255),
platform VARCHAR(255)
)
'''
await self.ap.persistence_mgr.execute_async(sqlalchemy.text(create_table_sql))
# Create indexes
indexes = [
'CREATE INDEX ix_monitoring_feedback_timestamp ON monitoring_feedback (timestamp)',
'CREATE UNIQUE INDEX ix_monitoring_feedback_feedback_id ON monitoring_feedback (feedback_id)',
'CREATE INDEX ix_monitoring_feedback_bot_id ON monitoring_feedback (bot_id)',
'CREATE INDEX ix_monitoring_feedback_pipeline_id ON monitoring_feedback (pipeline_id)',
'CREATE INDEX ix_monitoring_feedback_session_id ON monitoring_feedback (session_id)',
'CREATE INDEX ix_monitoring_feedback_message_id ON monitoring_feedback (message_id)',
'CREATE INDEX ix_monitoring_feedback_stream_id ON monitoring_feedback (stream_id)',
]
for index_sql in indexes:
await self.ap.persistence_mgr.execute_async(sqlalchemy.text(index_sql))
self.ap.logger.info('Created monitoring_feedback table with indexes.')
async def downgrade(self):
"""Drop monitoring_feedback table."""
if await self._table_exists('monitoring_feedback'):
await self.ap.persistence_mgr.execute_async(
sqlalchemy.text('DROP TABLE monitoring_feedback')
)
self.ap.logger.info('Dropped monitoring_feedback table.')

View File

@@ -353,3 +353,62 @@ class LLMCallMonitor:
) )
return False # Don't suppress exceptions return False # Don't suppress exceptions
class FeedbackMonitor:
"""Helper for recording user feedback from AI Bot conversations"""
@staticmethod
async def record_feedback(
ap: app.Application,
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 = 'wecom',
):
"""Record user feedback (like/dislike) from AI Bot conversation.
Args:
ap: Application instance
feedback_id: Unique feedback identifier from platform
feedback_type: 1 = like, 2 = dislike
feedback_content: Optional user feedback text
inaccurate_reasons: List of reasons for inaccurate response
bot_id: Bot UUID
bot_name: Bot name
pipeline_id: Pipeline UUID
pipeline_name: Pipeline name
session_id: Session ID
message_id: Message ID
stream_id: Stream ID
user_id: User ID
platform: Platform name (default: wecom)
"""
try:
await ap.monitoring_service.record_feedback(
feedback_id=feedback_id,
feedback_type=feedback_type,
feedback_content=feedback_content,
inaccurate_reasons=inaccurate_reasons,
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,
)
ap.logger.info(f'Recorded feedback: feedback_id={feedback_id}, type={feedback_type}')
except Exception as e:
ap.logger.error(f'Failed to record feedback: {e}')

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
@@ -267,12 +268,35 @@ class PlatformManager:
adapter_inst = self.adapter_dict[bot_entity.adapter]( adapter_inst = self.adapter_dict[bot_entity.adapter](
bot_entity.adapter_config, bot_entity.adapter_config,
logger, logger,
ap=self.ap,
) )
# 如果 adapter 支持 set_bot_uuid 方法,设置 bot_uuid用于统一 webhook # 如果 adapter 支持 set_bot_uuid 方法,设置 bot_uuid用于统一 webhook
if hasattr(adapter_inst, 'set_bot_uuid'): if hasattr(adapter_inst, 'set_bot_uuid'):
adapter_inst.set_bot_uuid(bot_entity.uuid) adapter_inst.set_bot_uuid(bot_entity.uuid)
# 如果 adapter 支持 set_bot_info 方法,设置 bot 信息(用于监控记录)
if hasattr(adapter_inst, 'set_bot_info'):
pipeline_name = ''
if 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 == bot_entity.use_pipeline_uuid
)
)
pipeline_row = pipeline_result.first()
if pipeline_row:
pipeline_name = pipeline_row[0]
except Exception:
pass
adapter_inst.set_bot_info(
bot_id=bot_entity.uuid,
bot_name=bot_entity.name,
pipeline_id=bot_entity.use_pipeline_uuid or '',
pipeline_name=pipeline_name,
)
runtime_bot = RuntimeBot(ap=self.ap, bot_entity=bot_entity, adapter=adapter_inst, logger=logger) runtime_bot = RuntimeBot(ap=self.ap, bot_entity=bot_entity, adapter=adapter_inst, logger=logger)
await runtime_bot.initialize() await runtime_bot.initialize()

View File

@@ -139,7 +139,7 @@ class DingTalkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
dict # 回复卡片消息字典key为消息idvalue为回复卡片实例id用于在流式消息时判断是否发送到指定卡片 dict # 回复卡片消息字典key为消息idvalue为回复卡片实例id用于在流式消息时判断是否发送到指定卡片
) )
def __init__(self, config: dict, logger: EventLogger): def __init__(self, config: dict, logger: EventLogger, ap=None, **kwargs):
required_keys = [ required_keys = [
'client_id', 'client_id',
'client_secret', 'client_secret',

View File

@@ -136,7 +136,7 @@ class LINEAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
seq: int # 用于在发送卡片消息中识别消息顺序直接以seq作为标识 seq: int # 用于在发送卡片消息中识别消息顺序直接以seq作为标识
def __init__(self, config: dict, logger: EventLogger): def __init__(self, config: dict, logger: EventLogger, ap=None, **kwargs):
configuration = Configuration(access_token=config['channel_access_token']) configuration = Configuration(access_token=config['channel_access_token'])
line_webhook = WebhookHandler(config['channel_secret']) line_webhook = WebhookHandler(config['channel_secret'])
parser = WebhookParser(config['channel_secret']) parser = WebhookParser(config['channel_secret'])

View File

@@ -60,7 +60,7 @@ class OfficialAccountAdapter(abstract_platform_adapter.AbstractMessagePlatformAd
bot: typing.Union[OAClient, OAClientForLongerResponse] = pydantic.Field(exclude=True) bot: typing.Union[OAClient, OAClientForLongerResponse] = pydantic.Field(exclude=True)
bot_uuid: str = None bot_uuid: str = None
def __init__(self, config: dict, logger: EventLogger): def __init__(self, config: dict, logger: EventLogger, ap=None, **kwargs):
# 校验必填项 # 校验必填项
required_keys = ['token', 'EncodingAESKey', 'AppSecret', 'AppID', 'Mode'] required_keys = ['token', 'EncodingAESKey', 'AppSecret', 'AppID', 'Mode']
missing_keys = [k for k in required_keys if k not in config] missing_keys = [k for k in required_keys if k not in config]

View File

@@ -132,7 +132,7 @@ class QQOfficialAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter
message_converter: QQOfficialMessageConverter = QQOfficialMessageConverter() message_converter: QQOfficialMessageConverter = QQOfficialMessageConverter()
event_converter: QQOfficialEventConverter = QQOfficialEventConverter() event_converter: QQOfficialEventConverter = QQOfficialEventConverter()
def __init__(self, config: dict, logger: EventLogger): def __init__(self, config: dict, logger: EventLogger, ap=None, **kwargs):
bot = QQOfficialClient( bot = QQOfficialClient(
app_id=config['appid'], secret=config['secret'], token=config['token'], logger=logger, unified_mode=True app_id=config['appid'], secret=config['secret'], token=config['token'], logger=logger, unified_mode=True
) )

View File

@@ -99,7 +99,7 @@ class SlackAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
event_converter: SlackEventConverter = SlackEventConverter() event_converter: SlackEventConverter = SlackEventConverter()
config: dict config: dict
def __init__(self, config: dict, logger: EventLogger): def __init__(self, config: dict, logger: EventLogger, ap=None, **kwargs):
required_keys = [ required_keys = [
'bot_token', 'bot_token',
'signing_secret', 'signing_secret',

View File

@@ -539,7 +539,7 @@ class WeChatPadAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
typing.Callable[[platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None], typing.Callable[[platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None],
] = {} ] = {}
def __init__(self, config: dict, logger: EventLogger): def __init__(self, config: dict, logger: EventLogger, ap=None, **kwargs):
quart_app = quart.Quart(__name__) quart_app = quart.Quart(__name__)
message_converter = WeChatPadMessageConverter(config, logger) message_converter = WeChatPadMessageConverter(config, logger)

View File

@@ -206,7 +206,7 @@ class WecomAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
config: dict config: dict
bot_uuid: str = None bot_uuid: str = None
def __init__(self, config: dict, logger: EventLogger): def __init__(self, config: dict, logger: EventLogger, ap=None, **kwargs):
# 校验必填项 # 校验必填项
required_keys = [ required_keys = [
'corpid', 'corpid',

View File

@@ -10,8 +10,10 @@ import langbot_plugin.api.entities.builtin.platform.events as platform_events
import langbot_plugin.api.entities.builtin.platform.entities as platform_entities import langbot_plugin.api.entities.builtin.platform.entities as platform_entities
from ..logger import EventLogger from ..logger import EventLogger
from langbot.libs.wecom_ai_bot_api.wecombotevent import WecomBotEvent from langbot.libs.wecom_ai_bot_api.wecombotevent import WecomBotEvent
from langbot.libs.wecom_ai_bot_api.api import WecomBotClient from langbot.libs.wecom_ai_bot_api.api import WecomBotClient, StreamSession
from langbot.libs.wecom_ai_bot_api.ws_client import WecomBotWsClient from langbot.libs.wecom_ai_bot_api.ws_client import WecomBotWsClient
from ...core import app as langbot_app
from ...pipeline.monitoring_helper import FeedbackMonitor
class WecomBotMessageConverter(abstract_platform_adapter.AbstractMessageConverter): class WecomBotMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
@@ -192,8 +194,10 @@ class WecomBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
_ws_mode: bool = False _ws_mode: bool = False
bot_name: str = '' bot_name: str = ''
listeners: dict = {} listeners: dict = {}
ap: langbot_app.Application = None # Application reference for monitoring
_bot_info: dict = None # Bot info for monitoring (bot_id, bot_name, pipeline_id, pipeline_name)
def __init__(self, config: dict, logger: EventLogger): def __init__(self, config: dict, logger: EventLogger, ap: langbot_app.Application = None, **kwargs):
enable_webhook = config.get('enable-webhook', False) enable_webhook = config.get('enable-webhook', False)
bot_name = config.get('robot_name', '') bot_name = config.get('robot_name', '')
@@ -228,8 +232,14 @@ class WecomBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
bot_account_id=bot_account_id, bot_account_id=bot_account_id,
bot_name=bot_name, bot_name=bot_name,
event_converter=event_converter, event_converter=event_converter,
**kwargs,
) )
self.listeners = {} self.listeners = {}
object.__setattr__(self, '_ws_mode', ws_mode)
object.__setattr__(self, 'ap', ap)
# Register feedback handler for monitoring
self._register_feedback_handler()
async def reply_message( async def reply_message(
self, self,
@@ -318,6 +328,66 @@ class WecomBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
"""设置 bot UUID用于生成 webhook URL""" """设置 bot UUID用于生成 webhook URL"""
self.bot_uuid = bot_uuid self.bot_uuid = bot_uuid
def set_bot_info(
self,
bot_id: str,
bot_name: str,
pipeline_id: str,
pipeline_name: str,
):
"""设置 bot 信息(用于监控记录)"""
self._bot_info = {
'bot_id': bot_id,
'bot_name': bot_name,
'pipeline_id': pipeline_id,
'pipeline_name': pipeline_name,
}
def _register_feedback_handler(self):
"""注册用户反馈处理器,用于持久化反馈数据到监控服务"""
async def handle_feedback(
feedback_id: str,
feedback_type: int,
feedback_content: str,
inaccurate_reasons: list[str],
session: StreamSession,
):
"""处理用户反馈事件,持久化到监控服务"""
if not self.ap or not self._bot_info:
return
try:
# Build session_id from session info
session_id = None
if session.chat_id:
session_id = f'group_{session.chat_id}'
elif session.user_id:
session_id = f'person_{session.user_id}'
await FeedbackMonitor.record_feedback(
ap=self.ap,
feedback_id=feedback_id,
feedback_type=feedback_type,
feedback_content=feedback_content if feedback_content else None,
inaccurate_reasons=inaccurate_reasons if inaccurate_reasons else None,
bot_id=self._bot_info['bot_id'],
bot_name=self._bot_info['bot_name'],
pipeline_id=self._bot_info['pipeline_id'],
pipeline_name=self._bot_info['pipeline_name'],
session_id=session_id,
message_id=session.msg_id if session else None,
stream_id=session.stream_id if session else None,
user_id=session.user_id if session else None,
platform='wecom',
)
except Exception:
await self.logger.error(f'Failed to record feedback: {traceback.format_exc()}')
# Register the feedback handler with the bot client
if hasattr(self.bot, 'on_feedback'):
self.bot.on_feedback()(handle_feedback)
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:

View File

@@ -2,7 +2,7 @@ import langbot
semantic_version = f'v{langbot.__version__}' semantic_version = f'v{langbot.__version__}'
required_database_version = 24 required_database_version = 25
"""Tag the version of the database schema, used to check if the database needs to be migrated""" """Tag the version of the database schema, used to check if the database needs to be migrated"""
debug_mode = False debug_mode = False

View File

@@ -0,0 +1,160 @@
'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,252 @@
'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,185 @@
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

@@ -5,6 +5,8 @@ import {
ModelCall, ModelCall,
LLMCall, LLMCall,
EmbeddingCall, EmbeddingCall,
FeedbackRecord,
FeedbackStats,
} from '../types/monitoring'; } from '../types/monitoring';
import { backendClient } from '@/app/infra/http'; import { backendClient } from '@/app/infra/http';
import { parseUTCTimestamp } from '../utils/dateUtils'; import { parseUTCTimestamp } from '../utils/dateUtils';

View File

@@ -1,6 +1,6 @@
'use client'; 'use client';
import React, { Suspense, useState } from 'react'; import React, { Suspense, useState, useMemo, useCallback } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
@@ -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,60 @@ 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,
total: feedbackTotal,
loading: feedbackLoading,
refetch: refetchFeedback,
} = 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 +306,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 +669,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: '查询记录',
}, },