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
8 changes: 8 additions & 0 deletions ui/src/api/API_README.md
Original file line number Diff line number Diff line change
Expand Up @@ -798,3 +798,11 @@ Workspace 和 System 资源管理的 `getKnowledgeDetail(knowledgeId, section?)`
对应后台独立的设置/策略查看权限;普通详情会去除未授权的配置元数据。
`getKnowledgeMcpConfig` 使用 `/<knowledgeId>/mcp_config`,仅获取 MCP 配置,独立于外部服务设置权限。
文档和图片复用的操作接口按持久化资源类型鉴权;批量图片操作未传 `resource_type` 时由服务端解析实际类型。

### 公开对话分享

`chat/conversation.ts` 的 `getShareConversation(shareId)` 请求 Chat API 下的 `GET /share/<shareId>`,
不需要 accessToken 或 applicationId。响应类型 `ChatShareDetail` 经 `@/api/types` 导出,包含
`abstract` 和按时间正序排列的 `chat_record_list`。当前后端返回旧版 `problem_text`、
`answer_text`、`answer_text_list` 及仅含上传附件的 `execution_details`,不返回 V3 `question`、`messages`。
分享页适配旧版字段,不通过原会话接口补查内容。
6 changes: 5 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, PortalConversationSummary } from '@/api/types'
import type { ChatLogRecord, ChatShareDetail, PortalConversationSummary } from '@/api/types'

/** v3 对话接口均按智能体划分资源路径。 */
const getApplicationPath = (applicationId: string) => `/v3/application/${applicationId}`
Expand Down Expand Up @@ -46,6 +46,9 @@ 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 getShareConversation = (shareId: string) => get<ChatShareDetail>(`/share/${encodeURIComponent(shareId)}`)

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

Expand All @@ -61,5 +64,6 @@ export default {
deleteConversation,
putConversation,
postShareConversation,
getShareConversation,
postSpeechToText,
}
16 changes: 16 additions & 0 deletions ui/src/api/types/chat-share.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import type { ChatContent } from './chat-log'
import type { Dict } from './common'

/** 公开分享接口返回的旧版问答记录。 */
export interface ChatShareRecord {
id: string
problem_text: string
answer_text: string
answer_text_list: (ChatContent | ChatContent[])[]
execution_details?: Dict<unknown>[]
}

export interface ChatShareDetail {
abstract: string | null
chat_record_list: ChatShareRecord[]
}
2 changes: 2 additions & 0 deletions ui/src/api/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,3 +38,5 @@ export type * from './termbase'
export type * from './chat-log'

export type * from './shared-authorization'

export type * from './chat-share'
15 changes: 15 additions & 0 deletions ui/src/components/COMPONENT_README.md
Original file line number Diff line number Diff line change
Expand Up @@ -966,11 +966,26 @@ Workspace API 内部使用 `getWorkspaceId()`,System 共享 API 不接收工
聊天列表订阅流式进度后调用 `followBottom()`;只读日志无需订阅。
`message-list` 和 `log-message-list` 使用该容器,保留各自的消息正文与底部信息;聊天列表
自行展示流式圆点,开场白放入 `prepend` 插槽,日志空状态放入 `empty` 插槽。日志列表不注入发送能力。
正式对话的 `message-list` 根据会话 Store 的 `isShared` 展示分享选择态,同轮提问与回答同步选择,
内容只读且选中时显示背景;无记录 ID 的问答不可选。列表底部提供当前已加载轮次的全选、已选数量、
取消及创建链接,创建时按消息顺序提交记录 ID;成功退出分享,失败保留选择。分享期间主面板隐藏输入框,
消息列表卸载时退出分享态;调试入口不提供分享服务。
分享组件放在 `message-list/share/`:`ShareMessageRow` 通过插槽承载消息,按记录 ID 切换整轮选择;
`ShareFooter` 负责全选、数量、取消、创建链接及退出清理。两者通过 `v-model` 共享已选记录 ID,
各自在组件内获取分享服务与提交状态;`message-list/index.vue` 仅准备记录 ID、保存已选 ID 并组合组件。

消息 Store 的 `loadMore()` 在新增历史记录时返回 `true`,空页、失败或未执行时返回 `false`,
直接传给滚动容器。新会话及重置时 `hasMore` 为 `false`;已有会话主动加载首页,成功后再更新
`hasMore`。首页失败后通过重新加载首页重试,不从下一页继续。
Chat、Portal 和 Display 面板直接放置消息列表,并以当前会话 ID 为 `key`,不再包裹额外滚动层。

### SharePanel(对话模块)

`conversation-panel/main/share-panel/index.vue` 展示只读问答,通过 `useSharePanelContentStore()`
读取所属视图提供的 `SHARE_PANEL_CONTENT_KEY`。正式对话视图接入当前会话数据,独立分享页
提供公开接口返回的标题、消息及加载状态,不加载原会话历史。
回答复制提取内容项正文,不传递内容数组给剪贴板。

## 文档维护

仅在目录、公开接口或使用规则变化时更新对应组件章节;不登记内部函数和修复过程。
Expand Down
31 changes: 17 additions & 14 deletions ui/src/conversation-panel/components/header/chat/index.vue
Original file line number Diff line number Diff line change
Expand Up @@ -30,22 +30,25 @@ const handleNewConversation = () => (props.newConversation ? props.newConversati
</MkTooltip>
</template>

<div class="flex-1 flex-align-center gap-2">
<div class="min-w-0 flex-1">
<h4 class="truncate">{{ list.currentConversation.value?.abstract || '新建对话' }}</h4>
</div>
<!-- 新建对话 -->
<MkTooltip content="新建对话">
<el-button text @click="handleNewConversation">
<MkIcon name="icon_new-chat_outlined" :size="18" class="text-N900!" />
</el-button>
</MkTooltip>
<!-- 进入分享模式,勾选要分享的对话 -->
<MkTooltip v-if="shareable" content="分享">
<el-button text :disabled="!canShare" @click="list.switchShared(true)">
<MkIcon name="icon_share_outlined" :size="18" :class="canShare ? 'text-N900!' : 'text-N400!'" />
</el-button>
</MkTooltip>
<slot />
<template v-if="!list.isShared.value">
<!-- 新建对话 -->
<MkTooltip content="新建对话">
<el-button text @click="handleNewConversation">
<MkIcon name="icon_new-chat_outlined" :size="18" class="text-N900!" />
</el-button>
</MkTooltip>
<!-- 进入分享模式,勾选要分享的对话 -->
<MkTooltip v-if="shareable" content="分享">
<el-button text :disabled="!canShare" @click="list.switchShared(true)">
<MkIcon name="icon_share_outlined" :size="18" :class="canShare ? 'text-N900!' : 'text-N400!'" />
</el-button>
</MkTooltip>

<slot />
</template>
</header>
</template>

Expand Down
36 changes: 23 additions & 13 deletions ui/src/conversation-panel/components/message-list/index.vue
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,19 @@ import PrologueContent from '../prologue-content/index.vue'
import { formatPrologue } from '../prologue-content/index'
import ReverseInfiniteScroll from '../../core/reverse-infinite-scroll/index.vue'
import { useMessageListStore } from './index'
import ShareMessageRow from './share/ShareMessageRow.vue'
import ShareFooter from './share/ShareFooter.vue'
import { useConversationListStore } from '../../left-sidebar/conversation-list/index'
defineProps<{ appName?: string }>()

const { appInfo, messages, loading, streamLoading, hasMore, loadMore, onStreamProgress } = useMessageListStore()
const {
// 是否分享
isShared,
// 切换分享
switchShared } = useConversationListStore()
const { isShared } = useConversationListStore()
// 分享按完整问答轮次提交,提问与回答共用记录 ID。
const selectedRecordIds = ref<string[]>([])
const messageRecordIds = computed(() =>
messages.value.map((message, index) => (message.role === 'ASSISTANT' ? message.recordId : messages.value[index + 1]?.recordId)),
)
const availableRecordIds = computed(() => [...new Set(messageRecordIds.value.filter((id): id is string => Boolean(id)))])
const scrollRef = ref<InstanceType<typeof ReverseInfiniteScroll>>()

// 开场白随智能体信息变化(debug 保存后为最新草稿)
Expand All @@ -30,25 +34,30 @@ const showUserAvatar = computed(() => appInfo.value?.show_user_avatar)
</script>

<template>
<ReverseInfiniteScroll ref="scrollRef" content-class="mk-conversation-content p-4" :loading="loading"
:has-more="hasMore" :count="messages.length" :load-more="loadMore">
<ReverseInfiniteScroll
ref="scrollRef"
:content-class="`mk-conversation-content p-4 ${isShared ? 'space-y-2' : ''}`"
:loading="loading"
:has-more="hasMore"
:count="messages.length"
:load-more="loadMore"
>
<template #prepend>
<!-- 开场白在空会话或历史全部加载后显示,固定在会话最前 -->
<div v-if="prologue && (!messages.length || !hasMore)">
<div v-if="!isShared && prologue && (!messages.length || !hasMore)">
<ApplicationIcon v-if="showAvatar" :icon="avatar" :size="24" class="shrink-0" />
<PrologueContent :prologue="prologue" :app-info="appInfo" class="mb-3" />
</div>
</template>
<template v-for="(msg, i) in messages" :key="msg.id || i">
<div>
<ShareMessageRow :active="isShared" v-model="selectedRecordIds" :record-id="messageRecordIds[i]">
<!-- 用户提问靠右:头像独占一行在上,提问容器在下方靠右收缩 -->
<div v-if="msg.role === 'USER'">
<div v-if="showUserAvatar" class="text-right">
<el-avatar v-if="userAvatar" class="bg-transparent!" shape="square" :size="24">
<img :src="userAvatar" alt="" />
</el-avatar>
<el-avatar v-else :size="24"
:style="{ background: appInfo?.custom_theme?.theme_color || 'var(--mk-primary-gradient)' }">
<el-avatar v-else :size="24" :style="{ background: appInfo?.custom_theme?.theme_color || 'var(--mk-primary-gradient)' }">
<img src="@/assets/mk_icon_user_gradient.svg" alt="" style="width: 50%" />
</el-avatar>
</div>
Expand All @@ -58,15 +67,16 @@ 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.write_ed || msg.record)" :message="msg" />
<MessageFooter class="mt-3" v-if="!isShared && msg.role === 'ASSISTANT' && (msg.write_ed || msg.record)" :message="msg" />
</template>
</div>
</ShareMessageRow>
</template>

<div v-if="streamLoading">
<span class="mk-conversation-loading" />
</div>
</ReverseInfiniteScroll>
<ShareFooter v-if="isShared" v-model="selectedRecordIds" :record-ids="availableRecordIds" />
</template>

<style scoped lang="scss"></style>
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
<script setup lang="ts">
import { computed, inject, onBeforeUnmount } from 'vue'
import { SHARE_PANEL_KEY } from '../../../main/share-panel/index'
import { useConversationListStore } from '../../../left-sidebar/conversation-list/index'

defineOptions({ name: 'ShareFooter' })

const props = defineProps<{ recordIds: string[] }>()
const selectedRecordIds = defineModel<string[]>({ required: true })
const sharePanel = inject(SHARE_PANEL_KEY, null)
const { switchShared } = useConversationListStore()

// 全选与数量仅统计当前列表中的可分享记录。
const selectedIds = computed(() => props.recordIds.filter((recordId) => selectedRecordIds.value.includes(recordId)))
const selectedCount = computed(() => selectedIds.value.length)
const totalCount = computed(() => props.recordIds.length)
const allSelected = computed(() => totalCount.value > 0 && selectedCount.value === totalCount.value)
const sharing = computed(() => Boolean(sharePanel?.sharing.value))
const selectAll = (checked: boolean | string | number) => {
if (sharing.value) return
selectedRecordIds.value = checked ? [...props.recordIds] : []
}

// 创建链接成功后退出,失败时保留选择供重试。
const cancelShare = () => {
switchShared(false)
sharePanel?.close()
}
const copyShareLink = () => {
if (!sharePanel || !selectedCount.value || sharing.value) return
void sharePanel
.share(selectedIds.value)
.then(cancelShare)
.catch(() => {})
}
onBeforeUnmount(() => {
selectedRecordIds.value = []
switchShared(false)
})
</script>

<template>
<footer class="shrink-0 border-t">
<div class="mk-conversation-content flex-between gap-3 p-4">
<!-- 全选已加载的可分享问答 -->
<el-checkbox
:model-value="allSelected"
:indeterminate="selectedCount > 0 && !allSelected"
:disabled="!totalCount || sharing"
@change="selectAll($event)"
>
已选 {{ selectedCount }}/{{ totalCount }}
</el-checkbox>
<div>
<!-- 退出分享选择 -->
<el-button plain :disabled="sharing" @click="cancelShare()">取消</el-button>
<!-- 创建并复制分享链接 -->
<el-button type="primary" :disabled="!selectedCount || !sharePanel" :loading="sharing" @click="copyShareLink()">复制链接</el-button>
</div>
</div>
</footer>
</template>
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
<script setup lang="ts">
import { computed, inject } from 'vue'
import { SHARE_PANEL_KEY } from '../../../main/share-panel/index'

defineOptions({ name: 'ShareMessageRow' })

const props = defineProps<{
active: boolean
recordId?: string
}>()
const selectedRecordIds = defineModel<string[]>({ required: true })
const sharePanel = inject(SHARE_PANEL_KEY, null)
const selected = computed(() => Boolean(props.recordId && selectedRecordIds.value.includes(props.recordId)))
const disabled = computed(() => !props.recordId || Boolean(sharePanel?.sharing.value))

// 提问和回答使用同一记录 ID,同步切换整轮选择。
const toggleRecord = () => {
if (!props.active || disabled.value || !props.recordId) return
selectedRecordIds.value = selected.value
? selectedRecordIds.value.filter((recordId) => recordId !== props.recordId)
: [...selectedRecordIds.value, props.recordId]
}
</script>

<template>
<div :class="{ 'flex items-center gap-3': active }">
<!-- 选择当前问答轮次 -->
<el-checkbox v-if="active" :model-value="selected" :disabled="disabled" class="mt-3 shrink-0" @change="toggleRecord()" />
<!-- 分享模式下点击消息区域切换选择 -->
<div
class="min-w-0 flex-1"
:class="active ? ['rounded-xl p-3', disabled ? 'cursor-default' : 'cursor-pointer hover:bg-N900/8', { 'bg-N900/8': selected }] : ''"
@click="toggleRecord()"
>
<div :class="{ 'pointer-events-none select-none': active }">
<slot />
</div>
</div>
</div>
</template>
5 changes: 1 addition & 4 deletions ui/src/conversation-panel/main/chat-panel/index.vue
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,6 @@ const list = useConversationListStore()
const input = useMessageInputStore()
// 拖拽到面板任意处 → 交给 message-input 接收文件
const handleDrop = (e: DragEvent) => input.addFiles(e.dataTransfer?.files)



</script>
<template>
<!-- 带输入框的主面板:header + message-list + message-input -->
Expand All @@ -27,6 +24,6 @@ const handleDrop = (e: DragEvent) => input.addFiles(e.dataTransfer?.files)
<slot name="header"><ChatHeader :shareable="shareable" @share="emit('share')" /></slot>
<MessageList :key="list.currentChatId.value" :app-name="list.appInfo.value?.name || 'AI 助手'"></MessageList>

<footer class="mk-conversation-content flex-center shrink-0 p-4"><MessageInput /></footer>
<footer v-if="!list.isShared.value" class="mk-conversation-content flex-center shrink-0 p-4"><MessageInput /></footer>
</main>
</template>
17 changes: 16 additions & 1 deletion ui/src/conversation-panel/main/share-panel/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { ref, inject, type InjectionKey } from 'vue'
import { ref, inject, type Ref, type InjectionKey } from 'vue'

import type { MessageListStore } from '../../components/message-list/index'

export interface SharePanelDeps {
/** 提交分享:参数为按消息顺序排列的已选 chat_record id */
Expand Down Expand Up @@ -55,3 +57,16 @@ export function useSharePanelStore(): SharePanelStore {
if (!store) throw new Error('useSharePanelStore 必须在提供 SHARE_PANEL_KEY 的视图内使用')
return store
}

/** 分享面板只读展示所需的数据,由所属视图注入。 */
export type SharePanelContentStore = Pick<MessageListStore, 'appInfo' | 'messages' | 'loading' | 'hasMore' | 'loadMore'> & {
title: Readonly<Ref<string>>
}

export const SHARE_PANEL_CONTENT_KEY: InjectionKey<SharePanelContentStore> = Symbol('share-panel-content')

export function useSharePanelContentStore(): SharePanelContentStore {
const store = inject(SHARE_PANEL_CONTENT_KEY)
if (!store) throw new Error('useSharePanelContentStore 必须在提供 SHARE_PANEL_CONTENT_KEY 的视图内使用')
return store
}
Loading
Loading