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: 1 addition & 1 deletion ui/src/assets/iconfont.js

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion ui/src/components/business/generate-content/index.vue
Original file line number Diff line number Diff line change
Expand Up @@ -161,7 +161,7 @@ onBeforeUnmount(stopGenerate)
@keydown.stop="handleKeydown"
@paste.stop
/>
<div class="text-right">
<div class="text-right mt-3">
<!-- 停止生成内容 -->
<el-button v-if="loading" circle type="primary" @click="stopGenerate">
<MkIcon name="icon_square_filled" />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -148,10 +148,7 @@ const uploadFile = async (file: DynamicFormValue, fileList: DynamicFormValue[])
:min-scale="0.2"
:preview-src-list="getAttrsArray(images, 'url')"
:initial-index="index"
alt=""
fit="cover"
style="width: 170px; height: 170px; display: block"
class="border-r-6"
/>
</div>
</template>
Expand Down
5 changes: 1 addition & 4 deletions ui/src/components/mk-edit-avatar/index.vue
Original file line number Diff line number Diff line change
Expand Up @@ -131,10 +131,7 @@ onBeforeUnmount(() => {
:show-file-list="false"
:on-change="selectImage"
>
<el-avatar v-if="previewIcon" :size="80" shape="square">
<img :src="previewIcon" class="object-cover" />
</el-avatar>

<el-image v-if="previewIcon" :src="previewIcon" fit="cover" class="h-20 w-20" />
<div v-else class="flex-center h-20 w-20 rounded-md border border-dashed bg-N200 hover:border-primary">
<MkIcon name="icon_add_outlined" :size="24" />
</div>
Expand Down
163 changes: 51 additions & 112 deletions ui/src/conversation-panel/components/message-input/index.vue
Original file line number Diff line number Diff line change
@@ -1,24 +1,23 @@
<script setup lang="ts">
import { ref } from 'vue'
import { CircleCloseFilled, Paperclip, Promotion, VideoPause } from '@element-plus/icons-vue'
import { Headset } from '@element-plus/icons-vue'
import { getFileExtension, getFileIconUrl } from '@/utils/icon'
import { formatFileSize } from '@/utils/number'
import { inputShortcut } from '../../core/shortcuts'
import { useMessageInputStore } from './index'

const {
question,
fileList,
showDelete,
maxFiles,
maxSizeMB,
acceptList,
loading,
placeholder,
canSend,
imageFiles,
documentFiles,
audioFiles,
videoFiles,
getFileIcon,
addFiles,
removeFile,
send,
Expand Down Expand Up @@ -50,91 +49,41 @@ const handleKeydown = (e: KeyboardEvent) => {
</script>

<template>
<div class="mk-input-box conversation-message-input w-full border shadow-lg transition-colors focus-within:border-primary">
<!-- 文件预览区域 -->
<div v-if="fileList.length" class="mb-3 space-y-2">
<!-- 图片预览 -->
<el-space v-if="imageFiles.length" wrap>
<template v-for="(file, index) in imageFiles" :key="file.uid">
<div class="relative" @mouseenter="showDelete = file.url || ''" @mouseleave="showDelete = ''">
<!-- 移除图片附件 -->
<button
v-if="showDelete === file.url"
type="button"
class="attachment-remove absolute -top-[5px] -right-[5px] z-1"
@click="removeFile(index)"
>
<MkIcon :icon="CircleCloseFilled" />
</button>
<el-image v-if="file.url" :src="file.url" fit="cover" class="block h-10 w-10 rounded-md" />
<el-image v-else-if="file.previewUrl" :src="file.previewUrl" fit="cover" class="block h-10 w-10 rounded-md" />
</div>
</template>
</el-space>

<!-- 文档预览 -->
<el-row v-if="documentFiles.length" :gutter="8" class="gap-y-2">
<template v-for="(file, index) in documentFiles" :key="file.uid">
<el-col :xs="24" :sm="12">
<el-card shadow="never" body-class="p-2!" class="cursor-pointer" @mouseenter="showDelete = file.url || ''" @mouseleave="showDelete = ''">
<div class="flex-between gap-2">
<div class="flex-align-center min-w-0 gap-1">
<img :src="getFileIcon(file.name)" alt="" class="w-6 shrink-0" />
<span class="truncate" :title="file.name">{{ file.name }}</span>
</div>
<!-- 移除文档附件 -->
<button v-if="showDelete === file.url" type="button" class="attachment-remove" @click="removeFile(index)">
<MkIcon :icon="CircleCloseFilled" />
</button>
<div class="mk-input-box mk-conversation-message-input w-full shadow-lg">
<!-- 文件预览区域:所有类型按上传顺序横向排列。 // TODO 未来会改成横向有左右按钮控制 -->
<el-scrollbar v-if="fileList.length" class="mb-3 h-auto!" view-class="flex-align-center gap-2">
<template v-for="(file, index) in fileList" :key="file.uid">
<div class="group relative shrink-0">
<el-image
v-if="imageFiles.includes(file) && (file.url || file.previewUrl)"
:src="file.url || file.previewUrl"
:alt="file.name"
:title="file.name"
fit="cover"
/>
<video v-else-if="videoFiles.includes(file) && file.url" :src="file.url" :title="file.name" controls autoplay class="object-cover" />
<el-card shadow="never" v-else class="small w-60 rounded-lg!">
<div class="flex-align-center gap-2">
<MkIcon v-if="audioFiles.includes(file)" :icon="Headset" :size="24" class="shrink-0 text-N600" />
<img v-else :src="getFileIconUrl(file.name)" alt="" class="w-8 shrink-0" />
<div class="min-w-0 flex-1">
<div class="truncate" :title="file.name">{{ file.name }}</div>
<div class="text-sm text-N500">{{ getFileExtension(file.name).toUpperCase() || '文件' }} – {{ formatFileSize(file.size) }}</div>
</div>
</el-card>
</el-col>
</template>
</el-row>

<!-- 音频预览 -->
<el-row v-if="audioFiles.length" :gutter="8" class="gap-y-2">
<template v-for="(file, index) in audioFiles" :key="file.uid">
<el-col :xs="24" :sm="12">
<el-card shadow="never" body-class="p-2!" class="cursor-pointer" @mouseenter="showDelete = file.url || ''" @mouseleave="showDelete = ''">
<div class="flex-between gap-2">
<div class="flex-align-center min-w-0 gap-1">
<img :src="getFileIcon(file.name)" alt="" class="w-6 shrink-0" />
<span class="truncate" :title="file.name">{{ file.name }}</span>
</div>
<!-- 移除音频附件 -->
<button v-if="showDelete === file.url" type="button" class="attachment-remove" @click="removeFile(index)">
<MkIcon :icon="CircleCloseFilled" />
</button>
</div>
</el-card>
</el-col>
</template>
</el-row>

<!-- 视频预览 -->
<el-space v-if="videoFiles.length" wrap>
<template v-for="(file, index) in videoFiles" :key="file.uid">
<div class="relative" @mouseenter="showDelete = file.url || ''" @mouseleave="showDelete = ''">
<!-- 移除视频附件 -->
<button
v-if="showDelete === file.url"
type="button"
class="attachment-remove absolute -top-[5px] -right-[5px] z-1"
@click="removeFile(index)"
>
<MkIcon :icon="CircleCloseFilled" />
</button>
<video v-if="file.url" :src="file.url" controls class="block w-25 rounded-md" autoplay />
</div>
</el-card>
<!-- 移除附件 -->
<div class="group-hover-visible attachment-remove" @click="removeFile(index)">
<MkIcon name="icon_close_bold_outlined" class="text-white!" :size="6" />
</div>
</template>
</el-space>
</div>
</div>
</template>
</el-scrollbar>

<!-- 输入框 -->
<el-input
v-model="question"
:autosize="{ minRows: 1, maxRows: 10 }"
:autosize="{ minRows: 1, maxRows: 7 }"
type="textarea"
resize="none"
:placeholder="placeholder"
Expand All @@ -145,45 +94,35 @@ const handleKeydown = (e: KeyboardEvent) => {
/>

<!-- 操作栏 -->
<div class="mt-2 flex-align-center justify-end">
<div class="text-right mt-3">
<input ref="fileInputRef" type="file" multiple :accept="acceptList" class="hidden" @change="handleFileSelect" />
<MkTooltip placement="top" popper-class="max-w-75">
<MkTooltip placement="top">
<template #content>
<div class="break-all whitespace-pre-wrap">支持上传图片、文档、音频、视频文件,最多{{ maxFiles }}个,单个文件最大{{ maxSizeMB }}MB</div>
<div class="break-all whitespace-pre-wrap">
可拖拽到输入框内上传文件
<br />
上传文件:最多{{ maxFiles }}个,每个文件限制{{ maxSizeMB }}MB
<br />
<!-- // TODO: 类型格式处理 -->
文件类型:{{ acceptList }}
</div>
</template>
<!-- 上传文件 -->
<el-button text :disabled="loading || fileList.length >= maxFiles" @click="fileInputRef?.click()">
<MkIcon :icon="Paperclip" :size="20" />
<MkIcon name="icon_attachment_outlined" :size="18" class="text-N900!" />
</el-button>
</MkTooltip>
<el-divider direction="vertical" />
<!-- 发送 -->
<el-button v-if="!loading" text :disabled="!canSend" @click="send">
<MkIcon :icon="Promotion" :size="20" />
</el-button>
<el-divider direction="vertical" class="ml-3! mr-4!" />
<!-- 停止回复 -->
<el-button v-else text @click="stop">
<MkIcon :icon="VideoPause" :size="20" />
<el-button v-if="loading" circle type="primary" @click="stop">
<MkIcon name="icon_square_filled" />
</el-button>
<!-- 发送 -->
<el-button v-else circle type="primary" :disabled="!canSend" @click="send()">
<MkIcon name="icon_arrow-up_outlined" />
</el-button>
</div>
</div>
</template>

<style scoped lang="scss">
/* 附件删除按钮,供图片、文档、音频和视频预览复用。 */
.attachment-remove {
align-items: center;
background: transparent;
border: 0;
color: var(--mk-N600);
cursor: pointer;
display: flex;
flex-shrink: 0;
justify-content: center;
padding: 0;

&:hover {
color: var(--mk-danger);
}
}
</style>
<style scoped lang="scss"></style>
45 changes: 22 additions & 23 deletions ui/src/conversation-panel/core/layout/index.vue
Original file line number Diff line number Diff line change
@@ -1,26 +1,3 @@
<template>
<!-- 布局壳(纯展示):左/主/右三区 + 挤压/抽屉 + 遮罩。状态由外层传入,自身不含业务。 -->
<div class="relative flex h-full overflow-hidden">
<aside class="side-panel left shrink-0 overflow-hidden bg-N100" :class="[leftMode, { open: leftOpen }]" :style="{ '--side-w': leftWidth + 'px' }">
<div class="side-panel__inner"><slot name="left" /></div>
</aside>

<div class="flex-column min-w-0 flex-1"><slot name="main" /></div>

<aside
v-if="hasRight"
class="side-panel right shrink-0 overflow-hidden bg-N100"
:class="[rightMode, { open: rightOpen }]"
:style="{ '--side-w': rightWidth + 'px' }"
>
<div class="side-panel__inner"><slot name="right" /></div>
</aside>

<!-- 点击遮罩收起抽屉侧栏 -->
<div v-if="showMask" class="absolute inset-0 z-30 bg-N900/40" @click="$emit('mask-click')" />
</div>
</template>

<script setup lang="ts">
import { computed, useSlots } from 'vue'
import type { SidePanelMode } from './types'
Expand Down Expand Up @@ -50,6 +27,28 @@ const hasRight = computed(() => !!slots.right)
const showMask = computed(() => (props.leftMode === 'drawer' && props.leftOpen) || (props.rightMode === 'drawer' && props.rightOpen))
</script>

<template>
<!-- 布局壳(纯展示):左/主/右三区 + 挤压/抽屉 + 遮罩。状态由外层传入,自身不含业务。 -->
<div class="relative flex h-full overflow-hidden">
<aside class="side-panel left shrink-0 overflow-hidden bg-N100" :class="[leftMode, { open: leftOpen }]" :style="{ '--side-w': leftWidth + 'px' }">
<div class="side-panel__inner"><slot name="left" /></div>
</aside>

<slot name="main" />

<aside
v-if="hasRight"
class="side-panel right shrink-0 overflow-hidden bg-N100"
:class="[rightMode, { open: rightOpen }]"
:style="{ '--side-w': rightWidth + 'px' }"
>
<div class="side-panel__inner"><slot name="right" /></div>
</aside>

<!-- 点击遮罩收起抽屉侧栏 -->
<div v-if="showMask" class="absolute inset-0 z-30 bg-N900/40" @click="$emit('mask-click')" />
</div>
</template>
<style scoped lang="scss">
.side-panel {
/* 固定侧栏内容宽度,避免展开和收起时内容变形。 */
Expand Down
37 changes: 34 additions & 3 deletions ui/src/conversation-panel/index.scss
Original file line number Diff line number Diff line change
@@ -1,10 +1,41 @@
/* 对话模块公共样式,由 view/chat 与 view/debug 的 scoped SCSS 通过 @use 引入。
* 公共类名使用 conversation-panel- 前缀;标题栏使用 mk-conversation-header。
* 样式受入口组件作用域限制,跨子组件内部的规则需使用 :deep()。
/* 对话模块公共样式,由 view/chat 与 view/debug 的非 scoped SCSS 通过 @use 引入。
* 组件专属布局和交互样式保留在所属组件的 scoped 样式中。
*/

/* 对话主面板:聊天与只读展示共用布局。 */
.mk-conversation-main {
background: var(--color-white);
display: flex;
flex: 1;
flex-direction: column;
overflow: hidden;
position: relative;
}

/* 对话标题栏:在中性底色上叠加浅主题色。 */
.mk-conversation-header {
background: color-mix(in srgb, var(--mk-primary) 5%, var(--mk-N100));
}

/* 对话输入区:边框颜色随内部焦点平滑切换。 */
.mk-conversation-message-input {
border: 1px solid rgb(var(--mk-N350-rgb) / 60%);
transition: border-color 150ms cubic-bezier(0.4, 0, 0.2, 1);

&:focus-within {
border-color: rgb(var(--mk-primary-rgb) / 60%);
}
/* 移除附件 */
.attachment-remove {
background: rgb(var(--mk-N900-rgb) / 60%);
border-radius: 9999px;
padding: var(--spacing);
position: absolute;
right: var(--spacing);
top: var(--spacing);
z-index: 1;
display: flex;
justify-content: center;
cursor: pointer;
}
}
41 changes: 9 additions & 32 deletions ui/src/conversation-panel/main/chat-panel/index.vue
Original file line number Diff line number Diff line change
@@ -1,13 +1,3 @@
<template>
<!-- 带输入框的主面板:header + message-list + message-input -->
<main class="main-panel" @drop.prevent="handleDrop" @dragover.prevent>
<!-- 头部可被替换(如 debug 用 debug header);默认普通 chat header -->
<slot name="header"><ChatHeader /></slot>
<MessageList :app-name="list.appInfo.value?.name || 'AI 助手'" />
<footer class="panel-input"><MessageInput /></footer>
</main>
</template>

<script setup lang="ts">
import ChatHeader from '../../components/header/chat/index.vue'
import MessageList from '../../components/message-list/index.vue'
Expand All @@ -21,25 +11,12 @@ const input = useMessageInputStore()
// 拖拽到面板任意处 → 交给 message-input 接收文件
const handleDrop = (e: DragEvent) => input.addFiles(e.dataTransfer?.files)
</script>

<style scoped lang="scss">
.main-panel {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
height: 100%;
max-height: 100%;
background: var(--bg, #fff);
overflow: hidden;
position: relative;
}
.panel-input {
flex-shrink: 0;
padding: 12px 16px 16px;
background: var(--bg, #fff);
display: flex;
flex-direction: column;
align-items: center;
}
</style>
<template>
<!-- 带输入框的主面板:header + message-list + message-input -->
<main class="mk-conversation-main" @drop.prevent="handleDrop" @dragover.prevent>
<!-- 头部可被替换(如 debug 用 debug header);默认普通 chat header -->
<slot name="header"><ChatHeader /></slot>
<MessageList :app-name="list.appInfo.value?.name || 'AI 助手'" />
<footer class="flex-center shrink-0 p-4"><MessageInput /></footer>
</main>
</template>
Loading
Loading