feat(feedback): 新增消息反馈功能,支持用户对消息的点赞和点踩

- 在 chat_router 中添加了提交和获取消息反馈的 API 接口
- 在 dashboard_router 中更新统计信息,包含反馈统计数据
- 新增 MessageFeedback 数据模型,存储用户反馈信息
- 前端实现反馈提交和状态获取功能,优化用户体验
- 更新 DashboardView 以展示用户反馈统计信息
This commit is contained in:
Wenjie Zhang 2025-10-05 14:50:34 +08:00
parent a3028d17b5
commit cdb381b0e9
11 changed files with 653 additions and 31 deletions

View File

@ -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)}")

View File

@ -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)}")

View File

@ -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()

View File

@ -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,
}

View File

@ -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 - 模型提供商

View File

@ -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()}`)
} }
} }

View File

@ -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);
}
}
} }
} }

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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 ''