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
2 changes: 2 additions & 0 deletions ui/src/api/API_README.md
Original file line number Diff line number Diff line change
Expand Up @@ -500,6 +500,8 @@ Admin 上传尚未创建的资源文件时,`sourceId` 可传 `undefined`,省
资源类型使用 `@/api/enums` 的 `FILE_SOURCE_TYPE` 与 `@/api/types` 的 `FileSourceType`,
包括知识库、智能体、工具、文档、对话及三种临时文件有效期。对话 Store 使用
`FILE_SOURCE_TYPE.CHAT` 调用对应 File API,对话 API 不再维护上传接口。
chat、debug、portal 视图还通过 `upload` 注入为动态表单提供上传能力,使用当前会话 ID 与
`FILE_SOURCE_TYPE.CHAT`,返回带 `abort` 的 Promise,响应适配为 `{ data: url }`。
知识库工作流调试上传复用 Admin `FileApi.postUploadFile`,传知识库 ID 与 `FILE_SOURCE_TYPE.KNOWLEDGE`,
通过返回的 `request` 获取文件地址,再适配动态表单的 `{ data: url }` 返回协议;工作流 API 不重复封装上传。

Expand Down
5 changes: 5 additions & 0 deletions ui/src/components/COMPONENT_README.md
Original file line number Diff line number Diff line change
Expand Up @@ -671,6 +671,11 @@ JSON 专用输入框,通过 `v-model` 接收并回传解析后的 JSON 值,
已导入节点自动选中且不可取消,`disabled` 节点不可选择。懒加载先查询根目录,展开时通过
原有工具数据源接口提交 `current_node` 查询子节点;非懒加载使用 `option_list`。

动态表单 `UploadInput` 按附件原顺序使用 Flex 换行展示,不按类型分组。
文件及音视频使用图标和文件名紧凑卡片,文件链接在新标签页打开或下载。
图片使用 `ImageUploadCard`,音视频通过 `MkAudio`、`MkVideo` 打开预览。`inputDisabled` 为真时隐藏
上传和删除入口,保留下载、图片预览和音视频播放;可编辑时允许上传和删除附件。

动态表单 `LocalFileUpload` 字段复用 `MkDragUpload` 的多文件模式,将
`file_type_list`、`file_size_limit`、`file_count_limit` 映射为格式、大小和数量限制。
上传适配兼容上层返回的 Promise 或 `{ request, abort }`,响应支持地址字符串或 `{ data: 地址 }`;
Expand Down
225 changes: 58 additions & 167 deletions ui/src/components/mk-dynamics-form/items/upload/UploadInput.vue
Original file line number Diff line number Diff line change
Expand Up @@ -3,22 +3,22 @@ import type { DynamicFormValue } from '../../type'
import { computed, inject, ref, useAttrs } from 'vue'
import type { FormField } from '@/components/mk-dynamics-form/type'
import { MsgWarning } from '@/utils/message'
import { downloadByURL, getAttrsArray, getFileUrl } from '@/utils/common'
import { getFileExtension, getFileIconUrl } from '@/utils/icon'
import { formatFileSize } from '@/utils/number'

import { getFileUrl } from '@/utils/common'
import { getFileIconUrl, isImage, isAudio, isVideo } from '@/utils/icon'
import { useFormDisabled } from 'element-plus'

import ImageUploadCard from '@/components/mk-drag-upload/ImageUploadCard.vue'
import MkAudio from '@/components/global/mk-media-preview/MkAudio.vue'
import MkVideo from '@/components/global/mk-media-preview/MkVideo.vue'
const inputDisabled = useFormDisabled()
const attrs = useAttrs() as DynamicFormValue
const upload = inject('upload') as DynamicFormValue
const props = withDefaults(defineProps<{ modelValue?: DynamicFormValue; formField: FormField }>(), { modelValue: () => [] })
const emit = defineEmits(['update:modelValue'])

const deleteFile = (file: DynamicFormValue) => {
if (inputDisabled.value) {
return
}
fileArray.value = fileArray.value.filter((f: DynamicFormValue) => f.uid !== file.uid)
if (inputDisabled.value) return
fileArray.value = modelValueProxy.value.filter((attachment: DynamicFormValue) => attachment.file_id !== file.file_id)
emit('update:modelValue', fileArray.value)
}

Expand All @@ -35,23 +35,18 @@ const modelValueProxy = computed({
})
const fileArray = ref<DynamicFormValue>([])

const imageExtensions = ['JPG', 'JPEG', 'PNG', 'GIF', 'BMP']
const videoExtensions = ['MP4', 'AVI', 'MKV', 'MOV', 'FLV', 'WMV']
const audioExtensions = ['MP3', 'WAV', 'OGG', 'AAC', 'M4A']
const ofType = (exts: string[]) => (f: DynamicFormValue) => exts.includes(getFileExtension(f?.name || '').toUpperCase())

const filesWithUrl = computed(() => (modelValueProxy.value || []).map((f: DynamicFormValue) => ({ ...f, url: f.url || getFileUrl(f.file_id) })))
const images = computed(() => filesWithUrl.value.filter(ofType(imageExtensions)))
const audioFiles = computed(() => filesWithUrl.value.filter(ofType(audioExtensions)))
const videoFiles = computed(() => filesWithUrl.value.filter(ofType(videoExtensions)))
// 非图片/音频/视频的(文档、压缩包等)统一走下载卡片
const downloadFiles = computed(() =>
filesWithUrl.value.filter((f: DynamicFormValue) => !ofType([...imageExtensions, ...audioExtensions, ...videoExtensions])(f)),
// 按原顺序展示附件,类型仅决定预览方式。
const attachments = computed(() =>
(modelValueProxy.value || []).map((file: DynamicFormValue) => ({
...file,
status: 'success' as const,
url: file.url || getFileUrl(file.file_id),
kind: isImage(file.name) ? 'image' : isAudio(file.name) ? 'audio' : isVideo(file.name) ? 'video' : 'file',
})),
)
const imageSources = computed(() =>
attachments.value.filter((file: DynamicFormValue) => file.kind === 'image').map((file: DynamicFormValue) => file.url),
)

function downloadFile(item: DynamicFormValue) {
downloadByURL(item.url, item.name)
}

const loading = ref<boolean>(false)

Expand Down Expand Up @@ -82,8 +77,9 @@ const uploadFile = async (file: DynamicFormValue, fileList: DynamicFormValue[])
</script>

<template>
<!-- 上传表单附件 -->
<el-upload
style="width: 100%"
v-if="!inputDisabled"
v-loading="loading"
action="#"
v-bind="$attrs"
Expand All @@ -93,151 +89,46 @@ const uploadFile = async (file: DynamicFormValue, fileList: DynamicFormValue[])
multiple
:show-file-list="false"
>
<el-button type="primary">上传文件</el-button>
<el-button plain>
<MkIcon name="icon_upload_outlined" />
<span>上传文件</span>
</el-button>
</el-upload>
<el-space wrap class="w-full media-file-width upload_content mt-16" v-if="!inputDisabled">
<template v-for="(file, index) in modelValueProxy" :key="index">
<el-card style="--el-card-padding: 0" shadow="never">
<div class="flex-between" :class="[inputDisabled ? 'is-disabled' : '']" style="padding: 0 8px 0 8px">
<div class="flex align-center" style="width: 70%">
<img :src="getFileIconUrl(file?.name || '')" alt="" width="24" class="mr-4" />
<span :title="file.name">
{{ file.name }}
</span>
</div>
<div class="flex align-center">
<div :title="formatFileSize(file.size)">{{ formatFileSize(file.size) }}</div>

<el-button link class="ml-8" @click="deleteFile(file)" v-if="!inputDisabled">
<MkIcon name="icon_delete-trash_outlined"></MkIcon>
<div v-if="attachments.length" class="mt-2 flex flex-wrap w-full gap-2">
<div v-for="file in attachments" :key="file.file_id" class="min-w-0" :class="file.kind === 'image' ? 'shrink-0' : 'w-full'">
<!-- 查看图片,可编辑时允许删除 -->
<ImageUploadCard
v-if="file.kind === 'image'"
:file="file"
:src="file.url"
:size="104"
:show-remove="!inputDisabled"
:preview-src-list="imageSources"
:initial-index="imageSources.indexOf(file.url)"
@remove="deleteFile(file)"
/>
<!-- 播放音视频,或在新标签页打开及下载文件 -->
<component
:is="file.kind === 'audio' ? MkAudio : file.kind === 'video' ? MkVideo : 'a'"
v-else
v-bind="
file.kind === 'audio' || file.kind === 'video'
? { src: file.url, name: file.name }
: { href: file.url || undefined, target: '_blank', rel: 'noopener noreferrer', download: file.name }
"
class="block min-w-0"
>
<el-card shadow="never" class="small cursor-pointer">
<div class="flex-align-center gap-2">
<img :src="getFileIconUrl(file.name)" alt="" width="24" class="shrink-0" />
<span class="min-w-0 flex-1 truncate" :title="file.name">{{ file.name }}</span>
<!-- 删除附件,不触发文件打开或媒体播放 -->
<el-button v-if="!inputDisabled" text class="shrink-0" @click.stop.prevent="deleteFile(file)">
<MkIcon name="icon_delete-trash_outlined" />
</el-button>
</div>
</div>
</el-card>
</template>
</el-space>
<div class="mt-8 w-full" v-else>
<div class="mb-8" v-if="downloadFiles.length">
<el-space wrap class="w-full media-file-width upload_content">
<template v-for="(item, index) in downloadFiles" :key="index">
<el-card shadow="never" style="--el-card-padding: 8px" class="download-file cursor">
<div class="download-button flex align-center" @click="downloadFile(item)">
<el-icon class="mr-4">
<Download />
</el-icon>
下载
</div>
<div class="show flex align-center">
<img :src="getFileIconUrl(item?.name || '')" alt="" width="24" />
<div class="ml-4" :title="item && item?.name">
{{ item && item?.name }}
</div>
</div>
</el-card>
</template>
</el-space>
</div>
<div class="mb-8" v-if="images.length">
<el-space wrap>
<template v-for="(item, index) in images" :key="index">
<div class="file cursor border-r-6" v-if="item.url">
<el-image
class="size-8"
:src="item.url"
:preview-src-list="getAttrsArray(images, 'url')"
:scale="0.8"
:initial-index="index"
fit="cover"
/>
</div>
</template>
</el-space>
</div>
<div class="mb-8" v-if="audioFiles.length">
<el-space wrap>
<template v-for="(item, index) in audioFiles" :key="index">
<div class="file cursor border-r-6" v-if="item.url">
<audio :src="item.url" controls style="width: 350px; height: 43px" class="border-r-6" />
</div>
</template>
</el-space>
</div>
<div class="mb-8" v-if="videoFiles.length">
<el-space wrap>
<template v-for="(item, index) in videoFiles" :key="index">
<div class="file cursor border-r-6" v-if="item.url">
<video :src="item.url" style="width: 170px; display: block" class="border-r-6" controls />
</div>
</template>
</el-space>
</el-card>
</component>
</div>
</div>
</template>
<style lang="scss" scoped>
/* hover 显示下载按钮,样式照抄 question-content/index.vue */
.download-file {
height: 43px;

&:hover {
color: var(--el-color-primary);
border: 1px solid var(--el-color-primary);

.download-button {
display: block;
text-align: center;
line-height: 26px;
}

.show {
display: none;
}
}

.download-button {
display: none;
}
}

.upload_content {
.is-disabled {
background-color: var(--el-fill-color-light);
color: var(--el-text-color-placeholder);
cursor: not-allowed;
&:hover {
cursor: not-allowed;
}
}
&.media-file-width {
:deep(.el-space__item) {
width: calc(50% - 4px) !important;
}
}
}
@media only screen and (max-width: 768px) {
.upload_content {
&.media-file-width {
:deep(.el-space__item) {
min-width: 100% !important;
}
}
}
}
.debug-ai-chat {
.upload_content {
&.media-file-width {
:deep(.el-space__item) {
min-width: 100% !important;
}
}
}
}
.execution-details {
.upload_content {
&.media-file-width {
:deep(.el-space__item) {
min-width: 100% !important;
}
}
}
}
</style>
4 changes: 2 additions & 2 deletions ui/src/conversation-panel/components/content/items/form.vue
Original file line number Diff line number Diff line change
Expand Up @@ -59,8 +59,8 @@ const submit = async () => {
</script>

<template>
<div class="content-form">
<MdPreview class="!h-auto" editorId="conversation-text" :modelValue="leadingText || ''"></MdPreview>
<MdPreview class="!h-auto" editorId="conversation-text" :modelValue="leadingText || ''"></MdPreview>
<div class="mk-gray-card-lg space-y-4 border border-N300">
<MkDynamicsForm ref="dynamicsFormRef" :view="is_submit || !mainStore" :render-data="form_field_list" v-model="form_data" />
<MdPreview class="!h-auto" editorId="conversation-text" :modelValue="trailingText || ''"></MdPreview>
<el-button :type="is_submit ? 'info' : 'primary'" :disabled="is_submit || !mainStore" @click="submit">
Expand Down
4 changes: 2 additions & 2 deletions ui/src/conversation-panel/core/types.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { CHAT_TYPE } from './enums'
import type { ChatApplicationProfile, ChatContent, ChatLogRecord, Dict } from '@/api/types'
import type { ChatApplicationProfile, ChatContent, ChatLogRecord, ChatUserProfile, Dict } from '@/api/types'

/**
* 当前对话的智能体信息,由 view 加载并注入各组件 store,chat 与 debug 结构一致。
Expand All @@ -11,7 +11,7 @@ export type ConversationAppInfo = ChatApplicationProfile
export interface ConversationAccount {
nickName?: string
username?: string
token_quota?: any
token_quota?: ChatUserProfile['token_quota']
}

export interface Conversation {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,9 @@ const displayName = computed(() => account.value?.nickName || account.value?.use

// 配额尚未接入时保留空值展示,不将缺失数据表示为零用量。
const tokenPercentage = computed(() => {
return Math.min(100, Math.max(0, (account.value?.token_quota?.used_tokens / account.value?.token_quota?.total_tokens) * 100))
const quota = account.value?.token_quota
if (!quota) return 0
return Math.min(100, Math.max(0, (quota.used_tokens / quota.total_tokens) * 100))
})

const userPwdDialogRef = useTemplateRef<InstanceType<typeof UserPwdDialog>>('userPwdDialogRef')
Expand Down
17 changes: 17 additions & 0 deletions ui/src/conversation-panel/view/chat/index.vue
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
<script setup lang="ts">
import { ref, computed, watch, onMounted, onBeforeUnmount, provide } from 'vue'
import type { AxiosProgressEvent } from 'axios'
import FileApi from '@/api/chat/file'
import { FILE_SOURCE_TYPE } from '@/api/enums'
import ConversationLayout from '../../core/layout/index.vue'
import ConversationList from '../../left-sidebar/conversation-list/index.vue'
import ChatPanel from '../../main/chat-panel/index.vue'
Expand All @@ -14,6 +17,20 @@ import { createChatConversation } from './index'

const bundle = createChatConversation()

// 动态表单上传使用当前会话,兼容文件字段的地址包装与取消操作。
provide('upload', (file: File, onProgress?: (percent: number, event: AxiosProgressEvent) => void) => {
const upload = FileApi.postUploadFile(
file,
bundle.list.currentChatId.value,
FILE_SOURCE_TYPE.CHAT,
typeof onProgress === 'function' ? onProgress : undefined,
)
return Object.assign(
upload.request.then((url) => ({ data: url })),
{ abort: upload.abort },
)
})

// 各组件 store 由本视图 provide(视图是左/主/右的共同祖先)
provide(CONVERSATION_LIST_KEY, bundle.list)
provide(MESSAGE_LIST_KEY, bundle.msgs)
Expand Down
17 changes: 17 additions & 0 deletions ui/src/conversation-panel/view/debug/index.vue
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
<script setup lang="ts">
import { computed, onMounted, onBeforeUnmount, provide, toRef, watch } from 'vue'
import type { ApplicationDetail } from '@/api/types'
import type { AxiosProgressEvent } from 'axios'
import FileApi from '@/api/admin/file'
import { FILE_SOURCE_TYPE } from '@/api/enums'
import ConversationLayout from '../../core/layout/index.vue'
import ConversationList from '../../left-sidebar/conversation-list/index.vue'
import ChatPanel from '../../main/chat-panel/index.vue'
Expand All @@ -22,6 +25,20 @@ const props = defineProps<{

const bundle = createDebugConversation(toRef(props, 'application'))

// 动态表单上传使用当前会话,兼容文件字段的地址包装与取消操作。
provide('upload', (file: File, onProgress?: (percent: number, event: AxiosProgressEvent) => void) => {
const upload = FileApi.postUploadFile(
file,
bundle.list.currentChatId.value,
FILE_SOURCE_TYPE.CHAT,
typeof onProgress === 'function' ? onProgress : undefined,
)
return Object.assign(
upload.request.then((url) => ({ data: url })),
{ abort: upload.abort },
)
})

// 会话各组件 store + debug 面板状态,都由本视图 provide
provide(CONVERSATION_LIST_KEY, bundle.list)
provide(MESSAGE_LIST_KEY, bundle.msgs)
Expand Down
Loading
Loading