Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions ui/src/api/API_README.md
Original file line number Diff line number Diff line change
Expand Up @@ -806,3 +806,12 @@ Workspace 和 System 资源管理的 `getKnowledgeDetail(knowledgeId, section?)`
`abstract` 和按时间正序排列的 `chat_record_list`。当前后端返回旧版 `problem_text`、
`answer_text`、`answer_text_list` 及仅含上传附件的 `execution_details`,不返回 V3 `question`、`messages`。
分享页适配旧版字段,不通过原会话接口补查内容。

### 对话回答反馈

`chat/conversation.ts` 的 `putConversationFeedback(applicationId, chatId, recordId, data)` 使用
PUT `/v3/application/<applicationId>/chat/<chatId>/chat_record/<recordId>/vote`。
提交类型 `ChatFeedback` 经 `@/api/types` 导出;`vote_status` 为 `-1`(撤销)、`0`(满意)、
`1`(不满意)字符串,`vote_reason` 为单选原因,`vote_other_content` 为补充说明。
满意原因支持 `accurate`、`complete`、`other`,不满意支持 `inaccurate`、`incomplete`、`other`。
已评价记录须先撤销才能再次评价;历史记录通过同名字段回显。
7 changes: 6 additions & 1 deletion ui/src/api/chat/conversation.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { get, post, put, del, postStream } from './core/request'
import type { ResponsePage } from './core/types'
import { CHAT_API_BASE_PATH as chatApiBase } from '@/api/constants'
import type { ChatLogRecord, ChatShareDetail, PortalConversationSummary } from '@/api/types'
import type { ChatFeedback, ChatLogRecord, ChatShareDetail, PortalConversationSummary } from '@/api/types'

/** v3 对话接口均按智能体划分资源路径。 */
const getApplicationPath = (applicationId: string) => `/v3/application/${applicationId}`
Expand Down Expand Up @@ -46,13 +46,18 @@ const putConversation = (applicationId: string, chatId: string, data: unknown) =
const postShareConversation = (applicationId: string, chatId: string, data: { chat_record_ids: string[] }) =>
post<{ chat_record_ids: string[] }, { link: string }>(`${getApplicationPath(applicationId)}/chat/${chatId}/share_chat`, data)

/** 保存或撤销当前回答的反馈。 */
const putConversationFeedback = (applicationId: string, chatId: string, recordId: string, data: ChatFeedback) =>
put(`${getApplicationPath(applicationId)}/chat/${chatId}/chat_record/${recordId}/vote`, data)

/** 获取公开分享的对话记录,无需智能体访问凭证。 */
const getShareConversation = (shareId: string) => get<ChatShareDetail>(`/share/${encodeURIComponent(shareId)}`)

/** 将语音转换为文字。 */
const postSpeechToText = (applicationId: string, data: unknown) => post(`${getApplicationPath(applicationId)}/speech_to_text`, data)

export default {
putConversationFeedback,
getConversationOpen,
postConversationMessage,
postCancelConversationMessage,
Expand Down
9 changes: 8 additions & 1 deletion ui/src/api/types/chat-log.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ export interface ChatContent {
}

/** 对话日志中的单条问答记录,仅列出前端使用的字段。 */
export interface ChatLogRecord {
export interface ChatLogRecord extends Partial<ChatFeedback> {
id: string
create_time?: string
/** 提问内容项 */
Expand All @@ -58,3 +58,10 @@ export interface ChatLogRecord {
paragraph_list?: Dict<unknown>[]
execution_details?: Dict<unknown>[]
}

/** 对话反馈:-1 未评价,0 满意,1 不满意。 */
export interface ChatFeedback {
vote_status: '-1' | '0' | '1'
vote_reason?: 'accurate' | 'complete' | 'inaccurate' | 'incomplete' | 'other' | null
vote_other_content?: string
}
74 changes: 74 additions & 0 deletions ui/src/conversation-panel/components/log-message-footer/index.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
<script setup lang="ts">
import { computed } from 'vue'
import type { ChatContent } from '@/api/types'
import { copyText } from '@/utils/clipboard'
import { datetimeFormat } from '@/utils/time'
import type { ChatMessage, ChatRecordMeta } from '../../core/types'
import { useExecutionDetailStore } from '../../right-sidebar/execution-detail/index'

const props = defineProps<{ message: ChatMessage }>()

const detail = useExecutionDetailStore()

const EMPTY: ChatRecordMeta = {
messageTokens: 0,
answerTokens: 0,
runTime: 0,
knowledgeCount: 0,
executionDetails: [],
}
const meta = computed<ChatRecordMeta>(() => props.message.record || EMPTY)
const tokens = computed(() => meta.value.messageTokens + meta.value.answerTokens)

// 仅复制回答正文,按展示顺序收集分组内的文本,不包含思考与工具参数。
function collectAnswerText(contents: ChatContent[]): string {
return [...contents]
.sort((left, right) => (left.id || '').localeCompare(right.id || ''))
.flatMap((content) => {
if (content.type === 'GROUP') return collectAnswerText(content.children ?? [])
return content.type === 'TEXT' ? content.content || '' : []
})
.filter(Boolean)
.join('\n\n')
}
const answerText = computed(() => collectAnswerText(props.message.content))

const openExecutionDetail = () => {
detail.showExecutionDetail(meta.value.executionDetails || [])
}
</script>

<style scoped lang="scss"></style>

<template>
<div class="mk-conversation-message-footer space-y-2">
<!-- 助手消息底部信息栏:知识来源 / 执行详情 / Tokens · 耗时 -->
<div class="flex-align-center gap-1">
<el-button v-if="meta.knowledgeCount" text class="-ml-1">
<MkIcon name="icon_reference_outlined" />
<span>知识来源 ({{ meta.knowledgeCount }})</span>
</el-button>

<!-- 执行详情:对话结束时已拉取并缓存,点击直接在右侧展示 -->
<el-button text @click="openExecutionDetail" class="-ml-1">
<MkIcon name="icon_logs_outlined" />
<span>执行详情</span>
</el-button>
<template v-if="meta.runTime || tokens">
<el-divider direction="vertical" class="ml-2! mr-3!" />
<span class="text-N600">Tokens {{ tokens }} · 耗时 {{ meta.runTime.toFixed(2) }}s</span>
</template>
</div>
<!-- 其他操作 -->
<div class="flex-align-center gap-2">
<!-- 复制回答正文 -->
<MkTooltip content="复制">
<el-button text class="-ml-1" :disabled="!answerText" @click="copyText(answerText)">
<MkIcon name="icon_copy_outlined" />
</el-button>
</MkTooltip>
<span v-if="meta.createTime" class="text-N600">{{ datetimeFormat(meta.createTime) }}</span>
</div>
<div></div>
</div>
</template>
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { computed } from 'vue'
import ContentList from '../content-list/index.vue'
import MessageFooter from '../message-footer/index.vue'
import LogMessageFooter from '../log-message-footer/index.vue'
import ReverseInfiniteScroll from '../../core/reverse-infinite-scroll/index.vue'
import { useLogMessageListStore } from './index'

Expand Down Expand Up @@ -31,7 +31,7 @@ const showUserAvatar = computed(() => appInfo.value?.show_user_avatar)
<ApplicationIcon v-if="showAvatar" :icon="avatar" :size="24" class="shrink-0" />
<ContentList :content-list="msg.content" />
<!-- 助手消息底部信息栏(知识来源/执行详情/tokens/耗时) -->
<MessageFooter class="mt-3" v-if="msg.role === 'ASSISTANT' && msg.record" :message="msg" />
<LogMessageFooter class="mt-3" v-if="msg.role === 'ASSISTANT' && msg.record" :message="msg" />
</div>
</template>

Expand Down
118 changes: 118 additions & 0 deletions ui/src/conversation-panel/components/message-footer/feedback/index.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import type { ChatFeedback } from '@/api/types'
import type { ChatMessage } from '../../../core/types'
import { useMessageListStore } from '../../message-list'
import { MsgSuccess } from '@/utils/message'

defineOptions({ name: 'MessageFeedback' })

const FEEDBACK_OPTIONS = [
{ status: '0', label: '赞同', icon: 'thumbsup' },
{ status: '1', label: '反对', icon: 'thumbdown' },
] as const

const props = defineProps<{ message: ChatMessage }>()
const { feedback } = useMessageListStore()
const draftStatus = ref<'0' | '1'>()
const reason = ref<ChatFeedback['vote_reason']>(null)
const content = ref('')
const submitting = ref(false)
const currentStatus = computed(() => props.message.record?.feedback?.vote_status ?? '-1')
const reasons = computed(() =>
draftStatus.value === '0'
? ([
{ value: 'accurate', label: '内容准确' },
{ value: 'complete', label: '内容完善' },
{ value: 'other', label: '其他' },
] as const)
: ([
{ value: 'inaccurate', label: '内容不准确' },
{ value: 'incomplete', label: '内容不完善' },
{ value: 'other', label: '其他' },
] as const),
)

// 已提交反馈先撤销再重新评价,避免后端重复投票;请求失败保留草稿。
async function saveFeedback(data: ChatFeedback) {
if (!feedback || !props.message.recordId || submitting.value) return
const message = props.message
submitting.value = true
try {
await feedback(message.recordId!, data)
if (message.record) message.record.feedback = data
draftStatus.value = undefined
MsgSuccess(data.vote_status === '-1' ? '已取消反馈' : '感谢你的反馈')
} catch {
// 请求层统一提示错误,保留当前填写内容供重试。
} finally {
submitting.value = false
}
}

function openFeedback(status: '0' | '1') {
if (currentStatus.value === status) {
void saveFeedback({ vote_status: '-1' })
return
}
draftStatus.value = draftStatus.value === status ? undefined : status
reason.value = null
content.value = ''
}
</script>

<template>
<div v-if="feedback && message.recordId && message.record" class="flex-align-center gap-1">
<template v-if="feedback && message.recordId && message.record">
<template v-for="option in FEEDBACK_OPTIONS" :key="option.status">
<!-- 打开评价面板,再次点击已评价按钮可撤销反馈 -->
<MkTooltip :content="currentStatus === option.status ? `取消${option.label}` : option.label">
<el-button
text
:disabled="submitting || (currentStatus !== '-1' && currentStatus !== option.status)"
:class="{ 'text-primary!': currentStatus === option.status || draftStatus === option.status }"
@click="openFeedback(option.status)"
>
<MkIcon :name="`icon_${option.icon}_${currentStatus === option.status ? 'filled' : 'outlined'}`" />
</el-button>
</MkTooltip>
</template>
</template>
</div>
<div v-if="draftStatus" class="mk-gray-card-lg border w-full space-y-3">
<div class="flex items-center justify-between gap-2">
<span class="font-medium">{{ draftStatus === '0' ? '你觉得什么让你满意?' : '请告诉我们不满意的原因' }}</span>
<!-- 关闭反馈填写面板 -->
<el-button text :disabled="submitting" @click="draftStatus = undefined"><MkIcon name="icon_close_outlined" /></el-button>
</div>
<div class="flex flex-wrap gap-2">
<template v-for="option in reasons" :key="option.value">
<!-- 选择或取消反馈原因 -->
<el-check-tag
type="info"
:checked="reason === option.value"
:disabled="submitting"
@change="reason = reason === option.value ? null : option.value"
>
{{ option.label }}
</el-check-tag>
</template>
</div>
<el-input v-model="content" type="textarea" :rows="3" :disabled="submitting" placeholder="告诉我们更多关于你的相关体验" />
<div class="text-right">
<!-- 取消本次反馈 -->
<el-button class="w-16! min-w-0!" plain :disabled="submitting" @click="draftStatus = undefined">取消</el-button>
<!-- 提交反馈与补充说明 -->
<el-button
class="w-16! min-w-0!"
type="primary"
:loading="submitting"
@click="saveFeedback({ vote_status: draftStatus, vote_reason: reason, vote_other_content: content.trim() })"
>
提交
</el-button>
</div>
</div>
</template>

<style scoped lang="scss"></style>
5 changes: 3 additions & 2 deletions ui/src/conversation-panel/components/message-footer/index.vue
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
<script setup lang="ts">
import { computed } from 'vue'
// import Feedback from './feedback/index.vue'
import type { ChatContent } from '@/api/types'
import { copyText } from '@/utils/clipboard'
import { datetimeFormat } from '@/utils/time'
Expand Down Expand Up @@ -60,15 +61,15 @@ const openExecutionDetail = () => {
</template>
</div>
<!-- 其他操作 -->
<div class="flex-align-center gap-2">
<div class="flex-align-center flex-wrap gap-2">
<!-- 复制回答正文 -->
<MkTooltip content="复制">
<el-button text class="-ml-1" :disabled="!answerText" @click="copyText(answerText)">
<MkIcon name="icon_copy_outlined" />
</el-button>
</MkTooltip>
<!-- <Feedback :message="message" /> -->
<span v-if="meta.createTime" class="text-N600">{{ datetimeFormat(meta.createTime) }}</span>
</div>
<div></div>
</div>
</template>
6 changes: 4 additions & 2 deletions ui/src/conversation-panel/components/message-list/index.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
import { ref, inject, type InjectionKey, type Ref } from 'vue'
import { nanoid } from 'nanoid'
import type { ChatLogRecord, Dict } from '@/api/types'
import type { ChatFeedback, ChatLogRecord, Dict } from '@/api/types'
import { aggregators } from '../../core/aggregators'
import { ConversationStream } from '../../core/stream'
import type { ChatMessage, ChatRecordMeta, ConversationApiAdapter, ConversationAppInfo, SendMessageOptions, StreamChunk } from '../../core/types'

export interface MessageListDeps extends Pick<
ConversationApiAdapter,
'pageRecords' | 'recordDetail' | 'chatMessage' | 'resumeMessage' | 'cancel' | 'uploadFile'
'feedback' | 'pageRecords' | 'recordDetail' | 'chatMessage' | 'resumeMessage' | 'cancel' | 'uploadFile'
> {
// 只读获取当前会话 id(单一数据源在 conversation-list)。必须无副作用:不可惰性创建,
// 否则无会话时的 stop/cancel/loadMore 会凭空生成 id 并误请求后端。
Expand All @@ -18,6 +18,7 @@ export interface MessageListDeps extends Pick<

// 从后端 chat_record(分页项或详情接口,均为 reset_chat_record 结构)提取底部信息栏数据
const buildRecordMeta = (raw?: ChatLogRecord | null): ChatRecordMeta => ({
feedback: { vote_status: raw?.vote_status ?? '-1', vote_reason: raw?.vote_reason, vote_other_content: raw?.vote_other_content },
createTime: raw?.create_time,
messageTokens: raw?.message_tokens || 0,
answerTokens: raw?.answer_tokens || 0,
Expand Down Expand Up @@ -235,6 +236,7 @@ export function createMessageListStore(deps: MessageListDeps) {
}

return {
feedback: deps.feedback ? (recordId: string, data: ChatFeedback) => deps.feedback!(getChatId(), recordId, data) : undefined,
appInfo,
messages,
loading,
Expand Down
4 changes: 3 additions & 1 deletion ui/src/conversation-panel/core/types.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { CHAT_TYPE } from './enums'
import type { ChatApplicationProfile, ChatContent, ChatLogRecord, ChatUserProfile, Dict } from '@/api/types'
import type { ChatFeedback, ChatApplicationProfile, ChatContent, ChatLogRecord, ChatUserProfile, Dict } from '@/api/types'

/**
* 当前对话的智能体信息,由 view 加载并注入各组件 store,chat 与 debug 结构一致。
Expand All @@ -24,6 +24,7 @@ export interface Conversation {

// 助手消息底部展示数据(对话结束后 / 历史加载时填充):知识来源数、tokens、耗时、执行详情
export interface ChatRecordMeta {
feedback?: ChatFeedback
createTime?: string
messageTokens: number
answerTokens: number
Expand Down Expand Up @@ -71,6 +72,7 @@ export interface SendMessageOptions {

// 模式适配:chat / debug 只在这些 API 上有差异,由 view/{mode} 组装时注入。
export interface ConversationApiAdapter {
feedback?: (chatId: string, recordId: string, data: ChatFeedback) => Promise<unknown>
pageConversations: (page: number, size: number) => Promise<{ records?: Conversation[] } | null | undefined>
pageRecords: (chatId: string, page: number, size: number) => Promise<{ records?: ChatLogRecord[] } | null | undefined>
recordDetail: (chatId: string, chatRecordId: string) => Promise<ChatLogRecord | null | undefined>
Expand Down
2 changes: 2 additions & 0 deletions ui/src/conversation-panel/view/chat/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ export function createChatConversation(routing: ChatConversationRouting = useApp
const { user } = useStore()

const api: ConversationApiAdapter = {
feedback: (cid, rid, data) => ConversationApi.putConversationFeedback(getApplicationId(), cid, rid, data),
// 门户首页尚未确定智能体时没有会话可加载
pageConversations: (p, s) =>
getApplicationId() ? ConversationApi.getConversationPage(getApplicationId(), p, s) : Promise.resolve({ records: [] }),
Expand Down Expand Up @@ -82,6 +83,7 @@ export function createChatConversation(routing: ChatConversationRouting = useApp
appInfo,
})
const msgs = createMessageListStore({
feedback: api.feedback,
pageRecords: api.pageRecords,
recordDetail: api.recordDetail,
chatMessage: api.chatMessage,
Expand Down
7 changes: 7 additions & 0 deletions ui/src/views/VIEW_README.md
Original file line number Diff line number Diff line change
Expand Up @@ -497,3 +497,10 @@ View 负责路由、资源与模式、图加载、保存/发布/调试编排
全部智能体与选择抽屉复用卡片,抽屉每次打开重新查询,搜索状态独立。
门户历史侧栏仅展示有历史的智能体及最近 5 条会话,剩余数量取分组总数;“查看更多”打开历史对话抽屉,
支持切换智能体、摘要搜索、滚动分页及打开/删除会话,删除后同步刷新侧栏。

### 对话消息反馈

`conversation-panel/components/message-footer/feedback/` 维护满意/不满意入口与内联原因面板。
正式单应用、门户对话通过可选的 `ConversationApiAdapter.feedback` 接入保存,调试端未装配时
不展示入口。反馈提交成功后更新消息记录,失败保留草稿;已评价按钮支持撤销,再评价需先撤销。
日志消息继续使用独立日志 Footer。
Loading
Loading