diff --git a/ui/src/api/chat/conversation.ts b/ui/src/api/chat/conversation.ts index a71c61b6a81..e47972f8a9f 100644 --- a/ui/src/api/chat/conversation.ts +++ b/ui/src/api/chat/conversation.ts @@ -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) @@ -56,5 +60,6 @@ export default { getConversationRecordPage, deleteConversation, putConversation, + postShareConversation, postSpeechToText, } diff --git a/ui/src/conversation-panel/components/header/chat/index.vue b/ui/src/conversation-panel/components/header/chat/index.vue index 5e6e305450d..f2ee4561630 100644 --- a/ui/src/conversation-panel/components/header/chat/index.vue +++ b/ui/src/conversation-panel/components/header/chat/index.vue @@ -21,20 +21,35 @@ {{ list.currentConversation.value?.abstract || '新对话' }} + + @@ -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; diff --git a/ui/src/conversation-panel/main/chat-panel/index.vue b/ui/src/conversation-panel/main/chat-panel/index.vue index 4a436f25ca8..943ad24234a 100644 --- a/ui/src/conversation-panel/main/chat-panel/index.vue +++ b/ui/src/conversation-panel/main/chat-panel/index.vue @@ -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() @@ -15,7 +22,7 @@ const handleDrop = (e: DragEvent) => input.addFiles(e.dataTransfer?.files)
- +
diff --git a/ui/src/conversation-panel/main/share-panel/index.ts b/ui/src/conversation-panel/main/share-panel/index.ts new file mode 100644 index 00000000000..88ff0844ec6 --- /dev/null +++ b/ui/src/conversation-panel/main/share-panel/index.ts @@ -0,0 +1,57 @@ +import { ref, inject, type InjectionKey } from 'vue' + +export interface SharePanelDeps { + /** 提交分享:参数为按消息顺序排列的已选 chat_record id */ + share: (chatRecordIds: string[]) => Promise | 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([]) + + 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 + +export const SHARE_PANEL_KEY: InjectionKey = Symbol('share-panel') + +export function useSharePanelStore(): SharePanelStore { + const store = inject(SHARE_PANEL_KEY) + if (!store) throw new Error('useSharePanelStore 必须在提供 SHARE_PANEL_KEY 的视图内使用') + return store +} diff --git a/ui/src/conversation-panel/main/share-panel/index.vue b/ui/src/conversation-panel/main/share-panel/index.vue new file mode 100644 index 00000000000..9715b9d9e1c --- /dev/null +++ b/ui/src/conversation-panel/main/share-panel/index.vue @@ -0,0 +1,98 @@ + + + + + diff --git a/ui/src/conversation-panel/view/chat/index.ts b/ui/src/conversation-panel/view/chat/index.ts index 35de6315bcb..2e2b05229a1 100644 --- a/ui/src/conversation-panel/view/chat/index.ts +++ b/ui/src/conversation-panel/view/chat/index.ts @@ -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 对话接口按智能体划分路径。 */ @@ -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), @@ -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, @@ -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({ @@ -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 = () => { @@ -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 } } diff --git a/ui/src/conversation-panel/view/chat/index.vue b/ui/src/conversation-panel/view/chat/index.vue index af450d45634..73d118c9585 100644 --- a/ui/src/conversation-panel/view/chat/index.vue +++ b/ui/src/conversation-panel/view/chat/index.vue @@ -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() @@ -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 @@ -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() }) @@ -57,7 +62,11 @@ watch([() => bundle.list.currentChatId.value, () => bundle.list.composerResetSig @mask-click="closeDrawers" > - +