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
5 changes: 5 additions & 0 deletions ui/src/api/chat/conversation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,10 @@ const deleteConversation = (applicationId: string, chatId: string) => del(`${get
/** 修改会话信息。 */
const putConversation = (applicationId: string, chatId: string, data: unknown) => put(`${getApplicationPath(applicationId)}/chat/${chatId}`, data)

/** 生成会话记录分享链接,返回编码后的链接标识。 */
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 postSpeechToText = (applicationId: string, data: unknown) => post(`${getApplicationPath(applicationId)}/speech_to_text`, data)

Expand All @@ -56,5 +60,6 @@ export default {
getConversationRecordPage,
deleteConversation,
putConversation,
postShareConversation,
postSpeechToText,
}
20 changes: 20 additions & 0 deletions ui/src/conversation-panel/components/header/chat/index.vue
Original file line number Diff line number Diff line change
Expand Up @@ -21,20 +21,35 @@
<span class="header-title">{{ list.currentConversation.value?.abstract || '新对话' }}</span>
</div>
</div>
<!-- 进入分享模式,勾选要分享的对话 -->
<button v-if="shareable" class="header-btn" :disabled="!canShare" @click="emit('share')">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
<path d="M9.5 3l4 4-4 4M13.5 7H7.5a5 5 0 0 0-5 5v1" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round" />
</svg>
</button>
<slot />
</header>
</template>

<script setup lang="ts">
import { computed } from 'vue'
import { Plus } from '@element-plus/icons-vue'
import { useConversationListStore } from '../../../left-sidebar/conversation-list/index'
import { useMessageListStore } from '../../message-list/index'

const props = defineProps<{
/** 新建对话;不传时在当前应用中新建(门户在没有当前智能体时改为选择智能体) */
newConversation?: () => void
/** 显示分享入口;仅对话端传入 */
shareable?: boolean
}>()

const emit = defineEmits<{ share: [] }>()

const list = useConversationListStore()
const msgs = useMessageListStore()
// 回答生成中或没有消息时不可分享
const canShare = computed(() => msgs.messages.value.length > 0 && !msgs.streamLoading.value)

const handleNewConversation = () => (props.newConversation ? props.newConversation() : list.newConversation())
</script>
Expand Down Expand Up @@ -65,6 +80,11 @@ const handleNewConversation = () => (props.newConversation ? props.newConversati
.header-btn:hover {
background: rgba(0, 0, 0, 0.05);
}
.header-btn:disabled {
cursor: not-allowed;
opacity: 0.4;
background: transparent;
}
.header-info {
display: flex;
align-items: center;
Expand Down
9 changes: 8 additions & 1 deletion ui/src/conversation-panel/main/chat-panel/index.vue
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,13 @@ import MessageInput from '../../components/message-input/index.vue'
import { useConversationListStore } from '../../left-sidebar/conversation-list/index'
import { useMessageInputStore } from '../../components/message-input/index'

defineProps<{
/** 标题栏显示分享入口;仅对话端传入 */
shareable?: boolean
}>()

const emit = defineEmits<{ share: [] }>()

const list = useConversationListStore()
const input = useMessageInputStore()

Expand All @@ -15,7 +22,7 @@ const handleDrop = (e: DragEvent) => input.addFiles(e.dataTransfer?.files)
<!-- 带输入框的主面板:header + message-list + message-input -->
<main class="mk-conversation-main" @drop.prevent="handleDrop" @dragover.prevent>
<!-- 头部可被替换(如 debug 用 debug header);默认普通 chat header -->
<slot name="header"><ChatHeader /></slot>
<slot name="header"><ChatHeader :shareable="shareable" @share="emit('share')" /></slot>
<MessageList :key="list.currentChatId.value" :app-name="list.appInfo.value?.name || 'AI 助手'" />

<footer class="flex-center shrink-0 p-4"><MessageInput /></footer>
Expand Down
57 changes: 57 additions & 0 deletions ui/src/conversation-panel/main/share-panel/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { ref, inject, type InjectionKey } from 'vue'

export interface SharePanelDeps {
/** 提交分享:参数为按消息顺序排列的已选 chat_record id */
share: (chatRecordIds: string[]) => Promise<unknown> | void
}

/**
* share-panel 组件 store:分享模式开关 + 已选对话轮次。
* 由 view 创建并 provide;标题栏分享按钮经 chat-panel 事件交给 view 打开分享模式,view 再用 v-if 切换主区域。
*/
export function createSharePanelStore(deps: SharePanelDeps) {
const active = ref(false)
const sharing = ref(false)
// 已选轮次 key(轮次内提问消息的 id),提交时由面板按消息顺序换算为 chat_record id
const selectedRoundKeys = ref<string[]>([])

const open = () => {
selectedRoundKeys.value = []
active.value = true
}
const close = () => {
active.value = false
selectedRoundKeys.value = []
}

const isSelected = (roundKey: string) => selectedRoundKeys.value.includes(roundKey)
const toggleRound = (roundKey: string) => {
selectedRoundKeys.value = isSelected(roundKey)
? selectedRoundKeys.value.filter((selectedKey) => selectedKey !== roundKey)
: [...selectedRoundKeys.value, roundKey]
}
const setSelectedRounds = (roundKeys: string[]) => (selectedRoundKeys.value = [...roundKeys])

const share = async (chatRecordIds: string[]) => {
if (!chatRecordIds.length || sharing.value) return
sharing.value = true
try {
await deps.share(chatRecordIds)
close()
} finally {
sharing.value = false
}
}

return { active, sharing, selectedRoundKeys, open, close, isSelected, toggleRound, setSelectedRounds, share }
}

export type SharePanelStore = ReturnType<typeof createSharePanelStore>

export const SHARE_PANEL_KEY: InjectionKey<SharePanelStore> = Symbol('share-panel')

export function useSharePanelStore(): SharePanelStore {
const store = inject(SHARE_PANEL_KEY)
if (!store) throw new Error('useSharePanelStore 必须在提供 SHARE_PANEL_KEY 的视图内使用')
return store
}
98 changes: 98 additions & 0 deletions ui/src/conversation-panel/main/share-panel/index.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
<script setup lang="ts">
import { computed } from 'vue'
import ContentList from '../../components/content-list/index.vue'
import ReverseInfiniteScroll from '../../core/reverse-infinite-scroll/index.vue'
import { useMessageListStore } from '../../components/message-list/index'
import { useSharePanelStore } from './index'
import type { ChatMessage } from '../../core/types'

defineOptions({ name: 'SharePanel' })

/** 一轮对话:提问 + 其后的回答,以提问消息 id 作为勾选 key。 */
interface ShareRound {
key: string
chatRecordId?: string
messages: ChatMessage[]
}

const { messages, loading, hasMore, loadMore } = useMessageListStore()
const share = useSharePanelStore()

const shareRounds = computed<ShareRound[]>(() => {
const rounds: ShareRound[] = []
messages.value.forEach((message) => {
const lastRound = rounds[rounds.length - 1]
if (message.role === 'ASSISTANT' && lastRound && !lastRound.chatRecordId) {
lastRound.messages.push(message)
lastRound.chatRecordId = message.recordId
} else {
rounds.push({ key: message.id, chatRecordId: message.role === 'ASSISTANT' ? message.recordId : undefined, messages: [message] })
}
})
// 尚未生成 chat_record 的轮次(回答未落库)无法分享
return rounds.filter((round) => round.chatRecordId)
})

const selectedCount = computed(() => share.selectedRoundKeys.value.length)
const isAllSelected = computed(() => shareRounds.value.length > 0 && selectedCount.value === shareRounds.value.length)
const isIndeterminate = computed(() => selectedCount.value > 0 && !isAllSelected.value)

const handleSelectAll = (checked: boolean | string | number) => share.setSelectedRounds(checked ? shareRounds.value.map((round) => round.key) : [])

const handleShare = () => {
const chatRecordIds = shareRounds.value.filter((round) => share.isSelected(round.key)).map((round) => round.chatRecordId as string)
void share.share(chatRecordIds)
}
</script>

<template>
<!-- 分享主面板:勾选要分享的对话轮次 -->
<main class="mk-conversation-main">
<header class="flex-align-center h-11 shrink-0 gap-3 border-b border-N300 px-3">
<!-- 全选当前已加载的对话 -->
<el-checkbox :model-value="isAllSelected" :indeterminate="isIndeterminate" :disabled="!shareRounds.length" @change="handleSelectAll">
全选
</el-checkbox>
<span class="min-w-0 flex-1 truncate text-N600">已选择 {{ selectedCount }} 条对话</span>
<!-- 退出分享模式 -->
<el-button @click="share.close()">取消</el-button>
<!-- 分享已选对话 -->
<el-button type="primary" class="ml-0!" :disabled="!selectedCount" :loading="share.sharing.value" @click="handleShare">分享</el-button>
</header>

<ReverseInfiniteScroll class="p-4" :loading="loading" :has-more="hasMore" :count="shareRounds.length" :load-more="loadMore">
<!-- 点击整轮对话切换勾选 -->
<div
v-for="round in shareRounds"
:key="round.key"
class="share-round flex cursor-pointer gap-3 rounded-lg p-2"
:class="{ selected: share.isSelected(round.key) }"
@click="share.toggleRound(round.key)"
>
<el-checkbox :model-value="share.isSelected(round.key)" class="pointer-events-none shrink-0" />
<!-- 分享模式下内容只读,不响应内部交互 -->
<div class="pointer-events-none min-w-0 flex-1 select-none">
<ContentList v-for="message in round.messages" :key="message.id" :content-list="message.content" />
</div>
</div>

<template #empty>
<MkEmpty description="暂无可分享的对话" class="mt-20" />
</template>
</ReverseInfiniteScroll>
</main>
</template>

<style scoped lang="scss">
.share-round {
transition: background-color 150ms ease;

&:hover {
background: var(--mk-N100);
}

&.selected {
background: color-mix(in srgb, var(--mk-primary) 6%, transparent);
}
}
</style>
26 changes: 21 additions & 5 deletions ui/src/conversation-panel/view/chat/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,21 @@ import ConversationApi from '@/api/chat/conversation'
import { useStore } from '@/stores/chat'
import FileApi from '@/api/chat/file'
import { FILE_SOURCE_TYPE } from '@/api/enums'
import { CHAT_API_BASE_PATH } from '@/api/constants'
import { copyText } from '@/utils/clipboard'
import type { ConversationApiAdapter } from '../../core/types'
import { createConversationListStore } from '../../left-sidebar/conversation-list/index'
import { createMessageListStore } from '../../components/message-list/index'
import { createMessageInputStore } from '../../components/message-input/index'
import { createExecutionDetailStore } from '../../right-sidebar/execution-detail/index'
import { createSharePanelStore, type SharePanelStore } from '../../main/share-panel/index'
import type { ConversationBundle } from '../types'

/** chat 模式在通用组件 store 之外提供分享;debug 模式无分享。 */
export interface ChatConversationBundle extends ConversationBundle {
share: SharePanelStore
}

/** chat 模式的智能体与会话地址来源;单应用与门户的路由不同,由使用方提供。 */
export interface ChatConversationRouting {
/** 当前对话的智能体 ID,v3 对话接口按智能体划分路径。 */
Expand Down Expand Up @@ -42,13 +50,14 @@ function useApplicationChatRouting(): ChatConversationRouting {
* chat 模式:装配各组件 store。模式差异只在这里的 adapter。
* 当前会话与地址双向同步,切换智能体时重新加载智能体信息与会话。
*/
export function createChatConversation(routing: ChatConversationRouting = useApplicationChatRouting()): ConversationBundle {
export function createChatConversation(routing: ChatConversationRouting = useApplicationChatRouting()): ChatConversationBundle {
const { getApplicationId, getRouteChatId, replaceChatId } = routing
const { user } = useStore()

const api: ConversationApiAdapter = {
// 门户首页尚未确定智能体时没有会话可加载
pageConversations: (p, s) => (getApplicationId() ? ConversationApi.getConversationPage(getApplicationId(), p, s) : Promise.resolve({ records: [] })),
pageConversations: (p, s) =>
getApplicationId() ? ConversationApi.getConversationPage(getApplicationId(), p, s) : Promise.resolve({ records: [] }),
pageRecords: (cid, p, s) => ConversationApi.getConversationRecordPage(getApplicationId(), cid, p, s),
recordDetail: (cid, rid) => ConversationApi.getConversationRecordDetail(getApplicationId(), cid, rid),
remove: (id) => ConversationApi.deleteConversation(getApplicationId(), id),
Expand All @@ -58,7 +67,7 @@ export function createChatConversation(routing: ChatConversationRouting = useApp
uploadFile: (file, cid, onProgress) => FileApi.postUploadFile(file, cid, FILE_SOURCE_TYPE.CHAT, onProgress).request,
cancel: (cid) => ConversationApi.postCancelConversationMessage(cid),
}

const list = createConversationListStore({
pageConversations: api.pageConversations,
remove: api.remove,
Expand All @@ -75,7 +84,7 @@ export function createChatConversation(routing: ChatConversationRouting = useApp
resumeMessage: api.resumeMessage,
cancel: api.cancel,
uploadFile: api.uploadFile,
getChatId: () => list.currentChatId.value,
getChatId: () => list.currentChatId.value,
})
list.bindLoader(msgs.load)
const input = createMessageInputStore({
Expand All @@ -92,6 +101,13 @@ export function createChatConversation(routing: ChatConversationRouting = useApp
disabled: computed(() => !getApplicationId()),
})
const detail = createExecutionDetailStore()
// 生成分享链接后复制分享页地址(与 v2 一致为 {chat 部署路径}/share/{link})
const share = createSharePanelStore({
share: (chatRecordIds) =>
ConversationApi.postShareConversation(getApplicationId(), list.currentChatId.value, { chat_record_ids: chatRecordIds }).then(({ link }) =>
copyText(`${window.location.origin}${CHAT_API_BASE_PATH.replace(/\/api$/, '')}/share/${link}`, '分享链接已复制'),
),
})

// 智能体名称与图标展示在侧栏与消息列表
const loadApplicationProfile = () => {
Expand Down Expand Up @@ -131,5 +147,5 @@ export function createChatConversation(routing: ChatConversationRouting = useApp
// 地址中带会话 ID 时打开该会话
if (getRouteChatId()) list.selectConversation(getRouteChatId())

return { list, msgs, input, detail }
return { list, msgs, input, detail, share }
}
11 changes: 10 additions & 1 deletion ui/src/conversation-panel/view/chat/index.vue
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,12 @@ import ConversationLayout from '../../core/layout/index.vue'
import ConversationList from '../../left-sidebar/conversation-list/index.vue'
import ChatPanel from '../../main/chat-panel/index.vue'
import ExecutionDetail from '../../right-sidebar/execution-detail/index.vue'
import SharePanel from '../../main/share-panel/index.vue'
import { CONVERSATION_LIST_KEY } from '../../left-sidebar/conversation-list/index'
import { MESSAGE_LIST_KEY } from '../../components/message-list/index'
import { MESSAGE_INPUT_KEY } from '../../components/message-input/index'
import { EXECUTION_DETAIL_KEY } from '../../right-sidebar/execution-detail/index'
import { SHARE_PANEL_KEY } from '../../main/share-panel/index'
import { createChatConversation } from './index'

const bundle = createChatConversation()
Expand All @@ -17,6 +19,7 @@ provide(CONVERSATION_LIST_KEY, bundle.list)
provide(MESSAGE_LIST_KEY, bundle.msgs)
provide(MESSAGE_INPUT_KEY, bundle.input)
provide(EXECUTION_DETAIL_KEY, bundle.detail)
provide(SHARE_PANEL_KEY, bundle.share)

// 响应式:窄屏 drawer,宽屏 push
const BREAKPOINT = 768
Expand Down Expand Up @@ -44,6 +47,8 @@ watch(isMobile, (m) => (bundle.list.leftSideOpen.value = !m))
// 新建或重新选择会话时,即使会话 ID 不变也收起左侧抽屉。
watch([() => bundle.list.currentChatId.value, () => bundle.list.composerResetSignal.value], () => {
if (leftMode.value === 'drawer') bundle.list.leftSideOpen.value = false
// 切换或新建会话时退出分享模式
bundle.share.close()
})
</script>

Expand All @@ -57,7 +62,11 @@ watch([() => bundle.list.currentChatId.value, () => bundle.list.composerResetSig
@mask-click="closeDrawers"
>
<template #left><ConversationList /></template>
<template #main><ChatPanel /></template>
<template #main>
<!-- 分享模式下主区域切换为分享面板 -->
<SharePanel v-if="bundle.share.active.value" />
<ChatPanel v-else shareable @share="bundle.share.open()" />
</template>
<template #right><ExecutionDetail /></template>
</ConversationLayout>
</template>
Expand Down
Loading