feat(chat): 添加对话反馈功能和置信度显示

- 新增对话记录反馈提交功能,支持用户提交反馈内容
- 添加多语言支持包括英文、简体中文、繁体中文的反馈相关文案
- 在AI聊天界面添加反馈按钮和对话框组件
- 实现反馈类型选择和内容输入验证功能
- 添加置信度分数显示功能,在答案内容区域展示综合评分
- 更新应用发布流程,支持内部发布和公开发布的模式选择
- 优化系统资源管理中的应用访问控制逻辑
- 扩展应用聊天记录数据结构,增加反馈内容字段
- 实现反馈列表和详情展示功能,支持历史对话中的反馈查看
v3.2
tanlianwang 2026-03-31 19:03:10 +08:00
parent 193361fd64
commit c4361b158a
35 changed files with 791 additions and 14 deletions

View File

@ -42,6 +42,10 @@ class ApplicationChatResponseSerializers(serializers.Serializer):
star_num = serializers.IntegerField(required=True, label=_("Number of Likes"))
trample_num = serializers.IntegerField(required=True, label=_("Number of thumbs-downs"))
mark_sum = serializers.IntegerField(required=True, label=_("Number of tags"))
feedback_count = serializers.IntegerField(required=False, label=_("Feedback count"))
feedback_content = serializers.CharField(required=False, allow_null=True, allow_blank=True,
label=_("Feedback content"))
feedback_list = serializers.ListField(required=False, label=_("Feedback list"))
class ApplicationChatRecordExportRequest(serializers.Serializer):
@ -136,11 +140,45 @@ class ApplicationChatQuerySerializers(serializers.Serializer):
def list(self, with_valid=True):
if with_valid:
self.is_valid(raise_exception=True)
return native_search(self.get_query_set(), select_string=get_file_content(
result = native_search(self.get_query_set(), select_string=get_file_content(
os.path.join(PROJECT_DIR, "apps", "application", 'sql',
('list_application_chat_ee.sql' if ['PE', 'EE'].__contains__(
edition) else 'list_application_chat.sql'))),
with_table_name=False)
return self.append_feedback_content(result)
@staticmethod
def get_feedback_items(details):
if not isinstance(details, dict):
return []
feedback = details.get('feedback')
if isinstance(feedback, list):
return feedback
if isinstance(feedback, dict):
items = feedback.get('items')
return items if isinstance(items, list) else []
return []
@classmethod
def append_feedback_content(cls, records):
if not records:
return records
chat_id_list = [row.get('id') for row in records if row.get('id') is not None]
feedback_map = {}
chat_records = QuerySet(ChatRecord).filter(chat_id__in=chat_id_list).order_by('create_time')
for chat_record in chat_records:
feedback_items = cls.get_feedback_items(chat_record.details)
if not feedback_items:
continue
feedback_map[str(chat_record.chat_id)] = feedback_items
for row in records:
feedback_items = feedback_map.get(str(row.get('id')), [])
latest_feedback = feedback_items[-1] if feedback_items else {}
row['feedback_count'] = len(feedback_items)
row['feedback_content'] = latest_feedback.get('content') if isinstance(latest_feedback, dict) else ''
row['feedback_list'] = feedback_items
return records
@staticmethod
def paragraph_list_to_string(paragraph_list):
@ -239,11 +277,13 @@ class ApplicationChatQuerySerializers(serializers.Serializer):
def page(self, current_page: int, page_size: int, with_valid=True):
if with_valid:
self.is_valid(raise_exception=True)
return native_page_search(current_page, page_size, self.get_query_set(), select_string=get_file_content(
page = native_page_search(current_page, page_size, self.get_query_set(), select_string=get_file_content(
os.path.join(PROJECT_DIR, "apps", "application", 'sql',
('list_application_chat_ee.sql' if ['PE', 'EE'].__contains__(
edition) else 'list_application_chat.sql'))),
with_table_name=False)
page['records'] = self.append_feedback_content(page.get('records'))
return page
class ChatCountSerializer(serializers.Serializer):

View File

@ -10,6 +10,7 @@ from functools import reduce
from typing import Dict
import uuid_utils.compat as uuid
from django.utils import timezone
from django.db import transaction
from django.db.models import QuerySet
from django.db.models.aggregates import Max, Min
@ -45,6 +46,8 @@ class ChatRecordOperateSerializer(serializers.Serializer):
workspace_id = serializers.CharField(required=False, allow_null=True, allow_blank=True, label=_("Workspace ID"))
application_id = serializers.UUIDField(required=True, label=_("Application ID"))
chat_record_id = serializers.UUIDField(required=True, label=_("Conversation record id"))
feedback_type = serializers.CharField(required=False, allow_blank=True, label=_("Feedback type"))
content = serializers.CharField(required=False, allow_blank=True, label=_("Feedback content"))
def is_valid(self, *, debug=False, raise_exception=False):
super().is_valid(raise_exception=True)
@ -85,6 +88,49 @@ class ChatRecordOperateSerializer(serializers.Serializer):
return ApplicationChatRecordQuerySerializers.reset_chat_record(
chat_record, True if debug else show_source, True if debug else show_exec)
def feedback(self, request=None, with_valid=True):
if with_valid:
self.is_valid(raise_exception=True)
chat_record = self.get_chat_record()
if chat_record is None:
raise AppApiException(500, gettext('Conversation record does not exist'))
feedback_type = self.data.get('feedback_type')
content = self.data.get('content')
if not feedback_type:
raise AppApiException(500, gettext('Feedback type is required'))
if not content:
raise AppApiException(500, gettext('Feedback content is required'))
feedback_items = []
feedback_details = chat_record.details.get('feedback')
if isinstance(feedback_details, list):
feedback_items = feedback_details
elif isinstance(feedback_details, dict):
feedback_items = feedback_details.get('items') or []
feedback_data = {
'feedback_type': feedback_type,
'content': content,
'create_time': timezone.now().isoformat()
}
chat_record.details['feedback'] = {
'type': 'feedback-node',
'items': [*feedback_items, feedback_data]
}
chat_record.save()
application_access_token = QuerySet(ApplicationAccessToken).filter(
application_id=self.data.get('application_id')).first()
show_source = False
show_exec = False
if application_access_token is not None:
show_exec = application_access_token.show_exec
show_source = application_access_token.show_source
return ApplicationChatRecordQuerySerializers.reset_chat_record(
chat_record, show_source, show_exec)
class ApplicationChatRecordQuerySerializers(serializers.Serializer):
workspace_id = serializers.CharField(required=False, allow_null=True, allow_blank=True, label=_("Workspace ID"))
@ -114,12 +160,14 @@ class ApplicationChatRecordQuerySerializers(serializers.Serializer):
def reset_chat_record(chat_record, show_source, show_exec):
knowledge_list = []
paragraph_list = []
if 'search_step' in chat_record.details and chat_record.details.get('search_step').get(
if 'search_step' in chat_record.details and isinstance(chat_record.details.get('search_step'), dict) and chat_record.details.get('search_step').get(
'paragraph_list') is not None:
paragraph_list = chat_record.details.get('search_step').get(
'paragraph_list')
for item in chat_record.details.values():
if not isinstance(item, dict):
continue
if item.get('type') == 'search-knowledge-node' and item.get('show_knowledge', False):
paragraph_list = paragraph_list + (item.get(
'paragraph_list') or [])
@ -151,6 +199,7 @@ class ApplicationChatRecordQuerySerializers(serializers.Serializer):
show_source_dict = {'knowledge_list': knowledge_list,
'paragraph_list': paragraph_list, }
show_exec_dict = {'execution_details': [chat_record.details[key] for key in chat_record.details if
isinstance(chat_record.details[key], dict) and chat_record.details[key].get('type') != 'feedback-node' and
(True if show_exec else chat_record.details[key].get(
'type') == 'start-node')]}
return {

View File

@ -25,6 +25,7 @@ urlpatterns = [
path('workspace/<str:workspace_id>/application/<str:application_id>/chat/<int:current_page>/<int:page_size>', views.ApplicationChat.Page.as_view()),
path('workspace/<str:workspace_id>/application/<str:application_id>/chat/<str:chat_id>/chat_record', views.ApplicationChatRecord.as_view()),
path('workspace/<str:workspace_id>/application/<str:application_id>/chat/<str:chat_id>/chat_record/<str:chat_record_id>', views.ApplicationChatRecordOperateAPI.as_view()),
path('workspace/<str:workspace_id>/application/<str:application_id>/chat/<str:chat_id>/chat_record/<str:chat_record_id>/feedback', views.ApplicationChatRecordOperateAPI.as_view()),
path('workspace/<str:workspace_id>/application/<str:application_id>/chat/<str:chat_id>/chat_record/<int:current_page>/<int:page_size>', views.ApplicationChatRecord.Page.as_view()),
path('workspace/<str:workspace_id>/application/<str:application_id>/chat/<str:chat_id>/chat_record/<str:chat_record_id>/improve', views.ApplicationChatRecordImprove.as_view()),
path('workspace/<str:workspace_id>/application/<str:application_id>/chat/<str:chat_id>/chat_record/<str:chat_record_id>/knowledge/<str:knowledge_id>/document/<str:document_id>/improve', views.ApplicationChatRecordImproveParagraph.as_view()),

View File

@ -107,6 +107,30 @@ class ApplicationChatRecordOperateAPI(APIView):
'chat_id': chat_id,
'chat_record_id': chat_record_id}).one(True))
@extend_schema(
methods=['POST'],
description=_('Submit conversation feedback'),
summary=_('Submit conversation feedback'),
operation_id=_('Submit conversation feedback'), # type: ignore
tags=[_('Application/Conversation Log')] # type: ignore
)
@has_permissions(PermissionConstants.APPLICATION_CHAT_LOG_READ.get_workspace_application_permission(),
PermissionConstants.APPLICATION_CHAT_LOG_READ.get_workspace_permission_workspace_manage_role(),
PermissionConstants.APPLICATION_READ.get_workspace_application_permission(),
PermissionConstants.APPLICATION_READ.get_workspace_permission_workspace_manage_role(),
ViewPermission([RoleConstants.USER.get_workspace_role()],
[PermissionConstants.APPLICATION.get_workspace_application_permission()],
CompareConstants.AND),
RoleConstants.WORKSPACE_MANAGE.get_workspace_role())
def post(self, request: Request, workspace_id: str, application_id: str, chat_id: str, chat_record_id: str):
return result.success(ChatRecordOperateSerializer(
data={
'workspace_id': workspace_id,
'application_id': application_id,
'chat_id': chat_id,
'chat_record_id': chat_record_id,
**request.data}).feedback(request=request))
class ApplicationChatRecordAddKnowledge(APIView):
authentication_classes = [TokenAuth]

View File

@ -19,6 +19,7 @@ urlpatterns = [
name='application/chat_completions'),
path('vote/chat/<str:chat_id>/chat_record/<str:chat_record_id>', views.VoteView.as_view(), name='vote'),
path('historical_conversation', views.HistoricalConversationView.as_view(), name='historical_conversation'),
path('historical_conversation/<str:chat_id>/record/<str:chat_record_id>/feedback', views.ChatRecordView.as_view(), name='conversation_feedback'),
path('historical_conversation/<str:chat_id>/record/<str:chat_record_id>',views.ChatRecordView.as_view(),name='conversation_details'),
path('historical_conversation/<int:current_page>/<int:page_size>', views.HistoricalConversationView.PageView.as_view(), name='historical_conversation'),
path('historical_conversation/clear',views.HistoricalConversationView.BatchDelete.as_view(), name='historical_conversation_clear'),

View File

@ -198,3 +198,22 @@ class ChatRecordView(APIView):
'application_id': request.auth.application_id,
'chat_user_id': request.auth.chat_user_id,
}).one(False))
@extend_schema(
methods=['POST'],
description=_("Submit conversation feedback"),
summary=_("Submit conversation feedback"),
operation_id=_("Submit conversation feedback"), # type: ignore
parameters=PageHistoricalConversationRecordAPI.get_parameters(),
responses=PageHistoricalConversationRecordAPI.get_response(),
tags=[_('Chat')] # type: ignore
)
def post(self, request: Request, chat_id: str, chat_record_id: str):
return result.success(ChatRecordOperateSerializer(
data={
'chat_id': chat_id,
'chat_record_id': chat_record_id,
'application_id': request.auth.application_id,
'chat_user_id': request.auth.chat_user_id,
**request.data
}).feedback())

View File

@ -3,4 +3,4 @@
prefix: '/admin',
chatPrefix: '/chat',
}
})()</script><script type="module" crossorigin src="./assets/admin-BHHIGtO5.js"></script><link rel="stylesheet" crossorigin href="./assets/admin-uFds98Rz.css"></head><body><div id="app"></div></body></html>
})()</script><script type="module" crossorigin src="./assets/admin-Da392tT3.js"></script><link rel="stylesheet" crossorigin href="./assets/admin-D4RuFzCU.css"></head><body><div id="app"></div></body></html>

View File

@ -198,6 +198,30 @@ const getChatRecordDetails: (
loading,
)
}
/**
*
* @param application_id
* @param chat_id
* @param chat_record_id
* @param data
* @param loading
*/
const postChatRecordFeedback: (
application_id: string,
chat_id: string,
chat_record_id: string,
data: any,
loading?: Ref<boolean>,
) => Promise<Result<any>> = (application_id, chat_id, chat_record_id, data, loading) => {
return post(
`${prefix.value}/${application_id}/chat/${chat_id}/chat_record/${chat_record_id}/feedback`,
data,
undefined,
loading,
)
}
export default {
postChatLogAddKnowledge,
getChatLog,
@ -207,4 +231,5 @@ export default {
delMarkChatRecord,
postExportChatLog,
getChatRecordDetails,
postChatRecordFeedback,
}

View File

@ -250,6 +250,15 @@ const getChatRecord: (
) => Promise<Result<any>> = (chat_id, chat_record_id, loading) => {
return get(`historical_conversation/${chat_id}/record/${chat_record_id}`, {}, loading)
}
const postChatRecordFeedback: (
chat_id: string,
chat_record_id: string,
data: any,
loading?: Ref<boolean>,
) => Promise<Result<any>> = (chat_id, chat_record_id, data, loading) => {
return post(`historical_conversation/${chat_id}/record/${chat_record_id}/feedback`, data, undefined, loading)
}
/**
*
*/
@ -356,6 +365,7 @@ export default {
resetCurrentPassword,
getChatUserProfile,
getChatRecord,
postChatRecordFeedback,
textToSpeech,
speechToText,
deleteChat,

View File

@ -180,6 +180,22 @@ const getChatRecordDetails: (
loading,
)
}
const postChatRecordFeedback: (
application_id: string,
chat_id: string,
chat_record_id: string,
data: any,
loading?: Ref<boolean>,
) => Promise<Result<any>> = (application_id, chat_id, chat_record_id, data, loading) => {
return post(
`${prefix}/${application_id}/chat/${chat_id}/chat_record/${chat_record_id}/feedback`,
data,
undefined,
loading,
)
}
export default {
postChatLogAddKnowledge,
getChatLog,
@ -189,4 +205,5 @@ export default {
delMarkChatRecord,
postExportChatLog,
getChatRecordDetails,
postChatRecordFeedback,
}

View File

@ -57,6 +57,7 @@ interface chatType {
id: string
problem_text: string
answer_text: string
comprehensive_score?: number | string | null
buffer: Array<string>
answer_text_list: Array<
Array<{

View File

@ -54,6 +54,17 @@
</el-card>
</div>
</template>
<div
v-if="formattedComprehensiveScore !== null"
class="content confidence-row"
:style="{
'padding-left': showAvatar ? 'var(--padding-left)' : '0',
'padding-right': showUserAvatar ? 'var(--padding-left)' : '0',
}"
>
<span class="confidence-row__label">{{ $t('chat.comprehensiveScore') }}</span>
<span class="confidence-row__value">{{ formattedComprehensiveScore }}</span>
</div>
<div
class="content"
:style="{
@ -146,6 +157,18 @@ const answer_text_list = computed(() => {
})
})
const formattedComprehensiveScore = computed(() => {
const score = props.chatRecord?.comprehensive_score
if (score === undefined || score === null || score === '') {
return null
}
const numericScore = Number(score)
if (Number.isNaN(numericScore)) {
return null
}
return numericScore.toFixed(2)
})
function showSource(row: any) {
if (props.type === 'log') {
return true
@ -182,4 +205,19 @@ onMounted(() => {
})
})
</script>
<style lang="scss" scoped></style>
<style lang="scss" scoped>
.confidence-row {
margin-bottom: 8px;
font-size: 13px;
color: var(--app-text-color-light);
}
.confidence-row__label {
margin-right: 6px;
}
.confidence-row__value {
color: var(--app-text-color);
font-weight: 500;
}
</style>

View File

@ -93,7 +93,15 @@
<AppIcon iconName="app-oppose-color"></AppIcon>
</el-button>
</el-tooltip>
<el-divider direction="vertical" />
<el-tooltip effect="dark" :content="$t('views.chatLog.feedback.shortTitle')" placement="top">
<el-button text @click="openFeedback(data)" class="feedback-trigger">
<el-icon class="feedback-trigger__icon"><ChatDotRound /></el-icon>
<span class="feedback-trigger__label">{{ $t('views.chatLog.feedback.shortTitle') }}</span>
</el-button>
</el-tooltip>
</span>
<FeedbackDialog ref="FeedbackDialogRef" @refresh="refreshFeedback" />
<div ref="audioCiontainer"></div>
</div>
</div>
@ -101,12 +109,14 @@
<script setup lang="ts">
import { nextTick, onMounted, ref, onBeforeUnmount, type Ref } from 'vue'
import { useRoute } from 'vue-router'
import { ChatDotRound } from '@element-plus/icons-vue'
import { copyClick } from '@/utils/clipboard'
import applicationApi from '@/api/application/application'
import chatAPI from '@/api/chat/chat'
import { datetimeFormat } from '@/utils/time'
import { MsgError } from '@/utils/message'
import bus from '@/bus'
import FeedbackDialog from '@/views/chat-log/component/FeedbackDialog.vue'
const copy = (data: any) => {
try {
const text = data.answer_text_list
@ -143,6 +153,7 @@ const emit = defineEmits(['update:data', 'regeneration'])
const audioPlayer = ref<HTMLAudioElement[] | null>([])
const audioCiontainer = ref<HTMLDivElement>()
const FeedbackDialogRef = ref()
const buttonData = ref(props.data)
const loading = ref(false)
@ -152,6 +163,19 @@ function regeneration() {
emit('regeneration')
}
function openFeedback(data: any) {
FeedbackDialogRef.value.open({
...data,
chat_id: data.chat_id || props.chatId,
application_id: props.applicationId
})
}
function refreshFeedback(data: any) {
buttonData.value = data
emit('update:data', buttonData.value)
}
function voteHandle(val: string) {
chatAPI.vote(props.chatId, props.data.record_id, val, loading).then(() => {
buttonData.value['vote_status'] = val
@ -560,6 +584,36 @@ onBeforeUnmount(() => {
})
</script>
<style lang="scss" scoped>
.feedback-trigger {
display: inline-flex;
align-items: center;
gap: 0;
overflow: hidden;
}
.feedback-trigger__icon {
font-size: 16px;
}
.feedback-trigger__label {
max-width: 0;
opacity: 0;
white-space: nowrap;
overflow: hidden;
transition:
max-width 0.18s ease,
opacity 0.18s ease,
margin-left 0.18s ease;
margin-left: 0;
}
.feedback-trigger:hover .feedback-trigger__label {
max-width: 32px;
opacity: 1;
margin-left: 4px;
}
</style>
<style lang="scss" scoped>
@media only screen and (max-width: 430px) {
.chat-operation-button {
display: block;

View File

@ -49,8 +49,15 @@
<el-button text disabled v-if="buttonData?.vote_status === '1'">
<AppIcon iconName="app-oppose-color"></AppIcon>
</el-button>
<el-divider direction="vertical" />
<el-tooltip effect="dark" :content="$t('views.chatLog.feedback.title')" placement="top">
<el-button text @click="openFeedback(data)">
<AppIcon iconName="app-feedback"></AppIcon>
</el-button>
</el-tooltip>
<EditContentDialog ref="EditContentDialogRef" @refresh="refreshContent" />
<EditMarkDialog ref="EditMarkDialogRef" @refresh="refreshMark" />
<FeedbackDialog ref="FeedbackDialogRef" @refresh="refreshFeedback" />
<!-- 先渲染不然不能播放 -->
<audio ref="audioPlayer" v-for="item in audioList" :key="item" controls hidden="hidden"></audio>
</div>
@ -61,6 +68,7 @@ import { computed, onMounted, ref } from 'vue'
import { copyClick } from '@/utils/clipboard'
import EditContentDialog from '@/views/chat-log/component/EditContentDialog.vue'
import EditMarkDialog from '@/views/chat-log/component/EditMarkDialog.vue'
import FeedbackDialog from '@/views/chat-log/component/FeedbackDialog.vue'
import { datetimeFormat } from '@/utils/time'
import applicationApi from '@/api/application/application'
import { useRoute } from 'vue-router'
@ -103,6 +111,7 @@ const audioPlayer = ref<HTMLAudioElement[] | null>(null)
const EditContentDialogRef = ref()
const EditMarkDialogRef = ref()
const FeedbackDialogRef = ref()
const buttonData = ref(props.data)
const loading = ref(false)
@ -118,6 +127,19 @@ function editMark(data: any) {
EditMarkDialogRef.value.open(data)
}
function openFeedback(data: any) {
FeedbackDialogRef.value.open({
...data,
chat_id: data.chat_id || '',
application_id: props.applicationId,
})
}
function refreshFeedback(data: any) {
buttonData.value = data
emit('update:data', buttonData.value)
}
const audioPlayerStatus = ref(false)
function markdownToPlainText(md: string) {

View File

@ -35,6 +35,7 @@
:applicationId="application.id"
:chatId="chatRecord.chat_id"
:chat_loading="loading"
@update:data="(event: any) => emit('update:chatRecord', event)"
@regeneration="regenerationChart(chatRecord)"
/>
</div>

View File

@ -495,6 +495,43 @@ export const iconMap: any = {
])
},
},
'app-feedback': {
iconReader: () => {
return h('i', [
h(
'svg',
{
style: { height: '100%', width: '100%' },
viewBox: '0 0 1024 1024',
version: '1.1',
xmlns: 'http://www.w3.org/2000/svg',
},
[
h('path', {
d: 'M512 106.666667c-224.853333 0-407.04 182.186667-407.04 407.04s182.186667 407.04 407.04 407.04 407.04-182.186667 407.04-407.04-182.186667-407.04-407.04-407.04z m0 768a360.96 360.96 0 1 1 0-721.92 360.96 360.96 0 0 1 0 721.92z',
fill: 'currentColor',
}),
h('path', {
d: 'M341.333333 469.333333a42.666667 42.666667 0 1 0 0 85.333334 42.666667 42.666667 0 0 0 0-85.333334z',
fill: 'currentColor',
}),
h('path', {
d: 'M512 469.333333a42.666667 42.666667 0 1 0 0 85.333334 42.666667 42.666667 0 0 0 0-85.333334z',
fill: 'currentColor',
}),
h('path', {
d: 'M682.666667 469.333333a42.666667 42.666667 0 1 0 0 85.333334 42.666667 42.666667 0 0 0 0-85.333334z',
fill: 'currentColor',
}),
h('path', {
d: 'M512 597.333333a128 128 0 1 0 0-256 128 128 0 0 0 0 256z m0 42.666667a170.666667 170.666667 0 1 1 0-341.333334 170.666667 170.666667 0 0 1 0 341.333334z',
fill: 'currentColor',
}),
],
),
])
},
},
// 动态加载的图标
...dynamicIcons,
}

View File

@ -120,7 +120,10 @@ function getProvider() {
model
.asyncGetProvider()
.then((res: any) => {
providerOptions.value = res?.data
const excludedProviders = ['OpenAI', 'Azure OpenAI']
providerOptions.value = (res?.data || []).filter(
(provider: Provider) => !excludedProviders.includes(provider.name),
)
loading.value = false
})
.catch(() => {

View File

@ -15,6 +15,7 @@ export default {
chatId: 'Chat ID',
chatUserId: 'Chat User ID',
chatUserType: 'Chat User Type',
comprehensiveScore: 'Confidence:',
userInput: 'User Input',
quote: 'Quote',
download: 'Click to Download',

View File

@ -22,6 +22,13 @@ export default {
toChat: 'Chat',
publish: 'Publish',
},
publishDialog: {
title: 'Select Publish Mode',
internalTitle: 'Internal Publish',
internalDesc: 'Publish the app and automatically turn off the public URL.',
publicTitle: 'Public Publish',
publicDesc: 'Publish the app and automatically turn on the public URL.',
},
delete: {
confirmTitle: 'Are you sure you want to delete this APP: ',
confirmMessage:

View File

@ -18,7 +18,9 @@ export default {
feedback: {
label: 'User Feedback',
star: 'Agree',
trample: 'Disagree'
trample: 'Disagree',
countLabel: 'Feedback Count',
contentLabel: 'Feedback Content'
},
mark: 'Marks',
recenTimes: 'Last Chat Time'
@ -38,5 +40,20 @@ export default {
title: {
placeholder: 'Please set a title for the current content for management and viewing'
}
},
feedback: {
title: 'Chat Feedback',
shortTitle: 'Feedback',
type: 'Feedback Type',
typePlaceholder: 'Please select feedback type',
typeSuggestion: 'Suggestion',
typeQuestion: 'Question',
typeSupplement: 'Supplement',
typeOther: 'Other',
content: 'Feedback Content',
listTitle: 'Feedback Details',
contentPlaceholder: 'Please enter your feedback content',
typeRequired: 'Please select feedback type',
contentRequired: 'Please enter feedback content'
}
}

View File

@ -15,6 +15,7 @@ export default {
chatId: '对话 ID',
chatUserId: '对话用户 ID',
chatUserType: '对话用户类型',
comprehensiveScore: '置信度:',
userInput: '用户输入',
quote: '引用',
download: '点击下载文件',

View File

@ -24,6 +24,13 @@ export default {
toChat: '去对话',
publish: '发布',
},
publishDialog: {
title: '选择发布方式',
internalTitle: '内部发布',
internalDesc: '发布后自动关闭公开访问链接,仅保留内部使用。',
publicTitle: '公开发布',
publicDesc: '发布后自动打开公开访问链接,允许外部访问。',
},
delete: {
confirmTitle: '是否删除应用:',
confirmMessage: '删除后该应用将不再提供服务,请谨慎操作。',

View File

@ -18,7 +18,9 @@ export default {
feedback: {
label: '用户反馈',
star: '赞同',
trample: '反对'
trample: '反对',
countLabel: '反馈条数',
contentLabel: '反馈内容'
},
mark: '改进标注',
recenTimes: '最近对话时间'
@ -38,5 +40,20 @@ export default {
title: {
placeholder: '请给当前内容设置一个标题,以便管理查看'
}
},
feedback: {
title: '对话反馈',
shortTitle: '反馈',
type: '反馈类型',
typePlaceholder: '请选择反馈类型',
typeSuggestion: '建议',
typeQuestion: '疑问',
typeSupplement: '补充',
typeOther: '其他',
content: '反馈内容',
listTitle: '反馈详情',
contentPlaceholder: '请输入您的反馈内容',
typeRequired: '请选择反馈类型',
contentRequired: '请输入反馈内容'
}
}

View File

@ -15,6 +15,7 @@ export default {
chatId: '對話 ID',
chatUserId: '對話用戶 ID',
chatUserType: '對話用戶類型',
comprehensiveScore: '置信度:',
userInput: '用戶輸入',
quote: '引用',
download: '點擊下載文件',

View File

@ -21,6 +21,13 @@ export default {
publish: '發布',
addModel: '新增模型',
},
publishDialog: {
title: '選擇發布方式',
internalTitle: '內部發布',
internalDesc: '發布後自動關閉公開訪問連結,僅保留內部使用。',
publicTitle: '公開發布',
publicDesc: '發布後自動開啟公開訪問連結,允許外部訪問。',
},
delete: {
confirmTitle: '是否刪除應用:',
confirmMessage: '刪除後該應用將不再提供服務,請謹慎操作。',

View File

@ -18,7 +18,9 @@ export default {
feedback: {
label: '用戶反饋',
star: '贊同',
trample: '反對'
trample: '反對',
countLabel: '反饋條數',
contentLabel: '反饋內容'
},
mark: '改進標註',
recenTimes: '最近對話時間'
@ -38,5 +40,20 @@ export default {
title: {
placeholder: '請給當前內容設定一個標題,以便管理查看'
}
},
feedback: {
title: '對話反饋',
shortTitle: '反饋',
type: '反饋類型',
typePlaceholder: '請選擇反饋類型',
typeSuggestion: '建議',
typeQuestion: '疑問',
typeSupplement: '補充',
typeOther: '其他',
content: '反饋內容',
listTitle: '反饋詳情',
contentPlaceholder: '請輸入您的反饋內容',
typeRequired: '請選擇反饋類型',
contentRequired: '請輸入反饋內容'
}
}

View File

@ -2,7 +2,7 @@ import { defineStore } from 'pinia'
import { type Ref } from 'vue'
const useApplicationStore = defineStore('application', {
state: () => ({
location: `${window.location.origin}${window.MaxKB.chatPrefix ? window.MaxKB.chatPrefix : window.MaxKB.prefix}/`,
location: `${window.location.origin}${window.MaxKB.chatPrefix ? window.MaxKB.chatPrefix : '/chat'}/`,
}),
actions: {},
})

View File

@ -15,7 +15,7 @@
</el-button>
<el-button
type="primary"
@click="publish(applicationFormRef)"
@click="openPublishDialog(applicationFormRef)"
:disabled="loading"
v-if="permissionPrecise.edit(id)"
>
@ -633,6 +633,46 @@
/>
<McpServersDialog ref="mcpServersDialogRef" @refresh="submitMcpServersDialog" />
<ToolDialog ref="toolDialogRef" @refresh="submitToolDialog" />
<el-dialog
v-model="publishDialogVisible"
:title="$t('views.application.publishDialog.title')"
width="520"
:close-on-click-modal="false"
:close-on-press-escape="false"
>
<div class="publish-mode-list">
<div
class="publish-mode-card"
:class="{ active: publishMode === 'internal' }"
@click="publishMode = 'internal'"
>
<div class="publish-mode-card__title">
{{ $t('views.application.publishDialog.internalTitle') }}
</div>
<div class="publish-mode-card__desc">
{{ $t('views.application.publishDialog.internalDesc') }}
</div>
</div>
<div
class="publish-mode-card"
:class="{ active: publishMode === 'public' }"
@click="publishMode = 'public'"
>
<div class="publish-mode-card__title">
{{ $t('views.application.publishDialog.publicTitle') }}
</div>
<div class="publish-mode-card__desc">
{{ $t('views.application.publishDialog.publicDesc') }}
</div>
</div>
</div>
<template #footer>
<el-button @click="publishDialogVisible = false">{{ $t('common.cancel') }}</el-button>
<el-button type="primary" :loading="loading" @click="confirmPublish(applicationFormRef)">
{{ $t('common.confirm') }}
</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
@ -703,6 +743,8 @@ const AddKnowledgeDialogRef = ref()
const loading = ref(false)
const knowledgeLoading = ref(false)
const publishDialogVisible = ref(false)
const publishMode = ref<'internal' | 'public'>('public')
const applicationForm = ref<ApplicationFormType>({
name: '',
@ -791,10 +833,32 @@ const publish = (formEl: FormInstance | undefined) => {
)
})
.then(() => {
return loadSharedApi({ type: 'application', systemType: apiType.value }).putAccessToken(
id,
{
is_active: publishMode.value === 'public',
},
loading,
)
})
.then(() => {
publishDialogVisible.value = false
MsgSuccess(t('views.application.tip.publishSuccess'))
})
})
}
const openPublishDialog = (formEl: FormInstance | undefined) => {
if (!formEl) return
formEl.validate().then(() => {
publishMode.value = 'public'
publishDialogVisible.value = true
})
}
const confirmPublish = (formEl: FormInstance | undefined) => {
publish(formEl)
}
const submit = async (formEl: FormInstance | undefined) => {
if (!formEl) return
await formEl.validate((valid, fields) => {
@ -1127,6 +1191,39 @@ onMounted(() => {
color: var(--app-text-color);
}
.publish-mode-list {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16px;
}
.publish-mode-card {
border: 1px solid var(--el-border-color);
border-radius: 12px;
padding: 16px;
cursor: pointer;
transition: border-color 0.2s ease, background-color 0.2s ease, box-shadow 0.2s ease;
}
.publish-mode-card.active {
border-color: var(--el-color-primary);
background: var(--el-color-primary-light-9);
box-shadow: 0 0 0 1px var(--el-color-primary-light-5);
}
.publish-mode-card__title {
font-size: 15px;
font-weight: 600;
color: var(--app-text-color);
margin-bottom: 8px;
}
.publish-mode-card__desc {
font-size: 13px;
line-height: 1.6;
color: var(--app-text-color-light);
}
.dialog-bg {
border-radius: 8px;
background: var(--dialog-bg-gradient-color);

View File

@ -572,7 +572,19 @@ function toChat(row: any) {
aips = aips ? aips : []
const apiParams = mapToUrlParams(aips) ? '?' + mapToUrlParams(aips) : ''
ApplicationApi.getAccessToken(row.id, loading).then((res: any) => {
window.open(application.location + res?.data?.access_token + apiParams)
if (res?.data?.is_active) {
window.open(application.location + res?.data?.access_token + apiParams)
return
}
const debugPreviewUrl = router.resolve({
name: 'AppSetting',
params: {
from: 'workspace',
id: row.id,
type: row.type,
},
}).href
window.open(debugPreviewUrl)
})
})
}

View File

@ -0,0 +1,139 @@
<template>
<el-dialog
:title="$t('views.chatLog.feedback.title')"
v-model="dialogVisible"
width="600"
:close-on-click-modal="false"
:close-on-press-escape="false"
>
<el-form
ref="formRef"
:model="form"
label-position="top"
require-asterisk-position="right"
:rules="rules"
@submit.prevent
>
<el-form-item :label="$t('views.chatLog.feedback.type')" prop="feedback_type">
<el-select v-model="form.feedback_type" :placeholder="$t('views.chatLog.feedback.typePlaceholder')">
<el-option :label="$t('views.chatLog.feedback.typeSuggestion')" value="SUGGESTION"></el-option>
<el-option :label="$t('views.chatLog.feedback.typeQuestion')" value="QUESTION"></el-option>
<el-option :label="$t('views.chatLog.feedback.typeSupplement')" value="SUPPLEMENT"></el-option>
<el-option :label="$t('views.chatLog.feedback.typeOther')" value="OTHER"></el-option>
</el-select>
</el-form-item>
<el-form-item :label="$t('views.chatLog.feedback.content')" prop="content">
<el-input
v-model="form.content"
type="textarea"
:placeholder="$t('views.chatLog.feedback.contentPlaceholder')"
:rows="6"
maxlength="2000"
show-word-limit
></el-input>
</el-form-item>
</el-form>
<template #footer>
<span class="dialog-footer">
<el-button @click.prevent="dialogVisible = false"> {{ $t('common.cancel') }} </el-button>
<el-button type="primary" native-type="button" @click.prevent="submitForm(formRef)" :loading="loading">
{{ $t('common.save') }}
</el-button>
</span>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { ref, watch, reactive, computed } from 'vue'
import { useRoute } from 'vue-router'
import type { FormInstance, FormRules } from 'element-plus'
import { t } from '@/locales'
import { loadSharedApi } from '@/utils/dynamics-api/shared-api'
import chatAPI from '@/api/chat/chat'
const route = useRoute()
const isChatRoute = computed(() => {
return route.name === 'chat' || typeof route.params?.accessToken === 'string'
})
const apiType = computed(() => {
if (route.path.includes('resource-management')) {
return 'systemManage'
} else {
return 'workspace'
}
})
const emit = defineEmits(['refresh'])
const formRef = ref<FormInstance>()
const dialogVisible = ref<boolean>(false)
const loading = ref(false)
const form = ref<any>({
chat_id: '',
record_id: '',
application_id: '',
feedback_type: '',
content: '',
})
const rules = reactive<FormRules>({
feedback_type: [
{ required: true, message: t('views.chatLog.feedback.typeRequired'), trigger: 'blur' },
],
content: [
{ required: true, message: t('views.chatLog.feedback.contentRequired'), trigger: 'blur' },
],
})
watch(dialogVisible, (bool) => {
if (!bool) {
form.value = {
chat_id: '',
record_id: '',
application_id: '',
feedback_type: '',
content: '',
}
formRef.value?.clearValidate()
}
})
const open = (data: any) => {
form.value.chat_id = data.chat_id
form.value.record_id = data.record_id || data.id
form.value.application_id = data.application_id
formRef.value?.clearValidate()
dialogVisible.value = true
}
const submitForm = async (formEl: FormInstance | undefined) => {
if (!formEl) return
await formEl.validate(async (valid) => {
if (valid) {
const obj = {
feedback_type: form.value.feedback_type,
content: form.value.content,
}
const request = isChatRoute.value
? chatAPI.postChatRecordFeedback(form.value.chat_id, form.value.record_id, obj, loading)
: loadSharedApi({ type: 'chatLog', systemType: apiType.value }).postChatRecordFeedback(
form.value.application_id,
form.value.chat_id,
form.value.record_id,
obj,
loading,
)
request
.then((res: any) => {
emit('refresh', res.data)
dialogVisible.value = false
})
.catch(() => undefined)
}
})
}
defineExpose({ open })
</script>
<style lang="scss" scoped></style>

View File

@ -163,6 +163,14 @@
</template>
</el-table-column>
<el-table-column prop="mark_sum" :label="$t('views.chatLog.table.mark')" align="right" />
<el-table-column prop="feedback_count" :label="$t('views.chatLog.table.feedback.countLabel')" align="center">
<template #default="{ row }">
<el-button v-if="row.feedback_count" link type="primary" @click.stop="openFeedbackList(row)">
{{ row.feedback_count }}
</el-button>
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column prop="asker" :label="$t('views.chatLog.table.user')">
<template #default="{ row }">
{{ row.asker?.username }}
@ -238,6 +246,23 @@
</span>
</template>
</el-dialog>
<el-dialog
:title="$t('views.chatLog.feedback.listTitle')"
v-model="feedbackListDialogVisible"
width="720px"
:close-on-click-modal="false"
>
<div v-if="currentFeedbackList.length" class="feedback-list">
<div v-for="(item, index) in currentFeedbackList" :key="index" class="feedback-list__item">
<div class="feedback-list__meta">
<span>{{ feedbackTypeLabelMap[item.feedback_type] || item.feedback_type }}</span>
<span>{{ datetimeFormat(item.create_time) }}</span>
</div>
<div class="feedback-list__content">{{ item.content }}</div>
</div>
</div>
<div v-else>-</div>
</el-dialog>
</div>
</template>
<script setup lang="ts">
@ -321,6 +346,7 @@ const multipleSelection = ref<any[]>([])
const ChatRecordRef = ref()
const loading = ref(false)
const documentLoading = ref(false)
const feedbackListDialogVisible = ref(false)
const paginationConfig = reactive({
current_page: 1,
page_size: 20,
@ -330,6 +356,13 @@ const dialogVisible = ref(false)
const documentDialogVisible = ref(false)
const days = ref<number>(180)
const tableData = ref<any[]>([])
const currentFeedbackList = ref<any[]>([])
const feedbackTypeLabelMap = {
SUGGESTION: t('views.chatLog.feedback.typeSuggestion'),
QUESTION: t('views.chatLog.feedback.typeQuestion'),
SUPPLEMENT: t('views.chatLog.feedback.typeSupplement'),
OTHER: t('views.chatLog.feedback.typeOther'),
} as Record<string, string>
const tableIndexMap = computed<Dict<number>>(() => {
return tableData.value
.map((row, index) => ({
@ -570,6 +603,11 @@ function openDocumentDialog() {
documentDialogVisible.value = true
}
function openFeedbackList(row: any) {
currentFeedbackList.value = row.feedback_list || []
feedbackListDialogVisible.value = true
}
onMounted(() => {
changeDayHandle(history_day.value)
getDetail()
@ -581,4 +619,32 @@ onMounted(() => {
cursor: pointer;
}
}
.feedback-list {
max-height: 480px;
overflow-y: auto;
}
.feedback-list__item {
padding: 12px 0;
border-bottom: 1px solid var(--el-border-color-lighter);
}
.feedback-list__item:last-child {
border-bottom: none;
}
.feedback-list__meta {
display: flex;
justify-content: space-between;
gap: 12px;
margin-bottom: 8px;
color: var(--el-text-color-secondary);
font-size: 13px;
}
.feedback-list__content {
white-space: pre-wrap;
line-height: 1.6;
}
</style>

View File

@ -82,6 +82,8 @@ const checkModelType = (model_type: string) => {
const excludedProviders = [
'Anthropic',
'Amazon Bedrock',
'OpenAI',
'Azure OpenAI',
'Gemini',
'SILICONFLOW',
'Xorbits Inference',

View File

@ -223,6 +223,8 @@ onMounted(() => {
const excludedProviders = [
'Anthropic',
'Amazon Bedrock',
'OpenAI',
'Azure OpenAI',
'Gemini',
'SILICONFLOW',
'Xorbits Inference',

View File

@ -120,7 +120,17 @@
</el-dropdown>
</el-card>
</div>
<h2 class="mb-16">{{ data.title || '-' }}</h2>
<div class="paragraph-box__header mb-16">
<h2 class="paragraph-box__title">{{ data.title || '-' }}</h2>
<div class="paragraph-box__meta" v-if="data.create_time || data.update_time">
<span v-if="data.create_time">
{{ $t('common.createTime') }}{{ datetimeFormat(data.create_time) }}
</span>
<span v-if="data.update_time">
{{ $t('common.updateTime') }}{{ datetimeFormat(data.update_time) }}
</span>
</div>
</div>
<MdPreview
ref="editorRef"
editorId="preview-only"
@ -154,6 +164,7 @@ import { MsgSuccess, MsgConfirm } from '@/utils/message'
import { loadSharedApi } from '@/utils/dynamics-api/shared-api'
import permissionMap from '@/permission'
import { t } from '@/locales'
import { datetimeFormat } from '@/utils/time'
const props = defineProps<{
data: any
disabled?: boolean
@ -337,6 +348,25 @@ watch(dialogVisible, (val: boolean) => {
overflow: inherit;
z-index: 10;
}
.paragraph-box__header {
display: flex;
flex-direction: column;
gap: 6px;
}
.paragraph-box__title {
margin: 0;
line-height: 1.4;
}
.paragraph-box__meta {
display: flex;
flex-wrap: wrap;
gap: 8px 16px;
font-size: 12px;
color: var(--app-text-color-light);
}
}
</style>

View File

@ -385,7 +385,19 @@ function toChat(row: any) {
? '?' + mapToUrlParams(apiInputParams.value)
: ''
ApplicationResourceApi.getAccessToken(row.id, loading).then((res: any) => {
window.open(application.location + res?.data?.access_token + apiParams)
if (res?.data?.is_active) {
window.open(application.location + res?.data?.access_token + apiParams)
return
}
const debugPreviewUrl = router.resolve({
name: 'AppSetting',
params: {
from: 'resource-management',
id: row.id,
type: row.type,
},
}).href
window.open(debugPreviewUrl)
})
}