feat(wecom): add user feedback support for WeChat Work AI Bot#2078
feat(wecom): add user feedback support for WeChat Work AI Bot#2078RockChinQ merged 6 commits intolangbot-app:masterfrom
Conversation
There was a problem hiding this comment.
Pull request overview
Adds end-to-end “user feedback (like/dislike)” support for the WeChat Work (WeCom) AI Bot, persisting feedback into the monitoring database and exposing it in the Web monitoring UI for viewing/exporting.
Changes:
- Backend: capture WeCom feedback events, persist them as
monitoring_feedback, and expose list/stats/export APIs. - Frontend: add a “User Feedback” monitoring tab with stats cards + feedback list, plus i18n strings.
- Platform/runtime wiring: pass application context (
ap) into adapters and provide bot/pipeline context to feedback recording.
Reviewed changes
Copilot reviewed 26 out of 28 changed files in this pull request and generated 11 comments.
Show a summary per file
| File | Description |
|---|---|
| web/src/i18n/locales/zh-Hans.ts | Adds Simplified Chinese strings for the feedback tab/components. |
| web/src/i18n/locales/en-US.ts | Adds English strings for the feedback tab/components. |
| web/src/app/home/monitoring/types/monitoring.ts | Introduces FeedbackRecord/FeedbackStats types and optional feedback fields in MonitoringData. |
| web/src/app/home/monitoring/page.tsx | Adds a “feedback” tab and wires it to the new feedback hook + UI components. |
| web/src/app/home/monitoring/hooks/useMonitoringData.ts | Adjusts imports (currently includes unused additions). |
| web/src/app/home/monitoring/hooks/useFeedbackData.ts | New hook to fetch feedback list + stats from backend monitoring endpoints. |
| web/src/app/home/monitoring/components/FeedbackList.tsx | New UI component to render expandable feedback records and context fields. |
| web/src/app/home/monitoring/components/FeedbackCard.tsx | New UI component for feedback stats cards (totals + satisfaction rate). |
| src/langbot/pkg/utils/constants.py | Bumps required_database_version to 25 for the new migration. |
| src/langbot/pkg/platform/sources/wecombot.py | Registers feedback handler to persist WeCom feedback into monitoring. |
| src/langbot/pkg/platform/sources/wecom.py | Updates adapter ctor signature to accept ap/**kwargs. |
| src/langbot/pkg/platform/sources/wechatpad.py | Updates adapter ctor signature to accept ap/**kwargs. |
| src/langbot/pkg/platform/sources/slack.py | Updates adapter ctor signature to accept ap/**kwargs. |
| src/langbot/pkg/platform/sources/qqofficial.py | Updates adapter ctor signature to accept ap/**kwargs. |
| src/langbot/pkg/platform/sources/officialaccount.py | Updates adapter ctor signature to accept ap/**kwargs. |
| src/langbot/pkg/platform/sources/line.py | Updates adapter ctor signature to accept ap/**kwargs. |
| src/langbot/pkg/platform/sources/dingtalk.py | Updates adapter ctor signature to accept ap/**kwargs. |
| src/langbot/pkg/platform/botmgr.py | Passes ap into adapters and sets bot/pipeline context via set_bot_info. |
| src/langbot/pkg/pipeline/monitoring_helper.py | Adds FeedbackMonitor helper to call monitoring service feedback recorder. |
| src/langbot/pkg/persistence/migrations/dbm025_feedback_stats.py | New migration creating monitoring_feedback table + indexes. |
| src/langbot/pkg/entity/persistence/monitoring.py | Adds MonitoringFeedback SQLAlchemy model. |
| src/langbot/pkg/api/http/service/monitoring.py | Adds feedback persistence + stats/list/export methods. |
| src/langbot/pkg/api/http/controller/groups/monitoring.py | Adds feedback stats/list routes and extends export to support feedback. |
| src/langbot/libs/wecom_ai_bot_api/wecombotevent.py | Adds feedback_id and stream_id properties on WecomBotEvent. |
| src/langbot/libs/wecom_ai_bot_api/api.py | Adds feedback ID generation/indexing, feedback event handling, and on_feedback decorator. |
| docker/docker-compose.yaml | Minor network section formatting adjustment. |
| Dockerfile | Switches apt/pip indexes to CN mirrors and adjusts uv sync index usage. |
| .gitignore | Ignores docker deployment data/override and backup artifacts. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| 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) | ||
| ) | ||
| ''' |
There was a problem hiding this comment.
The migration uses timestamp DATETIME NOT NULL in a single CREATE TABLE statement intended for both SQLite and PostgreSQL. PostgreSQL does not support the DATETIME type (it uses TIMESTAMP), so this migration is likely to fail on Postgres deployments. Please branch the DDL by self.ap.persistence_mgr.db.name (as other migrations do) and use the correct column types per DB.
| 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) | |
| ) | |
| ''' | |
| if self.ap.persistence_mgr.db.name == 'postgresql': | |
| create_table_sql = ''' | |
| CREATE TABLE monitoring_feedback ( | |
| id VARCHAR(255) PRIMARY KEY, | |
| timestamp TIMESTAMP 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) | |
| ) | |
| ''' | |
| else: | |
| 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) | |
| ) | |
| ''' |
| {loading && ( | ||
| <div className="py-12 flex justify-center"> | ||
| <LoadingSpinner text={t('common.loading')} /> | ||
| </div> | ||
| )} | ||
|
|
||
| {!loading && ( |
There was a problem hiding this comment.
The feedback tab uses the monitoring loading state to show/hide feedback UI. This means feedback can be hidden while monitoring data loads, and it won’t show a spinner when only feedback is loading. Use feedbackLoading (and/or both states) to control the feedback tab rendering.
| {loading && ( | |
| <div className="py-12 flex justify-center"> | |
| <LoadingSpinner text={t('common.loading')} /> | |
| </div> | |
| )} | |
| {!loading && ( | |
| {feedbackLoading && ( | |
| <div className="py-12 flex justify-center"> | |
| <LoadingSpinner text={t('common.loading')} /> | |
| </div> | |
| )} | |
| {!feedbackLoading && ( |
web/src/app/home/monitoring/page.tsx
Outdated
| @@ -10,8 +10,11 @@ | |||
| import { ExportDropdown } from './components/ExportDropdown'; | |||
| import { useMonitoringFilters } from './hooks/useMonitoringFilters'; | |||
| import { useMonitoringData } from './hooks/useMonitoringData'; | |||
| import { useFeedbackData } from './hooks/useFeedbackData'; | |||
| import { MessageDetailsCard } from './components/MessageDetailsCard'; | |||
| import { MessageContentRenderer } from './components/MessageContentRenderer'; | |||
| import { FeedbackStatsCards } from './components/FeedbackCard'; | |||
| import { FeedbackList } from './components/FeedbackList'; | |||
| import { MessageDetails } from './types/monitoring'; | |||
| import { httpClient } from '@/app/infra/http/HttpClient'; | |||
| import { LoadingSpinner, LoadingPage } from '@/components/ui/loading-spinner'; | |||
| @@ -68,6 +71,60 @@ | |||
| useMonitoringFilters(); | |||
| 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, | |||
| }); | |||
There was a problem hiding this comment.
There are unused React imports/vars that will trip next/typescript lint rules: useCallback is imported but not used, and useFeedbackData destructures total and refetch into feedbackTotal/refetchFeedback which are never used. Remove them or use them (e.g., show total / add a refresh action).
| 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, | ||
| })); | ||
|
|
There was a problem hiding this comment.
feedback_type values other than 1 are currently mapped to 'dislike'. For WeCom feedback events, type=3 (cancel) will be misrepresented as a dislike in the UI. Consider explicitly handling known values (1/2/3) and either filtering out cancels or mapping them to a third state.
| 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, | |
| })); | |
| const transformedFeedback: FeedbackRecord[] = result.feedback.reduce<FeedbackRecord[]>((acc, item) => { | |
| let feedbackType: FeedbackRecord['feedbackType'] | undefined; | |
| if (item.feedback_type === 1) { | |
| feedbackType = 'like'; | |
| } else if (item.feedback_type === 2) { | |
| feedbackType = 'dislike'; | |
| } else { | |
| // Ignore cancel (e.g. type=3) or any unknown feedback types | |
| return acc; | |
| } | |
| acc.push({ | |
| id: item.id, | |
| timestamp: new Date(item.timestamp), | |
| feedbackId: item.feedback_id, | |
| feedbackType, | |
| 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, | |
| }); | |
| return acc; | |
| }, []); |
src/langbot/pkg/platform/botmgr.py
Outdated
| 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 |
There was a problem hiding this comment.
This adds one DB query per bot to resolve pipeline_name (SELECT LegacyPipeline.name ... WHERE uuid = ...). If there are many bots, load_bots_from_db() will become an N+1 query pattern. Consider preloading pipeline names in a single query (e.g., fetch all needed pipeline UUIDs into a map) or joining when loading bots.
| 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 | |
| # Lazily initialize a simple in-memory cache for pipeline names | |
| if not hasattr(self, '_pipeline_name_cache'): | |
| self._pipeline_name_cache: dict[str, str] = {} | |
| pipeline_uuid = bot_entity.use_pipeline_uuid | |
| if pipeline_uuid in self._pipeline_name_cache: | |
| pipeline_name = self._pipeline_name_cache[pipeline_uuid] | |
| else: | |
| try: | |
| pipeline_result = await self.ap.persistence_mgr.execute_async( | |
| sqlalchemy.select(persistence_pipeline.LegacyPipeline.name).where( | |
| persistence_pipeline.LegacyPipeline.uuid == pipeline_uuid | |
| ) | |
| ) | |
| pipeline_row = pipeline_result.first() | |
| if pipeline_row: | |
| pipeline_name = pipeline_row[0] | |
| # Cache the result (including empty string) to avoid repeat queries | |
| self._pipeline_name_cache[pipeline_uuid] = pipeline_name | |
| except Exception: | |
| # On error, cache empty string to avoid repeated failing queries | |
| self._pipeline_name_cache[pipeline_uuid] = '' |
| end_time = parse_iso_datetime(end_time_str) | ||
|
|
||
| # Parse feedback type | ||
| feedback_type = int(feedback_type_str) if feedback_type_str else None |
There was a problem hiding this comment.
feedbackType parsing uses int(feedback_type_str) directly. If the client passes a non-integer value (or an empty string), this will raise ValueError and return a 500. Please validate/guard the conversion and return a 400 for invalid feedbackType values.
| feedback_type = int(feedback_type_str) if feedback_type_str else None | |
| feedback_type = None | |
| if feedback_type_str: | |
| try: | |
| feedback_type = int(feedback_type_str) | |
| except ValueError: | |
| quart.abort(400, description="Invalid feedbackType, must be an integer") |
| import { useState, useEffect, useCallback, useMemo } from 'react'; | ||
| import { | ||
| FilterState, | ||
| MonitoringData, | ||
| ModelCall, | ||
| LLMCall, | ||
| EmbeddingCall, | ||
| FeedbackRecord, | ||
| FeedbackStats, | ||
| } from '../types/monitoring'; | ||
| import { backendClient } from '@/app/infra/http'; | ||
| import { backendClient, httpClient } from '@/app/infra/http'; | ||
|
|
There was a problem hiding this comment.
FeedbackRecord, FeedbackStats, and httpClient are imported but never used in this hook. With the repo’s next/typescript ESLint config, unused imports are typically lint errors—please remove them or use them.
|
|
||
| useEffect(() => { | ||
| refetch(); | ||
| }, [paramsStr]); |
There was a problem hiding this comment.
useEffect calls refetch() but the dependency array is [paramsStr] only. With react-hooks/exhaustive-deps enabled (as in next/core-web-vitals), this will be flagged and can also lead to stale closures if refetch changes independently. Either include refetch in the deps (and ensure it’s stable), or add an explicit eslint disable comment with a justification like in useMonitoringData.
| }, [paramsStr]); | |
| }, [paramsStr, refetch]); |
| ) | ||
| self.listeners = {} | ||
| object.__setattr__(self, '_ws_mode', ws_mode) | ||
| object.__setattr__(self, 'ap', ap) | ||
|
|
||
| # Register feedback handler for monitoring | ||
| self._register_feedback_handler() |
There was a problem hiding this comment.
ws_mode is referenced when setting _ws_mode, but it is not defined anywhere in __init__ (will raise NameError during adapter initialization). Define ws_mode (e.g., based on enable_webhook) or set _ws_mode from the already-computed _ws_mode = not self.config.get('enable-webhook', False) logic used elsewhere in this class.
| def __init__(self, logger: EventLogger, ttl: int = 60) -> None: | ||
| self.logger = logger | ||
|
|
||
| self.ttl = ttl # 超时时间(秒),超过该时间未被访问的会话会被清理由 cleanup | ||
| self._sessions: dict[str, StreamSession] = {} # stream_id -> StreamSession 映射 | ||
| 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]: | ||
| if not msg_id: | ||
| return None | ||
| return self._msg_index.get(msg_id) | ||
|
|
||
| def get_session(self, stream_id: str) -> Optional[StreamSession]: | ||
| 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 |
There was a problem hiding this comment.
StreamSessionManager.cleanup() removes expired sessions from _sessions and _msg_index, but it never removes entries from the newly added _feedback_index. This can grow unbounded over time and will leave stale feedback_id -> stream_id mappings after the session is purged.
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
cc96d59 to
6bb7329
Compare
功能描述
实现企业微信智能机器人用户反馈功能(点赞/点踩)支持,允许在 Web 监控页面查看和管理用户反馈数据。
修改内容
后端修改
WecomBotEvent新增feedback_id和stream_id字段api.py实现反馈事件处理逻辑,新增on_feedback装饰器StreamSessionManager._feedback_index用于 feedback_id 反向查找MonitoringFeedback数据库实体dbm025_feedback_stats.py数据库迁移脚本FeedbackMonitor辅助类用于记录反馈ap参数botmgr.py新增set_bot_info方法传递机器人上下文信息前端修改
FeedbackCard.tsx反馈统计卡片组件FeedbackList.tsx反馈列表组件useFeedbackData.ts反馈数据获取 Hook其他修改
Dockerfile新增阿里云镜像加速docker-compose.yaml网络配置优化.gitignore新增 Docker 数据目录和备份目录忽略规则待完善功能
取消反馈处理 (
feedback_type = 3)重复反馈去重
测试
参考文档