feat(feedback): 新增消息反馈功能,支持用户对消息的点赞和点踩
- 在 chat_router 中添加了提交和获取消息反馈的 API 接口 - 在 dashboard_router 中更新统计信息,包含反馈统计数据 - 新增 MessageFeedback 数据模型,存储用户反馈信息 - 前端实现反馈提交和状态获取功能,优化用户体验 - 更新 DashboardView 以展示用户反馈统计信息
This commit is contained in:
parent
a3028d17b5
commit
cdb381b0e9
@ -11,7 +11,7 @@ from langchain_core.messages import AIMessageChunk, HumanMessage, ToolMessage
|
|||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from sqlalchemy.orm import Session
|
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 src.storage.conversation import ConversationManager
|
||||||
from server.routers.auth_router import get_admin_user
|
from server.routers.auth_router import get_admin_user
|
||||||
from server.utils.auth_middleware import get_db, get_required_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"}
|
role_type_map = {"user": "human", "assistant": "ai", "tool": "tool", "system": "system"}
|
||||||
|
|
||||||
msg_dict = {
|
msg_dict = {
|
||||||
|
"id": msg.id, # Include message ID for feedback
|
||||||
"type": role_type_map.get(msg.role, msg.role), # human/ai/tool/system
|
"type": role_type_map.get(msg.role, msg.role), # human/ai/tool/system
|
||||||
"content": msg.content,
|
"content": msg.content,
|
||||||
"created_at": msg.created_at.isoformat() if msg.created_at else None,
|
"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(),
|
"created_at": updated_conv.created_at.isoformat(),
|
||||||
"updated_at": updated_conv.updated_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)}")
|
||||||
|
|||||||
@ -186,7 +186,7 @@ async def get_dashboard_stats(
|
|||||||
current_user: User = Depends(get_admin_user),
|
current_user: User = Depends(get_admin_user),
|
||||||
):
|
):
|
||||||
"""Get dashboard statistics (Admin only)"""
|
"""Get dashboard statistics (Admin only)"""
|
||||||
from src.storage.db.models import Conversation, Message
|
from src.storage.db.models import Conversation, Message, MessageFeedback
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Basic counts
|
# 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
|
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 {
|
return {
|
||||||
"timestamp": datetime.utcnow().isoformat(),
|
"timestamp": datetime.utcnow().isoformat(),
|
||||||
"total_conversations": total_conversations,
|
"total_conversations": total_conversations,
|
||||||
@ -224,8 +239,82 @@ async def get_dashboard_stats(
|
|||||||
"conversations_24h": recent_conversations,
|
"conversations_24h": recent_conversations,
|
||||||
"messages_24h": recent_messages,
|
"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:
|
except Exception as e:
|
||||||
logger.error(f"Error getting dashboard stats: {e}")
|
logger.error(f"Error getting dashboard stats: {e}")
|
||||||
logger.error(traceback.format_exc())
|
logger.error(traceback.format_exc())
|
||||||
raise HTTPException(status_code=500, detail=f"Failed to get dashboard stats: {str(e)}")
|
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)}")
|
||||||
|
|||||||
@ -54,13 +54,6 @@ class Config(SimpleConfig):
|
|||||||
self.add_item(
|
self.add_item(
|
||||||
"content_guard_llm_model", default="siliconflow/Qwen/Qwen3-235B-A22B-Instruct-2507", des="内容审查LLM模型"
|
"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")
|
self.add_item("default_agent_id", default="", des="默认智能体ID")
|
||||||
# 模型配置
|
# 模型配置
|
||||||
@ -68,6 +61,11 @@ class Config(SimpleConfig):
|
|||||||
## 如果需要自定义本地模型路径,则在 src/.env 中配置 MODEL_DIR
|
## 如果需要自定义本地模型路径,则在 src/.env 中配置 MODEL_DIR
|
||||||
self.add_item("model_provider", default="siliconflow", des="模型提供商", choices=list(self.model_names.keys()))
|
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("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(
|
self.add_item(
|
||||||
"embed_model",
|
"embed_model",
|
||||||
@ -238,5 +236,4 @@ class Config(SimpleConfig):
|
|||||||
|
|
||||||
return value
|
return value
|
||||||
|
|
||||||
|
|
||||||
config = Config()
|
config = Config()
|
||||||
|
|||||||
@ -369,3 +369,31 @@ class OperationLog(Base):
|
|||||||
"ip_address": self.ip_address,
|
"ip_address": self.ip_address,
|
||||||
"timestamp": self.timestamp.isoformat() if self.timestamp else None,
|
"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,
|
||||||
|
}
|
||||||
|
|||||||
@ -65,6 +65,23 @@ export const agentApi = {
|
|||||||
*/
|
*/
|
||||||
getAgentHistory: (agentId, threadId) => apiGet(`/api/chat/agent/${agentId}/history?thread_id=${threadId}`),
|
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 - 模型提供商
|
* @param {string} provider - 模型提供商
|
||||||
|
|||||||
@ -42,6 +42,23 @@ export const dashboardApi = {
|
|||||||
*/
|
*/
|
||||||
getStats: () => {
|
getStats: () => {
|
||||||
return apiAdminGet('/api/dashboard/stats')
|
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<Array>} - 反馈列表
|
||||||
|
*/
|
||||||
|
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()}`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,8 +1,24 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="refs" v-if="showRefs">
|
<div class="refs" v-if="showRefs">
|
||||||
<div class="tags">
|
<div class="tags">
|
||||||
<span class="item btn" @click="likeThisResponse(msg)"><LikeOutlined /></span>
|
<span
|
||||||
<span class="item btn" @click="dislikeThisResponse(msg)"><DislikeOutlined /></span>
|
class="item btn"
|
||||||
|
:class="{ 'disabled': feedbackState.hasSubmitted }"
|
||||||
|
@click="likeThisResponse(msg)"
|
||||||
|
:title="feedbackState.hasSubmitted && feedbackState.rating === 'like' ? '已点赞' : '点赞'"
|
||||||
|
>
|
||||||
|
<LikeFilled v-if="feedbackState.rating === 'like'" />
|
||||||
|
<LikeOutlined v-else />
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
class="item btn"
|
||||||
|
:class="{ 'disabled': feedbackState.hasSubmitted }"
|
||||||
|
@click="dislikeThisResponse(msg)"
|
||||||
|
:title="feedbackState.hasSubmitted && feedbackState.rating === 'dislike' ? '已点踩' : '点踩'"
|
||||||
|
>
|
||||||
|
<DislikeFilled v-if="feedbackState.rating === 'dislike'" />
|
||||||
|
<DislikeOutlined v-else />
|
||||||
|
</span>
|
||||||
<span v-if="showKey('model') && getModelName(msg)" class="item" @click="console.log(msg)">
|
<span v-if="showKey('model') && getModelName(msg)" class="item" @click="console.log(msg)">
|
||||||
<BulbOutlined /> {{ getModelName(msg) }}
|
<BulbOutlined /> {{ getModelName(msg) }}
|
||||||
</span>
|
</span>
|
||||||
@ -35,10 +51,29 @@
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Dislike reason modal -->
|
||||||
|
<a-modal
|
||||||
|
v-model:open="dislikeModalVisible"
|
||||||
|
title="请告诉我们不满意的原因"
|
||||||
|
@ok="submitDislikeFeedback"
|
||||||
|
@cancel="cancelDislike"
|
||||||
|
:confirmLoading="submittingFeedback"
|
||||||
|
okText="提交"
|
||||||
|
cancelText="取消"
|
||||||
|
>
|
||||||
|
<a-textarea
|
||||||
|
v-model:value="dislikeReason"
|
||||||
|
:rows="4"
|
||||||
|
placeholder="您的反馈将帮助我们改进服务(可选)"
|
||||||
|
:maxlength="500"
|
||||||
|
show-count
|
||||||
|
/>
|
||||||
|
</a-modal>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed } from 'vue'
|
import { ref, computed, reactive, onMounted } from 'vue'
|
||||||
import { useClipboard } from '@vueuse/core'
|
import { useClipboard } from '@vueuse/core'
|
||||||
import { message } from 'ant-design-vue'
|
import { message } from 'ant-design-vue'
|
||||||
import {
|
import {
|
||||||
@ -49,8 +84,11 @@ import {
|
|||||||
BulbOutlined,
|
BulbOutlined,
|
||||||
ReloadOutlined,
|
ReloadOutlined,
|
||||||
LikeOutlined,
|
LikeOutlined,
|
||||||
|
LikeFilled,
|
||||||
DislikeOutlined,
|
DislikeOutlined,
|
||||||
|
DislikeFilled,
|
||||||
} from '@ant-design/icons-vue'
|
} from '@ant-design/icons-vue'
|
||||||
|
import { agentApi } from '@/apis'
|
||||||
|
|
||||||
const emit = defineEmits(['retry', 'openRefs']);
|
const emit = defineEmits(['retry', 'openRefs']);
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
@ -67,6 +105,18 @@ const props = defineProps({
|
|||||||
|
|
||||||
const msg = ref(props.message)
|
const msg = ref(props.message)
|
||||||
|
|
||||||
|
// Feedback state
|
||||||
|
const feedbackState = reactive({
|
||||||
|
hasSubmitted: false,
|
||||||
|
rating: null, // 'like' or 'dislike'
|
||||||
|
reason: null,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Modal state for dislike
|
||||||
|
const dislikeModalVisible = ref(false)
|
||||||
|
const dislikeReason = ref('')
|
||||||
|
const submittingFeedback = ref(false)
|
||||||
|
|
||||||
// 使用 useClipboard 实现复制功能
|
// 使用 useClipboard 实现复制功能
|
||||||
const { copy, isSupported } = useClipboard()
|
const { copy, isSupported } = useClipboard()
|
||||||
|
|
||||||
@ -140,12 +190,109 @@ const getModelName = (msg) => {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const likeThisResponse = (msg) => {
|
// Load existing feedback on mount
|
||||||
console.log(msg)
|
onMounted(async () => {
|
||||||
|
if (msg.value?.id) {
|
||||||
|
try {
|
||||||
|
const response = await agentApi.getMessageFeedback(msg.value.id)
|
||||||
|
if (response.has_feedback) {
|
||||||
|
feedbackState.hasSubmitted = true
|
||||||
|
feedbackState.rating = response.feedback.rating
|
||||||
|
feedbackState.reason = response.feedback.reason
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load feedback:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Handle like action
|
||||||
|
const likeThisResponse = async (msg) => {
|
||||||
|
if (feedbackState.hasSubmitted) {
|
||||||
|
message.info('您已经提交过反馈了')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!msg?.id) {
|
||||||
|
message.error('无法提交反馈:消息ID不存在')
|
||||||
|
console.error('Message object:', msg)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
submittingFeedback.value = true
|
||||||
|
await agentApi.submitMessageFeedback(msg.id, 'like', null)
|
||||||
|
|
||||||
|
feedbackState.hasSubmitted = true
|
||||||
|
feedbackState.rating = 'like'
|
||||||
|
|
||||||
|
message.success('感谢您的反馈!')
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to submit like feedback:', error)
|
||||||
|
if (error.message?.includes('already submitted')) {
|
||||||
|
message.info('您已经提交过反馈了')
|
||||||
|
feedbackState.hasSubmitted = true
|
||||||
|
} else {
|
||||||
|
message.error('提交反馈失败,请稍后重试')
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
submittingFeedback.value = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const dislikeThisResponse = (msg) => {
|
// Handle dislike action
|
||||||
console.log(msg)
|
const dislikeThisResponse = async (msg) => {
|
||||||
|
if (feedbackState.hasSubmitted) {
|
||||||
|
message.info('您已经提交过反馈了')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!msg?.id) {
|
||||||
|
message.error('无法提交反馈:消息ID不存在')
|
||||||
|
console.error('Message object:', msg)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open modal to get reason
|
||||||
|
dislikeModalVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Submit dislike feedback with reason
|
||||||
|
const submitDislikeFeedback = async () => {
|
||||||
|
try {
|
||||||
|
submittingFeedback.value = true
|
||||||
|
await agentApi.submitMessageFeedback(
|
||||||
|
msg.value.id,
|
||||||
|
'dislike',
|
||||||
|
dislikeReason.value || null
|
||||||
|
)
|
||||||
|
|
||||||
|
feedbackState.hasSubmitted = true
|
||||||
|
feedbackState.rating = 'dislike'
|
||||||
|
feedbackState.reason = dislikeReason.value
|
||||||
|
|
||||||
|
dislikeModalVisible.value = false
|
||||||
|
dislikeReason.value = ''
|
||||||
|
|
||||||
|
message.success('感谢您的反馈!')
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to submit dislike feedback:', error)
|
||||||
|
if (error.message?.includes('already submitted')) {
|
||||||
|
message.info('您已经提交过反馈了')
|
||||||
|
feedbackState.hasSubmitted = true
|
||||||
|
dislikeModalVisible.value = false
|
||||||
|
} else {
|
||||||
|
message.error('提交反馈失败,请稍后重试')
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
submittingFeedback.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cancel dislike modal
|
||||||
|
const cancelDislike = () => {
|
||||||
|
dislikeModalVisible.value = false
|
||||||
|
dislikeReason.value = ''
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@ -165,6 +312,7 @@ const dislikeThisResponse = (msg) => {
|
|||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
|
||||||
&.btn {
|
&.btn {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
@ -174,6 +322,16 @@ const dislikeThisResponse = (msg) => {
|
|||||||
&:active {
|
&:active {
|
||||||
background: var(--gray-200);
|
background: var(--gray-200);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Disabled state - when feedback has been submitted
|
||||||
|
&.disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.7;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--gray-50);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,8 +1,8 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="calculator-result">
|
<div class="calculator-result">
|
||||||
<div class="calc-header">
|
<!-- <div class="calc-header">
|
||||||
<h4><NumberOutlined /> 计算结果</h4>
|
<h4><NumberOutlined /> 计算结果</h4>
|
||||||
</div>
|
</div> -->
|
||||||
|
|
||||||
<div class="calc-display">
|
<div class="calc-display">
|
||||||
<div class="result-container">
|
<div class="result-container">
|
||||||
@ -82,7 +82,7 @@ const formatNumber = (num) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.result-value {
|
.result-value {
|
||||||
font-size: 1.1rem;
|
font-size: 1rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--main-color);
|
color: var(--main-color);
|
||||||
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
|
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
|
||||||
|
|||||||
@ -122,19 +122,18 @@ const mainList = [{
|
|||||||
</div>
|
</div>
|
||||||
<div class="nav">
|
<div class="nav">
|
||||||
<!-- 使用mainList渲染导航项 -->
|
<!-- 使用mainList渲染导航项 -->
|
||||||
<a-tooltip
|
<RouterLink
|
||||||
v-for="(item, index) in mainList"
|
v-for="(item, index) in mainList"
|
||||||
:key="index"
|
:key="index"
|
||||||
placement="right"
|
:to="item.path"
|
||||||
v-show="!item.hidden">
|
v-show="!item.hidden"
|
||||||
<template #title>{{ item.name }}</template>
|
class="nav-item"
|
||||||
<RouterLink
|
active-class="active">
|
||||||
:to="item.path"
|
<a-tooltip placement="right">
|
||||||
class="nav-item"
|
<template #title>{{ item.name }}</template>
|
||||||
active-class="active">
|
|
||||||
<component class="icon" :is="route.path.startsWith(item.path) ? item.activeIcon : item.icon" size="22"/>
|
<component class="icon" :is="route.path.startsWith(item.path) ? item.activeIcon : item.icon" size="22"/>
|
||||||
</RouterLink>
|
</a-tooltip>
|
||||||
</a-tooltip>
|
</RouterLink>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
ref="htmlRefHook"
|
ref="htmlRefHook"
|
||||||
@ -207,7 +206,7 @@ const mainList = [{
|
|||||||
|
|
||||||
<style lang="less" scoped>
|
<style lang="less" scoped>
|
||||||
// Less 变量定义
|
// Less 变量定义
|
||||||
@header-width: 60px;
|
@header-width: 50px;
|
||||||
|
|
||||||
.app-layout {
|
.app-layout {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@ -16,6 +16,50 @@
|
|||||||
</a-card>
|
</a-card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 用户反馈统计区域 -->
|
||||||
|
<a-card class="feedback-section" title="用户反馈统计" :loading="loading">
|
||||||
|
<a-row :gutter="16">
|
||||||
|
<a-col :span="6">
|
||||||
|
<a-statistic
|
||||||
|
title="总反馈数"
|
||||||
|
:value="stats.feedback_stats?.total_feedbacks || 0"
|
||||||
|
/>
|
||||||
|
</a-col>
|
||||||
|
<a-col :span="6">
|
||||||
|
<a-statistic
|
||||||
|
title="点赞数"
|
||||||
|
:value="stats.feedback_stats?.like_count || 0"
|
||||||
|
:value-style="{ color: '#3f8600' }"
|
||||||
|
>
|
||||||
|
<template #prefix>
|
||||||
|
<LikeOutlined />
|
||||||
|
</template>
|
||||||
|
</a-statistic>
|
||||||
|
</a-col>
|
||||||
|
<a-col :span="6">
|
||||||
|
<a-statistic
|
||||||
|
title="点踩数"
|
||||||
|
:value="stats.feedback_stats?.dislike_count || 0"
|
||||||
|
:value-style="{ color: '#cf1322' }"
|
||||||
|
>
|
||||||
|
<template #prefix>
|
||||||
|
<DislikeOutlined />
|
||||||
|
</template>
|
||||||
|
</a-statistic>
|
||||||
|
</a-col>
|
||||||
|
<a-col :span="6">
|
||||||
|
<a-statistic
|
||||||
|
title="满意度"
|
||||||
|
:value="stats.feedback_stats?.satisfaction_rate || 0"
|
||||||
|
suffix="%"
|
||||||
|
:value-style="{ color: stats.feedback_stats?.satisfaction_rate >= 80 ? '#3f8600' : stats.feedback_stats?.satisfaction_rate >= 60 ? '#fa8c16' : '#cf1322' }"
|
||||||
|
/>
|
||||||
|
</a-col>
|
||||||
|
</a-row>
|
||||||
|
<a-divider />
|
||||||
|
<a-button type="primary" @click="showFeedbackList">查看反馈详情</a-button>
|
||||||
|
</a-card>
|
||||||
|
|
||||||
<!-- 过滤器区域 -->
|
<!-- 过滤器区域 -->
|
||||||
<a-card class="filter-section" title="筛选条件">
|
<a-card class="filter-section" title="筛选条件">
|
||||||
<a-row :gutter="16">
|
<a-row :gutter="16">
|
||||||
@ -88,12 +132,57 @@
|
|||||||
</template>
|
</template>
|
||||||
</a-table>
|
</a-table>
|
||||||
</a-card>
|
</a-card>
|
||||||
|
|
||||||
|
<!-- 反馈列表模态框 -->
|
||||||
|
<a-modal
|
||||||
|
v-model:open="feedbackModalVisible"
|
||||||
|
title="用户反馈详情"
|
||||||
|
width="1000px"
|
||||||
|
:footer="null"
|
||||||
|
>
|
||||||
|
<a-space style="margin-bottom: 16px">
|
||||||
|
<a-radio-group v-model:value="feedbackFilter" @change="loadFeedbacks">
|
||||||
|
<a-radio-button value="all">全部</a-radio-button>
|
||||||
|
<a-radio-button value="like">点赞</a-radio-button>
|
||||||
|
<a-radio-button value="dislike">点踩</a-radio-button>
|
||||||
|
</a-radio-group>
|
||||||
|
</a-space>
|
||||||
|
|
||||||
|
<a-table
|
||||||
|
:columns="feedbackColumns"
|
||||||
|
:data-source="feedbacks"
|
||||||
|
:loading="loadingFeedbacks"
|
||||||
|
:pagination="feedbackPagination"
|
||||||
|
row-key="id"
|
||||||
|
size="small"
|
||||||
|
>
|
||||||
|
<template #bodyCell="{ column, record }">
|
||||||
|
<template v-if="column.key === 'rating'">
|
||||||
|
<a-tag :color="record.rating === 'like' ? 'green' : 'red'">
|
||||||
|
<template #icon>
|
||||||
|
<LikeOutlined v-if="record.rating === 'like'" />
|
||||||
|
<DislikeOutlined v-else />
|
||||||
|
</template>
|
||||||
|
{{ record.rating === 'like' ? '点赞' : '点踩' }}
|
||||||
|
</a-tag>
|
||||||
|
</template>
|
||||||
|
<template v-if="column.key === 'reason'">
|
||||||
|
<span v-if="record.reason">{{ record.reason }}</span>
|
||||||
|
<span v-else style="color: #999">-</span>
|
||||||
|
</template>
|
||||||
|
<template v-if="column.key === 'created_at'">
|
||||||
|
{{ formatDate(record.created_at) }}
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
</a-table>
|
||||||
|
</a-modal>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, reactive, onMounted } from 'vue'
|
import { ref, reactive, onMounted } from 'vue'
|
||||||
import { message } from 'ant-design-vue'
|
import { message } from 'ant-design-vue'
|
||||||
|
import { LikeOutlined, DislikeOutlined } from '@ant-design/icons-vue'
|
||||||
import { dashboardApi } from '@/apis/dashboard_api'
|
import { dashboardApi } from '@/apis/dashboard_api'
|
||||||
|
|
||||||
// 统计数据
|
// 统计数据
|
||||||
@ -102,6 +191,13 @@ const stats = reactive({
|
|||||||
active_conversations: 0,
|
active_conversations: 0,
|
||||||
total_messages: 0,
|
total_messages: 0,
|
||||||
total_users: 0,
|
total_users: 0,
|
||||||
|
feedback_stats: {
|
||||||
|
total_feedbacks: 0,
|
||||||
|
like_count: 0,
|
||||||
|
dislike_count: 0,
|
||||||
|
satisfaction_rate: 0,
|
||||||
|
recent_feedbacks_24h: 0,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
// 过滤器
|
// 过滤器
|
||||||
@ -115,6 +211,17 @@ const filters = reactive({
|
|||||||
const conversations = ref([])
|
const conversations = ref([])
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
|
|
||||||
|
// 反馈相关
|
||||||
|
const feedbackModalVisible = ref(false)
|
||||||
|
const feedbacks = ref([])
|
||||||
|
const loadingFeedbacks = ref(false)
|
||||||
|
const feedbackFilter = ref('all')
|
||||||
|
const feedbackPagination = reactive({
|
||||||
|
current: 1,
|
||||||
|
pageSize: 20,
|
||||||
|
total: 0,
|
||||||
|
})
|
||||||
|
|
||||||
// 分页
|
// 分页
|
||||||
const pagination = reactive({
|
const pagination = reactive({
|
||||||
current: 1,
|
current: 1,
|
||||||
@ -176,6 +283,49 @@ const columns = [
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
// 反馈表格列定义
|
||||||
|
const feedbackColumns = [
|
||||||
|
{
|
||||||
|
title: '反馈类型',
|
||||||
|
key: 'rating',
|
||||||
|
width: '10%',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '用户ID',
|
||||||
|
dataIndex: 'user_id',
|
||||||
|
key: 'user_id',
|
||||||
|
width: '12%',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '智能体ID',
|
||||||
|
dataIndex: 'agent_id',
|
||||||
|
key: 'agent_id',
|
||||||
|
width: '12%',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '对话标题',
|
||||||
|
dataIndex: 'conversation_title',
|
||||||
|
key: 'conversation_title',
|
||||||
|
width: '15%',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '消息内容',
|
||||||
|
dataIndex: 'message_content',
|
||||||
|
key: 'message_content',
|
||||||
|
width: '25%',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '反馈原因',
|
||||||
|
key: 'reason',
|
||||||
|
width: '16%',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '时间',
|
||||||
|
key: 'created_at',
|
||||||
|
width: '10%',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
// 加载统计数据
|
// 加载统计数据
|
||||||
const loadStats = async () => {
|
const loadStats = async () => {
|
||||||
try {
|
try {
|
||||||
@ -262,6 +412,32 @@ const formatDate = (dateString) => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 显示反馈列表
|
||||||
|
const showFeedbackList = () => {
|
||||||
|
feedbackModalVisible.value = true
|
||||||
|
loadFeedbacks()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 加载反馈列表
|
||||||
|
const loadFeedbacks = async () => {
|
||||||
|
loadingFeedbacks.value = true
|
||||||
|
try {
|
||||||
|
const params = {
|
||||||
|
rating: feedbackFilter.value === 'all' ? undefined : feedbackFilter.value,
|
||||||
|
limit: feedbackPagination.pageSize,
|
||||||
|
offset: (feedbackPagination.current - 1) * feedbackPagination.pageSize,
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await dashboardApi.getFeedbacks(params)
|
||||||
|
feedbacks.value = response
|
||||||
|
} catch (error) {
|
||||||
|
console.error('加载反馈列表失败:', error)
|
||||||
|
message.error('加载反馈列表失败')
|
||||||
|
} finally {
|
||||||
|
loadingFeedbacks.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 初始化
|
// 初始化
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
loadStats()
|
loadStats()
|
||||||
@ -299,6 +475,12 @@ onMounted(() => {
|
|||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.feedback-section {
|
||||||
|
margin-bottom: 24px;
|
||||||
|
background-color: var(--card-bg-color);
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
.conversations-section {
|
.conversations-section {
|
||||||
background-color: var(--card-bg-color);
|
background-color: var(--card-bg-color);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
|
|||||||
@ -25,6 +25,14 @@
|
|||||||
:model_provider="configStore.config?.model_provider"
|
:model_provider="configStore.config?.model_provider"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="card card-select">
|
||||||
|
<span class="label">{{ items?.fast_model.des }}</span>
|
||||||
|
<ModelSelectorComponent
|
||||||
|
@select-model="handleFastModelSelect"
|
||||||
|
:model_name="fastModelName"
|
||||||
|
:model_provider="fastModelProvider"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<div class="card card-select">
|
<div class="card card-select">
|
||||||
<div style="display: flex; justify-content: space-between; align-items: center;">
|
<div style="display: flex; justify-content: space-between; align-items: center;">
|
||||||
<span class="label">{{ items?.embed_model.des }}</span>
|
<span class="label">{{ items?.embed_model.des }}</span>
|
||||||
@ -250,6 +258,23 @@ const handleChatModelSelect = ({ provider, name }) => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const fastModelProvider = computed(() => {
|
||||||
|
const fastModel = configStore.config?.fast_model
|
||||||
|
if (!fastModel) return ''
|
||||||
|
return fastModel.split('/')[0]
|
||||||
|
})
|
||||||
|
|
||||||
|
const fastModelName = computed(() => {
|
||||||
|
const fastModel = configStore.config?.fast_model
|
||||||
|
if (!fastModel) return ''
|
||||||
|
const parts = fastModel.split('/')
|
||||||
|
return parts.slice(1).join('/')
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleFastModelSelect = ({ provider, name }) => {
|
||||||
|
configStore.setConfigValue('fast_model', `${provider}/${name}`)
|
||||||
|
}
|
||||||
|
|
||||||
const contentGuardModelProvider = computed(() => {
|
const contentGuardModelProvider = computed(() => {
|
||||||
const contentGuardModel = configStore.config?.content_guard_llm_model
|
const contentGuardModel = configStore.config?.content_guard_llm_model
|
||||||
if (!contentGuardModel) return ''
|
if (!contentGuardModel) return ''
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user