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 @@
+
+
@@ -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 @@
+
+
+
+
+
+
+
+
+ 全选
+
+ 已选择 {{ selectedCount }} 条对话
+
+ 取消
+
+ 分享
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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"
>
-
+
+
+
+
+