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
6 changes: 6 additions & 0 deletions ui/src/components/COMPONENT_README.md
Original file line number Diff line number Diff line change
Expand Up @@ -979,6 +979,12 @@ Workspace API 内部使用 `getWorkspaceId()`,System 共享 API 不接收工
`hasMore`。首页失败后通过重新加载首页重试,不从下一页继续。
Chat、Portal 和 Display 面板直接放置消息列表,并以当前会话 ID 为 `key`,不再包裹额外滚动层。

### Question(对话模块)

`conversation-panel/components/content/items/question/index.vue` 负责提问展示、附件和操作入口。
同目录 `QuestionEditor.vue` 接收 `content`、`disabled`,维护本次编辑草稿,通过 `cancel` 和
`send(question)` 通知入口组件;进入时挂载并聚焦,退出时卸载。入口组件负责发送能力与附件传递。

### SharePanel(对话模块)

`conversation-panel/main/share-panel/index.vue` 展示只读问答,通过 `useSharePanelContentStore()`
Expand Down
2 changes: 1 addition & 1 deletion ui/src/conversation-panel/components/content/index.vue
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import GroupToolItem from './items/group-tool/index.vue'
import GroupItem from './items/group/index.vue'
import FormItem from './items/form.vue'
import FailureItem from './items/failure.vue'
import QuestionItem from './items/question.vue'
import QuestionItem from './items/question/index.vue'

defineOptions({ name: 'ConversationContent' })

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import type { InputInstance } from 'element-plus'

defineOptions({ name: 'ConversationQuestionEditor' })

const props = defineProps<{ content: string; disabled: boolean }>()
const emit = defineEmits<{ cancel: []; send: [question: string] }>()

// 每次进入编辑模式创建草稿,取消时随组件卸载丢弃。
const questionDraft = ref(props.content)
const editorRef = ref<InputInstance>()
onMounted(() => editorRef.value?.focus())

function sendQuestion() {
if (!props.disabled && questionDraft.value.trim()) emit('send', questionDraft.value)
}
</script>

<template>
<div class="space-y-3">
<!-- 编辑提问正文 -->
<el-input
ref="editorRef"
v-model="questionDraft"
type="textarea"
:autosize="{ minRows: 1, maxRows: 8 }"
resize="none"
class="question-editor-input"
/>
<div class="flex justify-end gap-3">
<!-- 取消编辑 -->
<el-button class="min-w-20" @click="emit('cancel')">取消</el-button>
<!-- 发送编辑后的提问 -->
<el-button type="primary" class="min-w-20" :disabled="disabled || !questionDraft.trim()" @click="sendQuestion">发送</el-button>
</div>
</div>
</template>

<style scoped lang="scss">
.question-editor-input :deep(.el-textarea__inner) {
padding: 0;
box-shadow: none;
background: transparent;
}
</style>
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
<script setup lang="ts">
import { computed, inject } from 'vue'
import { computed, inject, ref, watch } from 'vue'
import QuestionEditor from './QuestionEditor.vue'
import { useMessageInputStoreOptional } from '../../../message-input/index'
import MkAudio from '@/components/global/mk-media-preview/MkAudio.vue'
import MkVideo from '@/components/global/mk-media-preview/MkVideo.vue'
import FileUploadCard from '@/components/mk-drag-upload/FileUploadCard.vue'
Expand All @@ -9,8 +11,8 @@ import { formatFileSize } from '@/utils/number'
import { downloadByURL } from '@/utils/common'
import { copyText } from '@/utils/clipboard'

import { MESSAGE_LIST_KEY } from '../../message-list/index'
import { CONVERSATION_LIST_KEY } from '../../../left-sidebar/conversation-list/index'
import { MESSAGE_LIST_KEY } from '../../../message-list/index'
import { CONVERSATION_LIST_KEY } from '../../../../left-sidebar/conversation-list/index'

defineOptions({ name: 'ConversationQuestionContent' })

Expand All @@ -31,6 +33,26 @@ type QuestionContent = { content?: string } & Partial<Record<AttachmentField, Qu

const props = defineProps<{ content: QuestionContent }>()

// 编辑保留原提问及附件,发送沿用输入框的新问题流程。
const messageInput = useMessageInputStoreOptional()
const editing = ref(false)
const canEdit = computed(() =>
Boolean(messageInput && !messageInput.disabled.value && !messageInput.streamLoading.value && !conversationList?.isShared.value),
)

function startEditing() {
if (canEdit.value) editing.value = true
}

function sendEditedQuestion(question: string) {
if (!canEdit.value || !question.trim()) return
messageInput?.sendQuestion(question, { attachments: props.content })
}

watch(canEdit, (available) => {
if (!available) editing.value = false
})

// 非图片附件统一排列在上方,图片单独放在下方。
const attachmentFields = ['document_list', 'image_list', 'audio_list', 'video_list', 'other_list'] as const
const attachments = computed(() =>
Expand Down Expand Up @@ -59,7 +81,10 @@ function getAttachmentDescription(file: QuestionAttachment) {
</script>

<template>
<div class="mk-conversation-question-content group/question space-y-3 cursor-pointer">
<div
class="mk-conversation-question-content group/question space-y-3"
:class="editing ? 'question-editor border border-primary rounded-2xl p-3' : 'cursor-pointer'"
>
<!-- 视频、音频和文档共用一行,图片在下方;超出当前宽度时自动换行。 -->
<div v-if="attachments.length" class="max-w-full space-y-2">
<template v-for="attachmentGroup in attachmentGroups" :key="attachmentGroup.type">
Expand Down Expand Up @@ -99,9 +124,20 @@ function getAttachmentDescription(file: QuestionAttachment) {
</div>
</template>
</div>
<div v-if="content.content" class="mk-gray-card-lg ml-auto w-fit max-w-full py-3! whitespace-pre-wrap wrap-anywhere">{{ content.content }}</div>
<!-- TODO 用户的操作 -->
<div class="h-8 -mt-3 flex items-center justify-end gap-1 invisible group-hover/question:visible">
<QuestionEditor v-if="editing" :content="content.content ?? ''" :disabled="!canEdit" @cancel="editing = false" @send="sendEditedQuestion" />
<div v-else-if="content.content" class="mk-gray-card-lg ml-auto w-fit max-w-full py-3! whitespace-pre-wrap wrap-anywhere">
{{ content.content }}
</div>
<div
v-if="!editing"
class="h-8 -mt-3 flex items-center justify-end gap-1 invisible group-hover/question:visible group-focus-within/question:visible"
>
<!-- 编辑提问 -->
<MkTooltip v-if="messageInput" content="编辑">
<el-button text :disabled="!canEdit" @click="startEditing">
<MkIcon name="icon_edit_outlined" />
</el-button>
</MkTooltip>
<!-- 复制提问 -->
<el-button text @click="copyText(content.content)">
<MkIcon name="icon_copy_outlined" />
Expand All @@ -115,3 +151,9 @@ function getAttachmentDescription(file: QuestionAttachment) {
</div>
</div>
</template>

<style scoped lang="scss">
.question-editor {
width: 100%;
}
</style>
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,6 @@ export function createMessageInputStore(deps: MessageInputDeps) {
const maxFiles = 10
const maxSizeMB = 50

// TODO 格式 不行
const acceptList = [...IMAGE_EXTENSIONS, ...DOCUMENT_EXTENSIONS, ...VIDEO_EXTENSIONS, ...AUDIO_EXTENSIONS].map((e) => `.${e}`).join(',')

// 语音输入:智能体开启语音转文字且当前模式提供接口时可用
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,35 @@ export function createPortalConversationListStore(deps: PortalConversationListDe
}
}

// 先查询全部分组和会话,再删除,避免边删除边翻页造成记录遗漏。
const clearConversations = async (onRemoved: (applicationId: string, chatId: string) => void) => {
const applicationIds = new Set<string>()
for (let page = 1; ; page++) {
const response = await pageApplicationConversations(page, APPLICATION_PAGE_SIZE)
const records = response.records ?? []
records.forEach((application) => applicationIds.add(application.id))
if (records.length < APPLICATION_PAGE_SIZE) break
}

const pendingConversations: { applicationId: string; chatId: string }[] = []
for (const applicationId of applicationIds) {
for (let page = 1; ; page++) {
const response = await pageGroupConversations(applicationId, page, APPLICATION_PAGE_SIZE)
// 查询失败不得当作空页继续删除。
if (!response) throw new Error('获取历史对话失败')
const records = response.records ?? []
records.forEach((conversation) => pendingConversations.push({ applicationId, chatId: conversation.id }))
if (records.length < APPLICATION_PAGE_SIZE) break
}
}

for (const { applicationId, chatId } of pendingConversations) {
await deleteGroupConversation(applicationId, chatId)
onRemoved(applicationId, chatId)
}
await loadApplicationGroups()
}

// 按名称查询可访问的智能体,全部智能体页与选择智能体抽屉各自维护搜索条件与结果
const fetchApplications = (keyword: string) => pageApplications(1, APPLICATION_PAGE_SIZE, keyword.trim()).then((response) => response.records ?? [])

Expand Down Expand Up @@ -116,6 +145,7 @@ export function createPortalConversationListStore(deps: PortalConversationListDe
loadApplicationGroups,
toggleGroup,
deleteGroupConversation,
clearConversations,
applications,
applicationKeyword,
isApplicationsLoading,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { MsgConfirm, MsgSuccess } from '@/utils/message'
import { relativeTimeFormat } from '@/utils/time'
import type { Conversation } from '../../core/types'
import { useConversationListStore } from '../conversation-list/index'
Expand All @@ -12,7 +13,7 @@ defineOptions({ name: 'PortalConversationList' })

const portalList = usePortalConversationListStore()
const { applicationGroups, currentApplicationId, isApplicationListActive, toggleGroup } = portalList
const { conversations, currentChatId, selectConversation, deleteChat, toggleLeftSide } = useConversationListStore()
const { conversations, currentChatId, selectConversation, deleteChat, newConversation, switchShared, toggleLeftSide } = useConversationListStore()

const historyApplicationId = ref('')
const historyVisible = ref(false)
Expand Down Expand Up @@ -50,6 +51,30 @@ const handleDeleteConversation = (group: PortalApplicationGroup, conversation: C
if (isCurrentApplication(group)) return deleteChat(conversation.id).then(() => portalList.loadApplicationGroups())
else portalList.deleteGroupConversation(group.id, conversation.id)
}
// 清空所有智能体的历史,删除成功后同步当前会话;失败时保留尚未删除的记录。
const clearing = ref(false)
const handleClearConversations = () => {
if (clearing.value) return
clearing.value = true
MsgConfirm('确认清空全部历史对话?', '将清空所有智能体的历史对话,清空后无法恢复。', { confirmButtonText: '清空' })
.then(() => {
historyVisible.value = false
return portalList
.clearConversations((applicationId, chatId) => {
if (applicationId !== currentApplicationId.value) return
conversations.value = conversations.value.filter((conversation) => conversation.id !== chatId)
if (currentChatId.value === chatId) {
switchShared(false)
newConversation()
}
})
.then(() => MsgSuccess('清空成功'))
})
.catch(() => {})
.finally(() => {
clearing.value = false
})
}
</script>

<template>
Expand Down Expand Up @@ -83,14 +108,16 @@ const handleDeleteConversation = (group: PortalApplicationGroup, conversation: C
<div class="flex-between pl-2 mt-4">
<h6 class="text-N600 font-medium">历史对话</h6>

<!-- // TODO: 全部清空对话 -->
<el-button text>
<MkIcon name="icon_delete-trash_outlined" />
</el-button>
<MkTooltip content="清空">
<!-- 清空所有智能体的历史对话 -->
<el-button text :loading="clearing" :disabled="!historyApplicationGroups.length" @click="handleClearConversations">
<MkIcon name="icon_delete-trash_outlined" />
</el-button>
</MkTooltip>
</div>

<!-- 历史列表独立滚动,门户入口和新建入口保持固定。 -->
<el-scrollbar class="min-h-0 flex-1 mt-1 -mr-4" view-class="pr-4">
<el-scrollbar :inert="clearing" class="min-h-0 flex-1 mt-1 -mr-4" view-class="pr-4">
<div v-if="historyApplicationGroups.length === 0" class="text-N600 text-center mt-4">暂无历史记录</div>
<div v-else class="space-y-1">
<template v-for="group in historyApplicationGroups" :key="group.id">
Expand Down
2 changes: 1 addition & 1 deletion ui/src/views/chat/share/index.vue
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, onMounted, provide, ref, watch } from 'vue'
import { computed, onMounted, provide, ref } from 'vue'
import { useRoute } from 'vue-router'
import ConversationApi from '@/api/chat/conversation'
import type { ChatShareDetail } from '@/api/types'
Expand Down
Loading