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")) star_num = serializers.IntegerField(required=True, label=_("Number of Likes"))
trample_num = serializers.IntegerField(required=True, label=_("Number of thumbs-downs")) trample_num = serializers.IntegerField(required=True, label=_("Number of thumbs-downs"))
mark_sum = serializers.IntegerField(required=True, label=_("Number of tags")) 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): class ApplicationChatRecordExportRequest(serializers.Serializer):
@ -136,11 +140,45 @@ class ApplicationChatQuerySerializers(serializers.Serializer):
def list(self, with_valid=True): def list(self, with_valid=True):
if with_valid: if with_valid:
self.is_valid(raise_exception=True) 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', os.path.join(PROJECT_DIR, "apps", "application", 'sql',
('list_application_chat_ee.sql' if ['PE', 'EE'].__contains__( ('list_application_chat_ee.sql' if ['PE', 'EE'].__contains__(
edition) else 'list_application_chat.sql'))), edition) else 'list_application_chat.sql'))),
with_table_name=False) 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 @staticmethod
def paragraph_list_to_string(paragraph_list): 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): def page(self, current_page: int, page_size: int, with_valid=True):
if with_valid: if with_valid:
self.is_valid(raise_exception=True) 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', os.path.join(PROJECT_DIR, "apps", "application", 'sql',
('list_application_chat_ee.sql' if ['PE', 'EE'].__contains__( ('list_application_chat_ee.sql' if ['PE', 'EE'].__contains__(
edition) else 'list_application_chat.sql'))), edition) else 'list_application_chat.sql'))),
with_table_name=False) with_table_name=False)
page['records'] = self.append_feedback_content(page.get('records'))
return page
class ChatCountSerializer(serializers.Serializer): class ChatCountSerializer(serializers.Serializer):

View File

@ -10,6 +10,7 @@ from functools import reduce
from typing import Dict from typing import Dict
import uuid_utils.compat as uuid import uuid_utils.compat as uuid
from django.utils import timezone
from django.db import transaction from django.db import transaction
from django.db.models import QuerySet from django.db.models import QuerySet
from django.db.models.aggregates import Max, Min 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")) workspace_id = serializers.CharField(required=False, allow_null=True, allow_blank=True, label=_("Workspace ID"))
application_id = serializers.UUIDField(required=True, label=_("Application ID")) application_id = serializers.UUIDField(required=True, label=_("Application ID"))
chat_record_id = serializers.UUIDField(required=True, label=_("Conversation record 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): def is_valid(self, *, debug=False, raise_exception=False):
super().is_valid(raise_exception=True) super().is_valid(raise_exception=True)
@ -85,6 +88,49 @@ class ChatRecordOperateSerializer(serializers.Serializer):
return ApplicationChatRecordQuerySerializers.reset_chat_record( return ApplicationChatRecordQuerySerializers.reset_chat_record(
chat_record, True if debug else show_source, True if debug else show_exec) 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): class ApplicationChatRecordQuerySerializers(serializers.Serializer):
workspace_id = serializers.CharField(required=False, allow_null=True, allow_blank=True, label=_("Workspace ID")) 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): def reset_chat_record(chat_record, show_source, show_exec):
knowledge_list = [] knowledge_list = []
paragraph_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') is not None:
paragraph_list = chat_record.details.get('search_step').get( paragraph_list = chat_record.details.get('search_step').get(
'paragraph_list') 'paragraph_list')
for item in chat_record.details.values(): 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): if item.get('type') == 'search-knowledge-node' and item.get('show_knowledge', False):
paragraph_list = paragraph_list + (item.get( paragraph_list = paragraph_list + (item.get(
'paragraph_list') or []) 'paragraph_list') or [])
@ -151,6 +199,7 @@ class ApplicationChatRecordQuerySerializers(serializers.Serializer):
show_source_dict = {'knowledge_list': knowledge_list, show_source_dict = {'knowledge_list': knowledge_list,
'paragraph_list': paragraph_list, } 'paragraph_list': paragraph_list, }
show_exec_dict = {'execution_details': [chat_record.details[key] for key in chat_record.details if 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( (True if show_exec else chat_record.details[key].get(
'type') == 'start-node')]} 'type') == 'start-node')]}
return { 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/<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', 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>', 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/<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>/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()), 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_id': chat_id,
'chat_record_id': chat_record_id}).one(True)) '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): class ApplicationChatRecordAddKnowledge(APIView):
authentication_classes = [TokenAuth] authentication_classes = [TokenAuth]

View File

@ -19,6 +19,7 @@ urlpatterns = [
name='application/chat_completions'), name='application/chat_completions'),
path('vote/chat/<str:chat_id>/chat_record/<str:chat_record_id>', views.VoteView.as_view(), name='vote'), 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', 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/<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/<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'), 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, 'application_id': request.auth.application_id,
'chat_user_id': request.auth.chat_user_id, 'chat_user_id': request.auth.chat_user_id,
}).one(False)) }).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', prefix: '/admin',
chatPrefix: '/chat', 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, 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 { export default {
postChatLogAddKnowledge, postChatLogAddKnowledge,
getChatLog, getChatLog,
@ -207,4 +231,5 @@ export default {
delMarkChatRecord, delMarkChatRecord,
postExportChatLog, postExportChatLog,
getChatRecordDetails, getChatRecordDetails,
postChatRecordFeedback,
} }

View File

@ -250,6 +250,15 @@ const getChatRecord: (
) => Promise<Result<any>> = (chat_id, chat_record_id, loading) => { ) => Promise<Result<any>> = (chat_id, chat_record_id, loading) => {
return get(`historical_conversation/${chat_id}/record/${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, resetCurrentPassword,
getChatUserProfile, getChatUserProfile,
getChatRecord, getChatRecord,
postChatRecordFeedback,
textToSpeech, textToSpeech,
speechToText, speechToText,
deleteChat, deleteChat,

View File

@ -180,6 +180,22 @@ const getChatRecordDetails: (
loading, 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 { export default {
postChatLogAddKnowledge, postChatLogAddKnowledge,
getChatLog, getChatLog,
@ -189,4 +205,5 @@ export default {
delMarkChatRecord, delMarkChatRecord,
postExportChatLog, postExportChatLog,
getChatRecordDetails, getChatRecordDetails,
postChatRecordFeedback,
} }

View File

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

View File

@ -54,6 +54,17 @@
</el-card> </el-card>
</div> </div>
</template> </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 <div
class="content" class="content"
:style="{ :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) { function showSource(row: any) {
if (props.type === 'log') { if (props.type === 'log') {
return true return true
@ -182,4 +205,19 @@ onMounted(() => {
}) })
}) })
</script> </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> <AppIcon iconName="app-oppose-color"></AppIcon>
</el-button> </el-button>
</el-tooltip> </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> </span>
<FeedbackDialog ref="FeedbackDialogRef" @refresh="refreshFeedback" />
<div ref="audioCiontainer"></div> <div ref="audioCiontainer"></div>
</div> </div>
</div> </div>
@ -101,12 +109,14 @@
<script setup lang="ts"> <script setup lang="ts">
import { nextTick, onMounted, ref, onBeforeUnmount, type Ref } from 'vue' import { nextTick, onMounted, ref, onBeforeUnmount, type Ref } from 'vue'
import { useRoute } from 'vue-router' import { useRoute } from 'vue-router'
import { ChatDotRound } from '@element-plus/icons-vue'
import { copyClick } from '@/utils/clipboard' import { copyClick } from '@/utils/clipboard'
import applicationApi from '@/api/application/application' import applicationApi from '@/api/application/application'
import chatAPI from '@/api/chat/chat' import chatAPI from '@/api/chat/chat'
import { datetimeFormat } from '@/utils/time' import { datetimeFormat } from '@/utils/time'
import { MsgError } from '@/utils/message' import { MsgError } from '@/utils/message'
import bus from '@/bus' import bus from '@/bus'
import FeedbackDialog from '@/views/chat-log/component/FeedbackDialog.vue'
const copy = (data: any) => { const copy = (data: any) => {
try { try {
const text = data.answer_text_list const text = data.answer_text_list
@ -143,6 +153,7 @@ const emit = defineEmits(['update:data', 'regeneration'])
const audioPlayer = ref<HTMLAudioElement[] | null>([]) const audioPlayer = ref<HTMLAudioElement[] | null>([])
const audioCiontainer = ref<HTMLDivElement>() const audioCiontainer = ref<HTMLDivElement>()
const FeedbackDialogRef = ref()
const buttonData = ref(props.data) const buttonData = ref(props.data)
const loading = ref(false) const loading = ref(false)
@ -152,6 +163,19 @@ function regeneration() {
emit('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) { function voteHandle(val: string) {
chatAPI.vote(props.chatId, props.data.record_id, val, loading).then(() => { chatAPI.vote(props.chatId, props.data.record_id, val, loading).then(() => {
buttonData.value['vote_status'] = val buttonData.value['vote_status'] = val
@ -560,6 +584,36 @@ onBeforeUnmount(() => {
}) })
</script> </script>
<style lang="scss" scoped> <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) { @media only screen and (max-width: 430px) {
.chat-operation-button { .chat-operation-button {
display: block; display: block;

View File

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

View File

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

View File

@ -120,7 +120,10 @@ function getProvider() {
model model
.asyncGetProvider() .asyncGetProvider()
.then((res: any) => { .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 loading.value = false
}) })
.catch(() => { .catch(() => {

View File

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

View File

@ -22,6 +22,13 @@ export default {
toChat: 'Chat', toChat: 'Chat',
publish: 'Publish', 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: { delete: {
confirmTitle: 'Are you sure you want to delete this APP: ', confirmTitle: 'Are you sure you want to delete this APP: ',
confirmMessage: confirmMessage:

View File

@ -18,7 +18,9 @@ export default {
feedback: { feedback: {
label: 'User Feedback', label: 'User Feedback',
star: 'Agree', star: 'Agree',
trample: 'Disagree' trample: 'Disagree',
countLabel: 'Feedback Count',
contentLabel: 'Feedback Content'
}, },
mark: 'Marks', mark: 'Marks',
recenTimes: 'Last Chat Time' recenTimes: 'Last Chat Time'
@ -38,5 +40,20 @@ export default {
title: { title: {
placeholder: 'Please set a title for the current content for management and viewing' 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', chatId: '对话 ID',
chatUserId: '对话用户 ID', chatUserId: '对话用户 ID',
chatUserType: '对话用户类型', chatUserType: '对话用户类型',
comprehensiveScore: '置信度:',
userInput: '用户输入', userInput: '用户输入',
quote: '引用', quote: '引用',
download: '点击下载文件', download: '点击下载文件',

View File

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

View File

@ -18,7 +18,9 @@ export default {
feedback: { feedback: {
label: '用户反馈', label: '用户反馈',
star: '赞同', star: '赞同',
trample: '反对' trample: '反对',
countLabel: '反馈条数',
contentLabel: '反馈内容'
}, },
mark: '改进标注', mark: '改进标注',
recenTimes: '最近对话时间' recenTimes: '最近对话时间'
@ -38,5 +40,20 @@ export default {
title: { title: {
placeholder: '请给当前内容设置一个标题,以便管理查看' 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', chatId: '對話 ID',
chatUserId: '對話用戶 ID', chatUserId: '對話用戶 ID',
chatUserType: '對話用戶類型', chatUserType: '對話用戶類型',
comprehensiveScore: '置信度:',
userInput: '用戶輸入', userInput: '用戶輸入',
quote: '引用', quote: '引用',
download: '點擊下載文件', download: '點擊下載文件',

View File

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

View File

@ -18,7 +18,9 @@ export default {
feedback: { feedback: {
label: '用戶反饋', label: '用戶反饋',
star: '贊同', star: '贊同',
trample: '反對' trample: '反對',
countLabel: '反饋條數',
contentLabel: '反饋內容'
}, },
mark: '改進標註', mark: '改進標註',
recenTimes: '最近對話時間' recenTimes: '最近對話時間'
@ -38,5 +40,20 @@ export default {
title: { title: {
placeholder: '請給當前內容設定一個標題,以便管理查看' 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' import { type Ref } from 'vue'
const useApplicationStore = defineStore('application', { const useApplicationStore = defineStore('application', {
state: () => ({ 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: {}, actions: {},
}) })

View File

@ -15,7 +15,7 @@
</el-button> </el-button>
<el-button <el-button
type="primary" type="primary"
@click="publish(applicationFormRef)" @click="openPublishDialog(applicationFormRef)"
:disabled="loading" :disabled="loading"
v-if="permissionPrecise.edit(id)" v-if="permissionPrecise.edit(id)"
> >
@ -633,6 +633,46 @@
/> />
<McpServersDialog ref="mcpServersDialogRef" @refresh="submitMcpServersDialog" /> <McpServersDialog ref="mcpServersDialogRef" @refresh="submitMcpServersDialog" />
<ToolDialog ref="toolDialogRef" @refresh="submitToolDialog" /> <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> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
@ -703,6 +743,8 @@ const AddKnowledgeDialogRef = ref()
const loading = ref(false) const loading = ref(false)
const knowledgeLoading = ref(false) const knowledgeLoading = ref(false)
const publishDialogVisible = ref(false)
const publishMode = ref<'internal' | 'public'>('public')
const applicationForm = ref<ApplicationFormType>({ const applicationForm = ref<ApplicationFormType>({
name: '', name: '',
@ -791,10 +833,32 @@ const publish = (formEl: FormInstance | undefined) => {
) )
}) })
.then(() => { .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')) 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) => { const submit = async (formEl: FormInstance | undefined) => {
if (!formEl) return if (!formEl) return
await formEl.validate((valid, fields) => { await formEl.validate((valid, fields) => {
@ -1127,6 +1191,39 @@ onMounted(() => {
color: var(--app-text-color); 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 { .dialog-bg {
border-radius: 8px; border-radius: 8px;
background: var(--dialog-bg-gradient-color); background: var(--dialog-bg-gradient-color);

View File

@ -572,7 +572,19 @@ function toChat(row: any) {
aips = aips ? aips : [] aips = aips ? aips : []
const apiParams = mapToUrlParams(aips) ? '?' + mapToUrlParams(aips) : '' const apiParams = mapToUrlParams(aips) ? '?' + mapToUrlParams(aips) : ''
ApplicationApi.getAccessToken(row.id, loading).then((res: any) => { 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> </template>
</el-table-column> </el-table-column>
<el-table-column prop="mark_sum" :label="$t('views.chatLog.table.mark')" align="right" /> <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')"> <el-table-column prop="asker" :label="$t('views.chatLog.table.user')">
<template #default="{ row }"> <template #default="{ row }">
{{ row.asker?.username }} {{ row.asker?.username }}
@ -238,6 +246,23 @@
</span> </span>
</template> </template>
</el-dialog> </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> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
@ -321,6 +346,7 @@ const multipleSelection = ref<any[]>([])
const ChatRecordRef = ref() const ChatRecordRef = ref()
const loading = ref(false) const loading = ref(false)
const documentLoading = ref(false) const documentLoading = ref(false)
const feedbackListDialogVisible = ref(false)
const paginationConfig = reactive({ const paginationConfig = reactive({
current_page: 1, current_page: 1,
page_size: 20, page_size: 20,
@ -330,6 +356,13 @@ const dialogVisible = ref(false)
const documentDialogVisible = ref(false) const documentDialogVisible = ref(false)
const days = ref<number>(180) const days = ref<number>(180)
const tableData = ref<any[]>([]) 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>>(() => { const tableIndexMap = computed<Dict<number>>(() => {
return tableData.value return tableData.value
.map((row, index) => ({ .map((row, index) => ({
@ -570,6 +603,11 @@ function openDocumentDialog() {
documentDialogVisible.value = true documentDialogVisible.value = true
} }
function openFeedbackList(row: any) {
currentFeedbackList.value = row.feedback_list || []
feedbackListDialogVisible.value = true
}
onMounted(() => { onMounted(() => {
changeDayHandle(history_day.value) changeDayHandle(history_day.value)
getDetail() getDetail()
@ -581,4 +619,32 @@ onMounted(() => {
cursor: pointer; 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> </style>

View File

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

View File

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

View File

@ -120,7 +120,17 @@
</el-dropdown> </el-dropdown>
</el-card> </el-card>
</div> </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 <MdPreview
ref="editorRef" ref="editorRef"
editorId="preview-only" editorId="preview-only"
@ -154,6 +164,7 @@ import { MsgSuccess, MsgConfirm } from '@/utils/message'
import { loadSharedApi } from '@/utils/dynamics-api/shared-api' import { loadSharedApi } from '@/utils/dynamics-api/shared-api'
import permissionMap from '@/permission' import permissionMap from '@/permission'
import { t } from '@/locales' import { t } from '@/locales'
import { datetimeFormat } from '@/utils/time'
const props = defineProps<{ const props = defineProps<{
data: any data: any
disabled?: boolean disabled?: boolean
@ -337,6 +348,25 @@ watch(dialogVisible, (val: boolean) => {
overflow: inherit; overflow: inherit;
z-index: 10; 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> </style>

View File

@ -385,7 +385,19 @@ function toChat(row: any) {
? '?' + mapToUrlParams(apiInputParams.value) ? '?' + mapToUrlParams(apiInputParams.value)
: '' : ''
ApplicationResourceApi.getAccessToken(row.id, loading).then((res: any) => { 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)
}) })
} }