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
129 changes: 52 additions & 77 deletions apps/application/serializers/application_chat_link.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
"""
@project: MaxKB
@Author: niu
@file: application_chat_link.py
@date: 2026/2/9 10:50
@desc:
@project: MaxKB
@Author: niu
@file: application_chat_link.py
@date: 2026/2/9 10:50
@desc:
"""

import re

from django.utils.translation import gettext_lazy as _
Expand All @@ -18,42 +19,20 @@


class ShareChatRecordModelSerializer(serializers.ModelSerializer):
execution_details = serializers.SerializerMethodField()

class Meta:
model = ChatRecord
fields = ['id', 'problem_text', 'answer_text', 'answer_text_list',
'create_time', 'execution_details']

@staticmethod
def get_execution_details(chat_record):
details = chat_record.details or {}
# 分享是公开链接,只挑 start-node 的上传文件列表,避免泄露其它执行详情
return [
{
'type': 'start-node',
'image_list': v.get('image_list', []),
'document_list': v.get('document_list', []),
'audio_list': v.get('audio_list', []),
'video_list': v.get('video_list', []),
'other_list': v.get('other_list', []),
}
for v in details.values() if v.get('type') == 'start-node'
]
fields = ["id", "problem_text", "answer_text", "answer_text_list", "create_time", "question", "messages"]


class ChatRecordShareLinkRequestSerializer(serializers.Serializer):
chat_record_ids = serializers.ListSerializer(
child=serializers.UUIDField(),
required=False,
allow_empty=False,
label=_("Chat record IDs")
child=serializers.UUIDField(), required=False, allow_empty=False, label=_("Chat record IDs")
)
is_current_all = serializers.BooleanField(required=False, default=False)

def validate(self, attrs):
if not attrs.get('is_current_all') and not attrs.get('chat_record_ids'):
raise serializers.ValidationError(_('Chat record ids can not be empty'))
if not attrs.get("is_current_all") and not attrs.get("chat_record_ids"):
raise serializers.ValidationError(_("Chat record ids can not be empty"))
return attrs


Expand All @@ -64,8 +43,8 @@ def extract_oss_file_urls(answer_text_list):
if not isinstance(answer_group, list):
answer_group = [answer_group]
for item in answer_group:
content = item.get('content', '')
urls = re.findall(r'\./oss/file/[\w-]+', content)
content = item.get("content", "")
urls = re.findall(r"\./oss/file/[\w-]+", content)
file_urls.extend(urls)
return file_urls

Expand All @@ -76,27 +55,23 @@ def save_public_file_access(chat_record_list):
for chat_record in chat_record_list:
urls = extract_oss_file_urls(chat_record.answer_text_list)
for url in urls:
file_id = url.replace('./oss/file/', '')
file_id = url.replace("./oss/file/", "")
if file_id:
file_ids.add(file_id)

if not file_ids:
return

existing = set(
PublicFileAccess.objects.filter(
source_type='FILE',
source_id__in=list(file_ids)
).values_list('source_id', flat=True)
PublicFileAccess.objects.filter(source_type="FILE", source_id__in=list(file_ids)).values_list(
"source_id", flat=True
)
)

new_records = [
PublicFileAccess(
id=uuid.uuid7(),
source_type='FILE',
source_id=file_id
)
for file_id in file_ids if file_id not in existing
PublicFileAccess(id=uuid.uuid7(), source_type="FILE", source_id=file_id)
for file_id in file_ids
if file_id not in existing
]

if new_records:
Expand All @@ -110,50 +85,54 @@ class ChatRecordShareLinkSerializer(serializers.Serializer):

def is_valid(self, *, raise_exception=False):
super().is_valid(raise_exception=True)
chat_id = self.data.get('chat_id')
application_id = self.data.get('application_id')
user_id = self.data.get('user_id')
chat_id = self.data.get("chat_id")
application_id = self.data.get("application_id")
user_id = self.data.get("user_id")

chat_query_set = Chat.objects.filter(
id=chat_id, application_id=application_id, chat_user_id=user_id, is_deleted=False
)
if not chat_query_set.exists():
raise AppApiException(500, _('Chat id does not exist'))
raise AppApiException(500, _("Chat id does not exist"))

def generate_link(self, instance, with_valid=True):
if with_valid:
request_serializer = ChatRecordShareLinkRequestSerializer(data=instance)
request_serializer.is_valid(raise_exception=True)
self.is_valid(raise_exception=True)
if not instance.get('is_current_all', False):
chat_record_ids: list[str] = instance.get('chat_record_ids')
if not instance.get("is_current_all", False):
chat_record_ids: list[str] = instance.get("chat_record_ids")

record_count = ChatRecord.objects.filter(id__in=chat_record_ids,
chat_id=self.data.get('chat_id')).count()
record_count = ChatRecord.objects.filter(
id__in=chat_record_ids, chat_id=self.data.get("chat_id")
).count()
if record_count != len(chat_record_ids):
raise AppApiException(500, _('Invalid chat record ids'))
chat_id = self.data.get('chat_id')
application_id = self.data.get('application_id')
user_id = self.data.get('user_id')
raise AppApiException(500, _("Invalid chat record ids"))
chat_id = self.data.get("chat_id")
application_id = self.data.get("application_id")
user_id = self.data.get("user_id")

is_current_all = instance.get('is_current_all', False)
is_current_all = instance.get("is_current_all", False)
if is_current_all:
sorted_ids = list(
ChatRecord.objects.filter(chat_id=chat_id).order_by('create_time').values_list('id',flat=True)
ChatRecord.objects.filter(chat_id=chat_id).order_by("create_time").values_list("id", flat=True)
)
else:
chat_record_ids: list[str] = instance.get('chat_record_ids')
sorted_ids = list(ChatRecord.objects.filter(id__in=chat_record_ids).order_by('create_time').values_list('id',flat=True))
chat_record_ids: list[str] = instance.get("chat_record_ids")
sorted_ids = list(
ChatRecord.objects.filter(id__in=chat_record_ids).order_by("create_time").values_list("id", flat=True)
)

existing = ChatShareLink.objects.filter(
chat_id=chat_id, application_id=application_id,
chat_id=chat_id,
application_id=application_id,
share_type=ShareLinkType.PUBLIC,
user_id=user_id,
chat_record_ids=sorted_ids
chat_record_ids=sorted_ids,
).first()

if existing:
return {'link': UUIDEncoder.encode(existing.id)}
return {"link": UUIDEncoder.encode(existing.id)}
chat_record_list = ChatRecord.objects.filter(id__in=sorted_ids)
save_public_file_access(chat_record_list)
chat_share_link_model = ChatShareLink(
Expand All @@ -162,13 +141,13 @@ def generate_link(self, instance, with_valid=True):
application_id=application_id,
share_type=ShareLinkType.PUBLIC,
user_id=user_id,
chat_record_ids=sorted_ids
chat_record_ids=sorted_ids,
)
chat_share_link_model.save()

link = UUIDEncoder.encode(chat_share_link_model.id)

return {'link': link}
return {"link": link}


class ChatShareLinkDetailSerializer(serializers.Serializer):
Expand All @@ -177,29 +156,25 @@ class ChatShareLinkDetailSerializer(serializers.Serializer):
def is_valid(self, *, raise_exception=False):
super().is_valid(raise_exception=True)

link = self.data.get('link')
link = self.data.get("link")
share_link_id = UUIDEncoder.decode_to_str(link)

share_link_query_set = ChatShareLink.objects.filter(id=share_link_id).first()
if not share_link_query_set:
raise AppApiException(500, _('Share link does not exist'))
raise AppApiException(500, _("Share link does not exist"))
if share_link_query_set.chat.is_deleted:
raise AppApiException(500, _('Chat has been deleted'))
raise AppApiException(500, _("Chat has been deleted"))

return share_link_query_set

def get_record_list(self):
share_link_model = self.is_valid(raise_exception=True)

chat_record_model_list = ChatRecord.objects.filter(id__in=share_link_model.chat_record_ids,
chat_id=share_link_model.chat_id).order_by('create_time')
chat_record_model_list = ChatRecord.objects.filter(
id__in=share_link_model.chat_record_ids, chat_id=share_link_model.chat_id
).order_by("create_time")

abstract = Chat.objects.filter(
id=share_link_model.chat_id
).values_list('abstract', flat=True).first()
abstract = Chat.objects.filter(id=share_link_model.chat_id).values_list("abstract", flat=True).first()
chat_record_list = ShareChatRecordModelSerializer(chat_record_model_list, many=True).data

return {
'abstract': abstract,
'chat_record_list': chat_record_list
}
return {"abstract": abstract, "chat_record_list": chat_record_list}
6 changes: 3 additions & 3 deletions ui/src/api/API_README.md
Original file line number Diff line number Diff line change
Expand Up @@ -806,9 +806,9 @@ Workspace 和 System 资源管理的 `getKnowledgeDetail(knowledgeId, section?)`

`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`。
分享页适配旧版字段,不通过原会话接口补查内容。
`abstract` 和按时间正序排列的 `chat_record_list`。记录包含 V3 `question`、`messages`,
同时保留旧版 `problem_text`、`answer_text`、`answer_text_list`;分享页使用 `question`、`messages`,
不通过原会话接口补查内容。

### 对话回答反馈

Expand Down
9 changes: 6 additions & 3 deletions ui/src/api/types/chat-share.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,16 @@
import type { ChatContent } from './chat-log'
import type { Dict } from './common'

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

export interface ChatShareDetail {
Expand Down
30 changes: 22 additions & 8 deletions ui/src/components/COMPONENT_README.md
Original file line number Diff line number Diff line change
Expand Up @@ -969,10 +969,16 @@ Workspace API 内部使用 `getWorkspaceId()`,System 共享 API 不接收工
正式对话的 `message-list` 根据会话 Store 的 `isShared` 展示分享选择态,同轮提问与回答同步选择,
内容只读且选中时显示背景;无记录 ID 的问答不可选。列表底部提供当前已加载轮次的全选、已选数量、
取消及创建链接,创建时按消息顺序提交记录 ID;成功退出分享,失败保留选择。分享期间主面板隐藏输入框,
消息列表卸载时退出分享态;调试入口不提供分享服务。
分享组件放在 `message-list/share/`:`ShareMessageRow` 通过插槽承载消息,按记录 ID 切换整轮选择;
`ShareFooter` 负责全选、数量、取消、创建链接及退出清理。两者通过 `v-model` 共享已选记录 ID,
各自在组件内获取分享服务与提交状态;`message-list/index.vue` 仅准备记录 ID、保存已选 ID 并组合组件。
消息列表卸载时退出分享态。分享入口仅在 `SHAREABLE_VIEWS`(Chat、Portal)显示,调试、日志等视图不提供分享服务。
分享选择放在 `message-list/share/`:`index.ts` 的 `createShareLinkStore({ messages, share, setShared })` 维护
每条消息的记录 ID、已选记录、数量与全选状态、单条切换、全选、进入(`enterShare`)、提交(`copyShareLink`)
及退出(`exitShare`);提交成功后退出,失败保留选择。分享模式开关仍由会话 Store 的 `isShared` 维护,
Store 经 `setShared` 开关。所有分享入口调用 `enterShare(recordIds?)` 进入:提问与回答的入口传入所属轮次记录 ID
以预先勾选,标题栏、会话菜单与门户历史入口不传;传入的 ID 只保留当前可分享记录。`ShareMessageRow` 通过
`SHARE_RECORD_ID_KEY` 向消息内容提供所属轮次记录 ID,内容项以 `useShareRecordId()` 读取。
Chat、Portal 视图创建并 provide `SHARE_LINK_KEY`,组件通过 `useShareLinkStoreOptional()` 获取,未提供时不进入选择态。
`ShareMessageRow` 通过插槽承载消息,`ShareFooter` 展示全选、数量、取消与创建链接并在卸载时退出分享态;
两者只绑定 Store 状态与方法,不维护逻辑。`message-list/index.vue` 只组合组件并传入 Store 提供的记录 ID。

消息 Store 的 `loadMore()` 在新增历史记录时返回 `true`,空页、失败或未执行时返回 `false`,
直接传给滚动容器。新会话及重置时 `hasMore` 为 `false`;已有会话主动加载首页,成功后再更新
Expand All @@ -987,10 +993,18 @@ Chat、Portal 和 Display 面板直接放置消息列表,并以当前会话 ID

### SharePanel(对话模块)

`conversation-panel/main/share-panel/index.vue` 展示只读问答,通过 `useSharePanelContentStore()`
读取所属视图提供的 `SHARE_PANEL_CONTENT_KEY`。正式对话视图接入当前会话数据,独立分享页
提供公开接口返回的标题、消息及加载状态,不加载原会话历史。
回答复制提取内容项正文,不传递内容数组给剪贴板。
`conversation-panel/main/share-panel/index.vue` 是公开分享页的主面板,展示标题栏并组合
`ShareMessageList`。`conversation-panel/view/share/index.vue` 接收 `shareId`,以
`createSharePanelStore({ shareId })` 创建并 provide `SHARE_PANEL_KEY`,提供 `VIEW_TYPE.SHARE` 与设备类型,
在挂载及分享 ID 变化时调用 `load()`;面板把内部的消息列表 Store 提供给 `ShareMessageList`。
分享视图不注入发送能力,表单内容项按只读视图展示。路由页 `views/chat/share/` 只读取地址中的分享 ID 并渲染该视图。

### ShareMessageList(对话模块)

`conversation-panel/components/share-message-list/` 负责请求公开分享接口并只读展示问答。
`createShareMessageListStore({ shareId })` 提供标题、消息、加载及失败状态,按记录的 `question`、`messages`
转换消息,不加载原会话历史、不分页;切换分享 ID 后丢弃旧请求结果。组件通过 `SHARE_MESSAGE_LIST_KEY`
注入 Store,失败时在空状态提供重试;回答复制提取内容项正文,不传递内容数组给剪贴板。

## 文档维护

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ const submit = async () => {
_submit.value = false
}
}
const READONLY_VIEWS: readonly ViewType[] = [VIEW_TYPE.LOG]
const READONLY_VIEWS: readonly ViewType[] = [VIEW_TYPE.LOG, VIEW_TYPE.SHARE]

</script>

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,16 @@ import { copyText } from '@/utils/clipboard'

import { MESSAGE_LIST_KEY } from '../../../message-list/index'
import { CONVERSATION_LIST_KEY } from '../../../../left-sidebar/conversation-list/index'
import { useViewType } from '../../../../view/index'
import { SHAREABLE_VIEWS, useShareLinkStoreOptional, useShareRecordId } from '../../../message-list/share/index'

defineOptions({ name: 'ConversationQuestionContent' })

// 仅提供分享服务的视图(对话、门户)显示分享入口;进入分享时预先勾选所属轮次。
const showShare = SHAREABLE_VIEWS.includes(useViewType())
const shareLink = useShareLinkStoreOptional()
const shareRecordId = useShareRecordId()

// 复用对话的分享选择入口,回答生成中禁用。
const conversationList = inject(CONVERSATION_LIST_KEY, null)
const messageList = inject(MESSAGE_LIST_KEY, null)
Expand Down Expand Up @@ -143,8 +150,8 @@ function getAttachmentDescription(file: QuestionAttachment) {
<MkIcon name="icon_copy_outlined" />
</el-button>
<!-- 进入分享模式 -->
<MkTooltip content="分享">
<el-button text :disabled="!canShare" @click="conversationList?.switchShared(true)">
<MkTooltip v-if="showShare" content="分享">
<el-button text :disabled="!canShare" @click="shareLink?.enterShare([shareRecordId])">
<MkIcon name="icon_share_outlined" />
</el-button>
</MkTooltip>
Expand Down
5 changes: 4 additions & 1 deletion ui/src/conversation-panel/components/header/chat/index.vue
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import { computed } from 'vue'
import { useConversationListStore } from '../../../left-sidebar/conversation-list/index'
import { useMessageListStore } from '../../message-list/index'
import { useShareLinkStoreOptional } from '../../message-list/share/index'

const props = defineProps<{
/** 新建对话;不传时在当前应用中新建(门户在没有当前智能体时改为选择智能体) */
Expand All @@ -12,6 +13,8 @@ const props = defineProps<{
const list = useConversationListStore()

const msgs = useMessageListStore()
// 标题栏分享不针对某一轮,进入后不预先勾选
const shareLink = useShareLinkStoreOptional()
// 回答生成中或没有消息时不可分享
const canShare = computed(() => msgs.messages.value.length > 0 && !msgs.streamLoading.value)

Expand Down Expand Up @@ -42,7 +45,7 @@ const handleNewConversation = () => (props.newConversation ? props.newConversati
</MkTooltip>
<!-- 进入分享模式,勾选要分享的对话 -->
<MkTooltip v-if="shareable" content="分享">
<el-button text :disabled="!canShare" @click="list.switchShared(true)">
<el-button text :disabled="!canShare" @click="shareLink?.enterShare()">
<MkIcon name="icon_share_outlined" :size="18" :class="canShare ? 'text-N900!' : 'text-N400!'" />
</el-button>
</MkTooltip>
Expand Down
Loading
Loading