From cdb381b0e9ee0387f8f3e7d014ecdc19e0da101a Mon Sep 17 00:00:00 2001 From: Wenjie Zhang Date: Sun, 5 Oct 2025 14:50:34 +0800 Subject: [PATCH] =?UTF-8?q?feat(feedback):=20=E6=96=B0=E5=A2=9E=E6=B6=88?= =?UTF-8?q?=E6=81=AF=E5=8F=8D=E9=A6=88=E5=8A=9F=E8=83=BD=EF=BC=8C=E6=94=AF?= =?UTF-8?q?=E6=8C=81=E7=94=A8=E6=88=B7=E5=AF=B9=E6=B6=88=E6=81=AF=E7=9A=84?= =?UTF-8?q?=E7=82=B9=E8=B5=9E=E5=92=8C=E7=82=B9=E8=B8=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在 chat_router 中添加了提交和获取消息反馈的 API 接口 - 在 dashboard_router 中更新统计信息,包含反馈统计数据 - 新增 MessageFeedback 数据模型,存储用户反馈信息 - 前端实现反馈提交和状态获取功能,优化用户体验 - 更新 DashboardView 以展示用户反馈统计信息 --- server/routers/chat_router.py | 112 ++++++++++- server/routers/dashboard_router.py | 91 ++++++++- src/config/app.py | 13 +- src/storage/db/models.py | 28 +++ web/src/apis/agent_api.js | 17 ++ web/src/apis/dashboard_api.js | 17 ++ web/src/components/RefsComponent.vue | 172 ++++++++++++++++- .../ToolCallingResult/CalculatorResult.vue | 6 +- web/src/layouts/AppLayout.vue | 21 +- web/src/views/DashboardView.vue | 182 ++++++++++++++++++ web/src/views/SettingView.vue | 25 +++ 11 files changed, 653 insertions(+), 31 deletions(-) diff --git a/server/routers/chat_router.py b/server/routers/chat_router.py index 44406f28..8978a81a 100644 --- a/server/routers/chat_router.py +++ b/server/routers/chat_router.py @@ -11,7 +11,7 @@ from langchain_core.messages import AIMessageChunk, HumanMessage, ToolMessage from pydantic import BaseModel from sqlalchemy.orm import Session -from src.storage.db.models import User +from src.storage.db.models import User, MessageFeedback, Message, Conversation from src.storage.conversation import ConversationManager from server.routers.auth_router import get_admin_user from server.utils.auth_middleware import get_db, get_required_user @@ -401,6 +401,7 @@ async def get_agent_history( role_type_map = {"user": "human", "assistant": "ai", "tool": "tool", "system": "system"} msg_dict = { + "id": msg.id, # Include message ID for feedback "type": role_type_map.get(msg.role, msg.role), # human/ai/tool/system "content": msg.content, "created_at": msg.created_at.isoformat() if msg.created_at else None, @@ -583,3 +584,112 @@ async def update_thread( "created_at": updated_conv.created_at.isoformat(), "updated_at": updated_conv.updated_at.isoformat(), } + + +# ============================================================================= +# > === 消息反馈分组 === +# ============================================================================= + + +class MessageFeedbackRequest(BaseModel): + rating: str # 'like' or 'dislike' + reason: str | None = None # Optional reason for dislike + + +class MessageFeedbackResponse(BaseModel): + id: int + message_id: int + rating: str + reason: str | None + created_at: str + + +@chat.post("/message/{message_id}/feedback", response_model=MessageFeedbackResponse) +async def submit_message_feedback( + message_id: int, + feedback_data: MessageFeedbackRequest, + db: Session = Depends(get_db), + current_user: User = Depends(get_required_user), +): + """Submit user feedback for a specific message""" + try: + # Validate rating + if feedback_data.rating not in ["like", "dislike"]: + raise HTTPException(status_code=422, detail="Rating must be 'like' or 'dislike'") + + # Verify message exists and get conversation to check permissions + message = db.query(Message).filter_by(id=message_id).first() + + if not message: + raise HTTPException(status_code=404, detail="Message not found") + + # Verify user has access to this message (through conversation) + conversation = db.query(Conversation).filter_by(id=message.conversation_id).first() + if not conversation or conversation.user_id != str(current_user.id): + raise HTTPException(status_code=403, detail="Access denied") + + # Check if feedback already exists (user can only submit once) + existing_feedback = ( + db.query(MessageFeedback).filter_by(message_id=message_id, user_id=str(current_user.id)).first() + ) + + if existing_feedback: + raise HTTPException(status_code=409, detail="Feedback already submitted for this message") + + # Create new feedback + new_feedback = MessageFeedback( + message_id=message_id, + user_id=str(current_user.id), + rating=feedback_data.rating, + reason=feedback_data.reason, + ) + + db.add(new_feedback) + db.commit() + db.refresh(new_feedback) + + logger.info(f"User {current_user.id} submitted {feedback_data.rating} feedback for message {message_id}") + + return MessageFeedbackResponse( + id=new_feedback.id, + message_id=new_feedback.message_id, + rating=new_feedback.rating, + reason=new_feedback.reason, + created_at=new_feedback.created_at.isoformat(), + ) + + except HTTPException: + raise + except Exception as e: + logger.error(f"Error submitting message feedback: {e}, {traceback.format_exc()}") + db.rollback() + raise HTTPException(status_code=500, detail=f"Failed to submit feedback: {str(e)}") + + +@chat.get("/message/{message_id}/feedback") +async def get_message_feedback( + message_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(get_required_user), +): + """Get feedback status for a specific message (for current user)""" + try: + # Get user's feedback for this message + feedback = db.query(MessageFeedback).filter_by(message_id=message_id, user_id=str(current_user.id)).first() + + if not feedback: + return {"has_feedback": False, "feedback": None} + + return { + "has_feedback": True, + "feedback": { + "id": feedback.id, + "rating": feedback.rating, + "reason": feedback.reason, + "created_at": feedback.created_at.isoformat(), + }, + } + + except Exception as e: + logger.error(f"Error getting message feedback: {e}") + raise HTTPException(status_code=500, detail=f"Failed to get feedback: {str(e)}") diff --git a/server/routers/dashboard_router.py b/server/routers/dashboard_router.py index 54c6d2f9..c7ded568 100644 --- a/server/routers/dashboard_router.py +++ b/server/routers/dashboard_router.py @@ -186,7 +186,7 @@ async def get_dashboard_stats( current_user: User = Depends(get_admin_user), ): """Get dashboard statistics (Admin only)""" - from src.storage.db.models import Conversation, Message + from src.storage.db.models import Conversation, Message, MessageFeedback try: # Basic counts @@ -211,6 +211,21 @@ async def get_dashboard_stats( ) recent_messages = db.query(func.count(Message.id)).filter(Message.created_at >= yesterday).scalar() or 0 + # Feedback statistics + total_feedbacks = db.query(func.count(MessageFeedback.id)).scalar() or 0 + like_count = db.query(func.count(MessageFeedback.id)).filter(MessageFeedback.rating == "like").scalar() or 0 + dislike_count = ( + db.query(func.count(MessageFeedback.id)).filter(MessageFeedback.rating == "dislike").scalar() or 0 + ) + + # Calculate satisfaction rate + satisfaction_rate = round((like_count / total_feedbacks * 100), 2) if total_feedbacks > 0 else 0 + + # Recent feedback (last 24 hours) + recent_feedbacks = ( + db.query(func.count(MessageFeedback.id)).filter(MessageFeedback.created_at >= yesterday).scalar() or 0 + ) + return { "timestamp": datetime.utcnow().isoformat(), "total_conversations": total_conversations, @@ -224,8 +239,82 @@ async def get_dashboard_stats( "conversations_24h": recent_conversations, "messages_24h": recent_messages, }, + "feedback_stats": { + "total_feedbacks": total_feedbacks, + "like_count": like_count, + "dislike_count": dislike_count, + "satisfaction_rate": satisfaction_rate, + "recent_feedbacks_24h": recent_feedbacks, + }, } except Exception as e: logger.error(f"Error getting dashboard stats: {e}") logger.error(traceback.format_exc()) raise HTTPException(status_code=500, detail=f"Failed to get dashboard stats: {str(e)}") + + +# ============================================================================= +# Feedback Management +# ============================================================================= + + +class FeedbackListItem(BaseModel): + """Feedback list item""" + + id: int + message_id: int + user_id: str + rating: str + reason: str | None + created_at: str + message_content: str + conversation_title: str | None + agent_id: str + + +@dashboard.get("/feedbacks", response_model=list[FeedbackListItem]) +async def get_all_feedbacks( + rating: str | None = None, + limit: int = 100, + offset: int = 0, + db: Session = Depends(get_db), + current_user: User = Depends(get_admin_user), +): + """Get all feedback records (Admin only)""" + from src.storage.db.models import MessageFeedback, Message, Conversation + + try: + # Build query with joins + query = ( + db.query(MessageFeedback, Message, Conversation) + .join(Message, MessageFeedback.message_id == Message.id) + .join(Conversation, Message.conversation_id == Conversation.id) + ) + + # Apply filters + if rating and rating in ["like", "dislike"]: + query = query.filter(MessageFeedback.rating == rating) + + # Order and paginate + query = query.order_by(MessageFeedback.created_at.desc()).limit(limit).offset(offset) + + results = query.all() + + return [ + { + "id": feedback.id, + "message_id": feedback.message_id, + "user_id": feedback.user_id, + "rating": feedback.rating, + "reason": feedback.reason, + "created_at": feedback.created_at.isoformat(), + "message_content": message.content[:100] + ("..." if len(message.content) > 100 else ""), + "conversation_title": conversation.title, + "agent_id": conversation.agent_id, + } + for feedback, message, conversation in results + ] + except Exception as e: + logger.error(f"Error getting feedbacks: {e}") + logger.error(traceback.format_exc()) + raise HTTPException(status_code=500, detail=f"Failed to get feedbacks: {str(e)}") diff --git a/src/config/app.py b/src/config/app.py index 7ef456f7..0edb87f0 100644 --- a/src/config/app.py +++ b/src/config/app.py @@ -54,13 +54,6 @@ class Config(SimpleConfig): self.add_item( "content_guard_llm_model", default="siliconflow/Qwen/Qwen3-235B-A22B-Instruct-2507", des="内容审查LLM模型" ) - self.add_item( - "enable_web_search", - default=False, - des=( - "是否开启网页搜索(注:现阶段会根据 TAVILY_API_KEY 自动开启,无法手动配置,将会在下个版本移除此配置项)" - ), - ) # 默认智能体配置 self.add_item("default_agent_id", default="", des="默认智能体ID") # 模型配置 @@ -68,6 +61,11 @@ class Config(SimpleConfig): ## 如果需要自定义本地模型路径,则在 src/.env 中配置 MODEL_DIR self.add_item("model_provider", default="siliconflow", des="模型提供商", choices=list(self.model_names.keys())) self.add_item("model_name", default="zai-org/GLM-4.5", des="模型名称") + self.add_item( + "fast_model", + default="siliconflow/THUDM/GLM-4-9B-0414", + des="快速响应模型", + ) self.add_item( "embed_model", @@ -238,5 +236,4 @@ class Config(SimpleConfig): return value - config = Config() diff --git a/src/storage/db/models.py b/src/storage/db/models.py index 416fb015..4ef1852d 100644 --- a/src/storage/db/models.py +++ b/src/storage/db/models.py @@ -369,3 +369,31 @@ class OperationLog(Base): "ip_address": self.ip_address, "timestamp": self.timestamp.isoformat() if self.timestamp else None, } + + +class MessageFeedback(Base): + """Message feedback table - stores user feedback on AI responses""" + + __tablename__ = "message_feedbacks" + + id = Column(Integer, primary_key=True, autoincrement=True, comment="Primary key") + message_id = Column( + Integer, ForeignKey("messages.id"), nullable=False, index=True, comment="Message ID being rated" + ) + user_id = Column(String(64), nullable=False, index=True, comment="User ID who provided feedback") + rating = Column(String(10), nullable=False, comment="Feedback rating: like or dislike") + reason = Column(Text, nullable=True, comment="Optional reason for dislike feedback") + created_at = Column(DateTime, default=lambda: dt.datetime.now(dt.UTC), comment="Feedback creation time") + + # Relationships + message = relationship("Message", backref="feedbacks") + + def to_dict(self): + return { + "id": self.id, + "message_id": self.message_id, + "user_id": self.user_id, + "rating": self.rating, + "reason": self.reason, + "created_at": self.created_at.isoformat() if self.created_at else None, + } diff --git a/web/src/apis/agent_api.js b/web/src/apis/agent_api.js index cd0aed5d..c94facf1 100644 --- a/web/src/apis/agent_api.js +++ b/web/src/apis/agent_api.js @@ -65,6 +65,23 @@ export const agentApi = { */ getAgentHistory: (agentId, threadId) => apiGet(`/api/chat/agent/${agentId}/history?thread_id=${threadId}`), + /** + * Submit feedback for a message + * @param {number} messageId - Message ID + * @param {string} rating - 'like' or 'dislike' + * @param {string|null} reason - Optional reason for dislike + * @returns {Promise} - Feedback response + */ + submitMessageFeedback: (messageId, rating, reason = null) => + apiPost(`/api/chat/message/${messageId}/feedback`, { rating, reason }), + + /** + * Get feedback status for a message + * @param {number} messageId - Message ID + * @returns {Promise} - Feedback status + */ + getMessageFeedback: (messageId) => apiGet(`/api/chat/message/${messageId}/feedback`), + /** * 获取模型提供商的模型列表 * @param {string} provider - 模型提供商 diff --git a/web/src/apis/dashboard_api.js b/web/src/apis/dashboard_api.js index d06e8ee8..5fc9890e 100644 --- a/web/src/apis/dashboard_api.js +++ b/web/src/apis/dashboard_api.js @@ -42,6 +42,23 @@ export const dashboardApi = { */ getStats: () => { return apiAdminGet('/api/dashboard/stats') + }, + + /** + * 获取用户反馈列表 + * @param {Object} params - 查询参数 + * @param {string} params.rating - 反馈类型过滤 (like/dislike/all) + * @param {number} params.limit - 每页数量 + * @param {number} params.offset - 偏移量 + * @returns {Promise} - 反馈列表 + */ + getFeedbacks: (params = {}) => { + const queryParams = new URLSearchParams() + if (params.rating && params.rating !== 'all') queryParams.append('rating', params.rating) + if (params.limit) queryParams.append('limit', params.limit) + if (params.offset) queryParams.append('offset', params.offset) + + return apiAdminGet(`/api/dashboard/feedbacks?${queryParams.toString()}`) } } diff --git a/web/src/components/RefsComponent.vue b/web/src/components/RefsComponent.vue index f7cf59fd..41c6bf20 100644 --- a/web/src/components/RefsComponent.vue +++ b/web/src/components/RefsComponent.vue @@ -1,8 +1,24 @@ @@ -165,6 +312,7 @@ const dislikeThisResponse = (msg) => { border-radius: 8px; font-size: 13px; user-select: none; + transition: all 0.2s ease; &.btn { cursor: pointer; @@ -174,6 +322,16 @@ const dislikeThisResponse = (msg) => { &:active { background: var(--gray-200); } + + // Disabled state - when feedback has been submitted + &.disabled { + cursor: not-allowed; + opacity: 0.7; + + &:hover { + background: var(--gray-50); + } + } } } diff --git a/web/src/components/ToolCallingResult/CalculatorResult.vue b/web/src/components/ToolCallingResult/CalculatorResult.vue index a3a38207..adf18a72 100644 --- a/web/src/components/ToolCallingResult/CalculatorResult.vue +++ b/web/src/components/ToolCallingResult/CalculatorResult.vue @@ -1,8 +1,8 @@ + + + + + + 全部 + 点赞 + 点踩 + + + + + + +