|
@@ -0,0 +1,2530 @@
|
|
|
+<script setup lang="ts">
|
|
|
+import { computed, isReactive, nextTick, onMounted, onUnmounted, reactive, ref, watch } from 'vue'
|
|
|
+import { useI18n } from 'vue-i18n'
|
|
|
+import { Local } from '/@/utils/storage'
|
|
|
+import {
|
|
|
+ ArrowDown,
|
|
|
+ ChatDotRound,
|
|
|
+ Check,
|
|
|
+ Close,
|
|
|
+ CopyDocument,
|
|
|
+ Delete,
|
|
|
+ Document,
|
|
|
+ Download,
|
|
|
+ Edit,
|
|
|
+ Files,
|
|
|
+ Headset,
|
|
|
+ Loading,
|
|
|
+ MoreFilled,
|
|
|
+ Picture,
|
|
|
+ Plus,
|
|
|
+ Promotion,
|
|
|
+ Search,
|
|
|
+ Setting,
|
|
|
+ Setting as EleSetting,
|
|
|
+ Star,
|
|
|
+ StarFilled,
|
|
|
+ User,
|
|
|
+ VideoPause,
|
|
|
+ VideoPlay,
|
|
|
+} from '@element-plus/icons-vue'
|
|
|
+import { MarkdownPlugin } from '/@/components/markdown/type/markdown'
|
|
|
+import EChartsPlugin from '/@/components/markdown/plugins/echarts'
|
|
|
+import ToolsLoadingPlugin from '/@/components/markdown/plugins/tools-loading'
|
|
|
+import TablePlugin from '/@/components/markdown/plugins/table'
|
|
|
+import Markdown from '/@/components/markdown/Markdown.vue'
|
|
|
+import assist from '/@/api/assist'
|
|
|
+import { ChatResponse, LmConfigInfo, LmSession, Message, Prompt } from '/@/api/assist/type'
|
|
|
+import { useLoading } from '/@/utils/loading-util'
|
|
|
+import { useRouter } from 'vue-router'
|
|
|
+import { ElMessage, ElMessageBox } from 'element-plus'
|
|
|
+import StructDataPlugin from '/@/components/markdown/plugins/struct-data'
|
|
|
+import { useDropZone, useFileDialog } from '@vueuse/core'
|
|
|
+import download from 'downloadjs'
|
|
|
+import common from '/@/api/common'
|
|
|
+import { UploadFile } from '/@/api/common/type'
|
|
|
+import system from '/@/api/system'
|
|
|
+
|
|
|
+const plugins: Array<MarkdownPlugin<any>> = [EChartsPlugin(), ToolsLoadingPlugin(), TablePlugin(), StructDataPlugin()]
|
|
|
+
|
|
|
+// 国际化
|
|
|
+const { t } = useI18n()
|
|
|
+
|
|
|
+//聊天管理接口
|
|
|
+// 消息列表
|
|
|
+const messages = ref<Message[]>([])
|
|
|
+const messagesContainer = ref<HTMLElement>()
|
|
|
+
|
|
|
+// 输入框内容
|
|
|
+const inputMessage = ref('')
|
|
|
+const {
|
|
|
+ open,
|
|
|
+ reset,
|
|
|
+ files: attachments,
|
|
|
+} = useFileDialog({
|
|
|
+ multiple: true,
|
|
|
+})
|
|
|
+
|
|
|
+// 附件管理:使用本地列表以支持单个移除
|
|
|
+const dropToUploadZone = ref<HTMLElement>()
|
|
|
+
|
|
|
+const { isOverDropZone } = useDropZone(dropToUploadZone, async (files: File[] | null) => {
|
|
|
+ if (loadingUpload.value) {
|
|
|
+ ElMessage.warning(t('message.assistant.status.uploading'))
|
|
|
+ return
|
|
|
+ }
|
|
|
+ if (files === null || files.length === 0) return
|
|
|
+ const data = await doUpload(files).catch(() => undefined)
|
|
|
+ if (data === undefined) {
|
|
|
+ return
|
|
|
+ }
|
|
|
+ selectedFiles.value = [...selectedFiles.value, ...data]
|
|
|
+})
|
|
|
+
|
|
|
+const selectedFiles = ref<UploadFile[]>([])
|
|
|
+
|
|
|
+const uploadProgress = ref<number>(-1)
|
|
|
+const { loading: loadingUpload, doLoading: doUpload } = useLoading(async (file: File[]) => {
|
|
|
+ uploadProgress.value = -1
|
|
|
+
|
|
|
+ return (await common.upload
|
|
|
+ .multi(file, (progress) => (uploadProgress.value = progress * 100))
|
|
|
+ .finally(() => (uploadProgress.value = -1))) as UploadFile[]
|
|
|
+})
|
|
|
+
|
|
|
+watch(
|
|
|
+ attachments,
|
|
|
+ async (newFiles) => {
|
|
|
+ if (newFiles === null || newFiles.length === 0) return
|
|
|
+
|
|
|
+ const data = await doUpload(Array.from(newFiles)).catch(() => undefined)
|
|
|
+ if (data === undefined) {
|
|
|
+ reset()
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ selectedFiles.value = [...selectedFiles.value, ...data]
|
|
|
+ reset()
|
|
|
+ },
|
|
|
+ { immediate: true }
|
|
|
+)
|
|
|
+
|
|
|
+const removeAttachment = (index: number) => {
|
|
|
+ if (index < 0 || index >= selectedFiles.value.length) return
|
|
|
+ selectedFiles.value.splice(index, 1)
|
|
|
+}
|
|
|
+
|
|
|
+// // 选中的工具和模型
|
|
|
+// const selectedTool = ref([])
|
|
|
+// // 工具选择
|
|
|
+// const toolOptions = ref([
|
|
|
+// {
|
|
|
+// value: 'code',
|
|
|
+// label: '代码工具',
|
|
|
+// children: [
|
|
|
+// {
|
|
|
+// value: 'codebase-retrieval',
|
|
|
+// label: '代码库检索',
|
|
|
+// },
|
|
|
+// {
|
|
|
+// value: 'str-replace-editor',
|
|
|
+// label: '代码编辑器',
|
|
|
+// },
|
|
|
+// {
|
|
|
+// value: 'save-file',
|
|
|
+// label: '文件保存',
|
|
|
+// },
|
|
|
+// ],
|
|
|
+// },
|
|
|
+// {
|
|
|
+// value: 'web',
|
|
|
+// label: '网络工具',
|
|
|
+// children: [
|
|
|
+// {
|
|
|
+// value: 'web-search',
|
|
|
+// label: '网络搜索',
|
|
|
+// },
|
|
|
+// {
|
|
|
+// value: 'web-fetch',
|
|
|
+// label: '网页获取',
|
|
|
+// },
|
|
|
+// ],
|
|
|
+// },
|
|
|
+// {
|
|
|
+// value: 'system',
|
|
|
+// label: '系统工具',
|
|
|
+// children: [
|
|
|
+// {
|
|
|
+// value: 'execute-command',
|
|
|
+// label: '命令执行',
|
|
|
+// },
|
|
|
+// {
|
|
|
+// value: 'launch-process',
|
|
|
+// label: '进程启动',
|
|
|
+// },
|
|
|
+// ],
|
|
|
+// },
|
|
|
+// ])
|
|
|
+
|
|
|
+// 模型选择
|
|
|
+const modelOptions = ref<LmConfigInfo[]>([])
|
|
|
+const modelLabel = computed(() => {
|
|
|
+ if (!loadingModels.value && selectedModel.value === undefined) {
|
|
|
+ return t('message.assistant.status.noModelConfigured')
|
|
|
+ }
|
|
|
+ const select = modelOptions.value.filter((i) => i.id === selectedModel.value)
|
|
|
+
|
|
|
+ if (select.length === 0) {
|
|
|
+ return t('message.assistant.status.loading')
|
|
|
+ }
|
|
|
+
|
|
|
+ return select[0].modelName
|
|
|
+})
|
|
|
+
|
|
|
+const { loading: loadingModels, doLoading: loadModel } = useLoading(async () => {
|
|
|
+ const data: { list: LmConfigInfo[]; total: number } = await assist.model.getList({ modelType: 'chat' }).catch(() => {
|
|
|
+ return {
|
|
|
+ list: [],
|
|
|
+ }
|
|
|
+ })
|
|
|
+
|
|
|
+ modelOptions.value = data.list ?? []
|
|
|
+ selectedModel.value = modelOptions.value[0]?.id ?? undefined
|
|
|
+})
|
|
|
+onMounted(loadModel)
|
|
|
+
|
|
|
+const selectEmbeddingId = ref<number | undefined>(undefined)
|
|
|
+const { loading: loadingEmbedding, doLoading: loadEmbedding } = useLoading(async () => {
|
|
|
+ const embedding_key = await system.getInfoByKey('assistant.embedding.default').then((res: any) => res.data.configValue)
|
|
|
+ const embedding_model = await assist.model.getList({ modelType: 'embedding',keyWord: embedding_key }) as LmConfigInfo[]
|
|
|
+ selectEmbeddingId.value = embedding_model[0]?.id
|
|
|
+})
|
|
|
+
|
|
|
+onMounted(loadEmbedding)
|
|
|
+
|
|
|
+// 提示词列表相关状态
|
|
|
+const customPrompt = ref('')
|
|
|
+const promptList = ref<Prompt[]>([])
|
|
|
+
|
|
|
+const displayPromptList = computed(() => {
|
|
|
+ //有提示词展示自定义提示词
|
|
|
+ let r = [...promptList.value]
|
|
|
+ if (customPrompt.value.trim() !== '') {
|
|
|
+ r.splice(1, 0, {
|
|
|
+ id: -2,
|
|
|
+ title: t('message.assistant.prompt.customPromptWithCount', { count: customPrompt.value.length }),
|
|
|
+ prompt: customPrompt.value,
|
|
|
+ })
|
|
|
+ }
|
|
|
+ return r
|
|
|
+})
|
|
|
+const selectPromptId = ref<number | undefined>(undefined)
|
|
|
+watch(selectPromptId, (newVal) => {
|
|
|
+ const placeholder = displayPromptList.value.find((i) => i.id === newVal)?.placeholder
|
|
|
+ if (placeholder === undefined) {
|
|
|
+ return
|
|
|
+ }
|
|
|
+ messages.value.push({
|
|
|
+ id: messages.value.length,
|
|
|
+ render_content: placeholder,
|
|
|
+ timestamp: Date.now(),
|
|
|
+ role: 'assistant',
|
|
|
+ content: placeholder,
|
|
|
+ })
|
|
|
+ // inputMessage.value = displayPromptList.value.find((i) => i.id === newVal)?.placeholder ?? ''
|
|
|
+})
|
|
|
+const promptLabel = computed(() => {
|
|
|
+ if (!loadingPromptList.value && selectPromptId.value === undefined) {
|
|
|
+ return t('message.assistant.status.noPromptConfigured')
|
|
|
+ }
|
|
|
+ const select = displayPromptList.value.filter((i) => i.id === selectPromptId.value)
|
|
|
+
|
|
|
+ if (select.length === 0) {
|
|
|
+ return t('message.assistant.status.loading')
|
|
|
+ }
|
|
|
+
|
|
|
+ return select[0].title
|
|
|
+})
|
|
|
+const { loading: loadingPromptList, doLoading: loadPromptList } = useLoading(async () => {
|
|
|
+ const data: { list: Prompt[]; total: number } = await assist.chat.prompt.list({ pageSize: 10, pageNum: 1, keyWord: '' }).catch(() => {
|
|
|
+ return {
|
|
|
+ list: [],
|
|
|
+ total: 0,
|
|
|
+ }
|
|
|
+ })
|
|
|
+
|
|
|
+ promptList.value = data.list ?? []
|
|
|
+ if (promptList.value.length !== 0) {
|
|
|
+ promptList.value.unshift({
|
|
|
+ id: -1,
|
|
|
+ title: t('message.assistant.prompt.noPrompt'),
|
|
|
+ prompt: '',
|
|
|
+ })
|
|
|
+ }
|
|
|
+ selectPromptId.value = promptList.value[0]?.id ?? undefined
|
|
|
+})
|
|
|
+
|
|
|
+onMounted(loadPromptList)
|
|
|
+
|
|
|
+const selectedModel = ref<number | undefined>(undefined)
|
|
|
+
|
|
|
+// 提示词管理对话框可见性
|
|
|
+const promptDialogVisible = ref(false)
|
|
|
+
|
|
|
+const chatInstance = ref<(() => void) | undefined>(undefined)
|
|
|
+onUnmounted(() => chatInstance.value?.())
|
|
|
+// 是否正在对话
|
|
|
+const isConversationActive = computed(() => chatInstance.value !== undefined)
|
|
|
+
|
|
|
+const { loading: loadingClearMessage, doLoading: clearMessage } = useLoading(async () => {
|
|
|
+ stopConversation()
|
|
|
+ if (messages.value.length !== 0 && activeConversationId.value !== undefined) {
|
|
|
+ const res = await assist.session.message
|
|
|
+ .save({
|
|
|
+ sessionId: activeConversationId.value,
|
|
|
+ messages: [],
|
|
|
+ })
|
|
|
+ .then(() => true)
|
|
|
+ .catch(() => false)
|
|
|
+ if (!res) {
|
|
|
+ return
|
|
|
+ }
|
|
|
+ }
|
|
|
+ messages.value = []
|
|
|
+})
|
|
|
+// 发送消息
|
|
|
+const sendMessage = async () => {
|
|
|
+ if (!inputMessage.value.trim()) return
|
|
|
+
|
|
|
+ if (activeConversationId.value === undefined) {
|
|
|
+ //未选中任何会话则创建新会话
|
|
|
+ await createConversationAndSetItActive()
|
|
|
+ }
|
|
|
+ await nextTick()
|
|
|
+ messages.value.push({
|
|
|
+ id: messages.value.length,
|
|
|
+ role: 'user',
|
|
|
+ render_content: inputMessage.value,
|
|
|
+ content: inputMessage.value,
|
|
|
+ timestamp: Date.now(),
|
|
|
+ files: selectedFiles.value,
|
|
|
+ })
|
|
|
+ selectedFiles.value = []
|
|
|
+
|
|
|
+ const rtn = reactive<Message>({
|
|
|
+ id: messages.value.length,
|
|
|
+ role: 'assistant',
|
|
|
+ render_content: '',
|
|
|
+ content: '',
|
|
|
+ timestamp: Date.now(),
|
|
|
+ tool_calls: [],
|
|
|
+ })
|
|
|
+
|
|
|
+ inputMessage.value = ''
|
|
|
+ scrollToBottom()
|
|
|
+ chatInternal(rtn)
|
|
|
+ messages.value.push(rtn)
|
|
|
+}
|
|
|
+
|
|
|
+const replaceMessage = async (index: number) => {
|
|
|
+ // 获取当前用户消息
|
|
|
+ const userMessage = messages.value[index]
|
|
|
+ if (!userMessage || userMessage.role !== 'user') {
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ // 查找对应的AI回复消息(通常是下一条消息)
|
|
|
+ let aiMessageIndex = -1
|
|
|
+ for (let i = index + 1; i < messages.value.length; i++) {
|
|
|
+ if (messages.value[i].role === 'assistant') {
|
|
|
+ aiMessageIndex = i
|
|
|
+ break
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ let rtn: Message
|
|
|
+
|
|
|
+ if (aiMessageIndex !== -1) {
|
|
|
+ // 找到了AI回复消息
|
|
|
+ const aiMessage = messages.value[aiMessageIndex]
|
|
|
+
|
|
|
+ if (aiMessage?.like === true) {
|
|
|
+ const confirm = await ElMessageBox({
|
|
|
+ title: t('message.assistant.messages.prompt'),
|
|
|
+ message: t('message.assistant.messages.replaceMessageWarning'),
|
|
|
+ type: 'warning',
|
|
|
+ confirmButtonText: t('message.assistant.buttons.confirmDialog'),
|
|
|
+ cancelButtonText: t('message.assistant.buttons.cancelDialog'),
|
|
|
+ })
|
|
|
+
|
|
|
+ if (!confirm) {
|
|
|
+ return
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ if (isReactive(aiMessage)) {
|
|
|
+ // 如果是reactive对象,清空内容
|
|
|
+ aiMessage.render_content = ''
|
|
|
+ aiMessage.content = ''
|
|
|
+ aiMessage.tool_calls = []
|
|
|
+ rtn = aiMessage
|
|
|
+ } else {
|
|
|
+ // 如果不是reactive对象,创建新的reactive对象替换
|
|
|
+ rtn = reactive<Message>({
|
|
|
+ id: aiMessage.id,
|
|
|
+ role: 'assistant',
|
|
|
+ render_content: '',
|
|
|
+ content: '',
|
|
|
+ timestamp: aiMessage.timestamp,
|
|
|
+ tool_calls: [],
|
|
|
+ })
|
|
|
+ messages.value[aiMessageIndex] = rtn
|
|
|
+ }
|
|
|
+ } else {
|
|
|
+ // 没有找到AI回复消息,创建新的
|
|
|
+ rtn = reactive<Message>({
|
|
|
+ id: messages.value.length,
|
|
|
+ role: 'assistant',
|
|
|
+ render_content: '',
|
|
|
+ content: '',
|
|
|
+ timestamp: Date.now(),
|
|
|
+ tool_calls: [],
|
|
|
+ })
|
|
|
+ messages.value.push(rtn)
|
|
|
+ }
|
|
|
+
|
|
|
+ // 重新发起对话
|
|
|
+ chatInternal(rtn, messages.value.slice(0, aiMessageIndex))
|
|
|
+}
|
|
|
+
|
|
|
+const chatInternal = (rtn: Message, context: Message[] = messages.value) => {
|
|
|
+ let r = [...context]
|
|
|
+ if (selectPromptId.value != undefined && selectPromptId.value != -1) {
|
|
|
+ const prompt = displayPromptList.value.find((i) => i.id === selectPromptId.value)?.prompt ?? ''
|
|
|
+ //insert element on top
|
|
|
+ r.unshift({
|
|
|
+ id: messages.value.length,
|
|
|
+ role: 'system',
|
|
|
+ render_content: prompt,
|
|
|
+ content: prompt,
|
|
|
+ timestamp: Date.now(),
|
|
|
+ })
|
|
|
+ }
|
|
|
+ chatInstance.value = assist.chat.sse({
|
|
|
+ chatRequest: {
|
|
|
+ session_id: activeConversationId.value!,
|
|
|
+ message: r,
|
|
|
+ modelClassId: selectedModel.value,
|
|
|
+ modelEmbeddingId: selectEmbeddingId.value,
|
|
|
+ },
|
|
|
+ onReceive: (resp: ChatResponse) => {
|
|
|
+ switch (resp.type) {
|
|
|
+ case 'structdata':
|
|
|
+ rtn.render_content += `
|
|
|
+\`\`\`structdata
|
|
|
+${resp.uuid}
|
|
|
+\`\`\`
|
|
|
+
|
|
|
+`
|
|
|
+ break
|
|
|
+ case 'message':
|
|
|
+ rtn.render_content += resp.message
|
|
|
+ rtn.content += resp.message
|
|
|
+ break
|
|
|
+ case 'toolres': {
|
|
|
+ if (showToolCalls.value) {
|
|
|
+ rtn.render_content += `
|
|
|
+\`\`\`tools-loading
|
|
|
+resp
|
|
|
+${resp.response.name}
|
|
|
+${resp.response.data.replace('\n', '')}
|
|
|
+\`\`\`
|
|
|
+
|
|
|
+`
|
|
|
+ } else {
|
|
|
+ if (rtn.render_content.at(-1) !== '\n') {
|
|
|
+ rtn.render_content += '\n\n'
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ //防止重试时底部有聊天记录无脑插入到底部出现后端无法识别的问题
|
|
|
+ messages.value.splice(messages.value.indexOf(rtn) + 1, 0, {
|
|
|
+ id: messages.value.length,
|
|
|
+ tool_call_id: resp.response.id,
|
|
|
+ role: 'tool',
|
|
|
+ render_content: '',
|
|
|
+ name: resp.response.name,
|
|
|
+ content: resp.response.data,
|
|
|
+ timestamp: Date.now(),
|
|
|
+ tool_calls: [],
|
|
|
+ })
|
|
|
+ break
|
|
|
+ }
|
|
|
+
|
|
|
+ case 'toolcall': {
|
|
|
+ if (showToolCalls.value) {
|
|
|
+ rtn.render_content += `
|
|
|
+\`\`\`tools-loading
|
|
|
+request
|
|
|
+${resp.request.name}
|
|
|
+${resp.request.data.replace('\n', '')}
|
|
|
+\`\`\`
|
|
|
+
|
|
|
+`
|
|
|
+ }
|
|
|
+
|
|
|
+ rtn.tool_calls?.push({
|
|
|
+ id: resp.request.id,
|
|
|
+ type: 'function',
|
|
|
+ function: {
|
|
|
+ name: resp.request.name,
|
|
|
+ arguments: resp.request.data,
|
|
|
+ },
|
|
|
+ })
|
|
|
+ break
|
|
|
+ }
|
|
|
+ case 'error':
|
|
|
+ rtn.render_content += `
|
|
|
+> ### 系统报错
|
|
|
+>
|
|
|
+>
|
|
|
+> ${resp.error.split('\n').join('\n> ')}
|
|
|
+
|
|
|
+`
|
|
|
+ //过滤ai消息下的所有tool防止报错,鬼知道后端怎么想的
|
|
|
+ messages.value = messages.value.slice(0, messages.value.indexOf(rtn) + 1)
|
|
|
+ break
|
|
|
+ }
|
|
|
+ },
|
|
|
+ onComplete: async (e) => {
|
|
|
+ if (e !== undefined) {
|
|
|
+ rtn.content += `
|
|
|
+
|
|
|
+ `
|
|
|
+ }
|
|
|
+ rtn.render_content += '\n'
|
|
|
+
|
|
|
+ const save_status = await assist.session.message
|
|
|
+ .save({
|
|
|
+ sessionId: activeConversationId.value!,
|
|
|
+ messages: messages.value,
|
|
|
+ })
|
|
|
+ .then(() => true)
|
|
|
+ .catch(() => false)
|
|
|
+
|
|
|
+ if (!save_status) {
|
|
|
+ ElMessage.warning(t('message.assistant.messages.messageSaveFailed'))
|
|
|
+ }
|
|
|
+
|
|
|
+ chatInstance.value = undefined
|
|
|
+ },
|
|
|
+ })
|
|
|
+}
|
|
|
+
|
|
|
+// 终止对话
|
|
|
+const stopConversation = () => {
|
|
|
+ chatInstance.value?.()
|
|
|
+ chatInstance.value = undefined
|
|
|
+}
|
|
|
+
|
|
|
+// 滚动到底部
|
|
|
+const scrollToBottom = () => {
|
|
|
+ nextTick(() => {
|
|
|
+ if (messagesContainer.value) {
|
|
|
+ messagesContainer.value.scrollTop = messagesContainer.value.scrollHeight
|
|
|
+ }
|
|
|
+ })
|
|
|
+}
|
|
|
+
|
|
|
+// 会话管理模块
|
|
|
+// 所有会话
|
|
|
+const conversations = ref<LmSession[]>([])
|
|
|
+const displayConversations = computed(() => {
|
|
|
+ return [{ session_id: -1, title: t('message.assistant.sidebar.bookmark') }, ...conversations.value]
|
|
|
+})
|
|
|
+const { loading: loadConversations, doLoading: doLoadConversations } = useLoading(async () => {
|
|
|
+ const data: { list: LmSession[]; total: number } = await assist.session
|
|
|
+ .list({
|
|
|
+ pageNum: 1,
|
|
|
+ pageSize: 30,
|
|
|
+ })
|
|
|
+ .catch(() => {
|
|
|
+ return {
|
|
|
+ list: [],
|
|
|
+ total: 0,
|
|
|
+ }
|
|
|
+ })
|
|
|
+
|
|
|
+ conversations.value = data.list ?? []
|
|
|
+})
|
|
|
+
|
|
|
+onMounted(doLoadConversations)
|
|
|
+
|
|
|
+// 当前活跃会话
|
|
|
+const activeConversationId = ref<number | undefined>(undefined)
|
|
|
+
|
|
|
+// watch(activeConversationId, async (newVal) => {
|
|
|
+// if (newVal === undefined) {
|
|
|
+// return
|
|
|
+// }
|
|
|
+// await doLoadingMessage(newVal)
|
|
|
+// })
|
|
|
+
|
|
|
+const bookmarkOptions = ref({
|
|
|
+ pageSize: 30,
|
|
|
+ pageNum: 1,
|
|
|
+ keyWord: '',
|
|
|
+})
|
|
|
+
|
|
|
+// 收藏消息总数
|
|
|
+const bookmarkTotal = ref(0)
|
|
|
+const { loading: loadingMessage, doLoading: doLoadingMessage } = useLoading(async (id: number) => {
|
|
|
+ //如果id是-1,拉取收藏的记录
|
|
|
+ if (id === -1) {
|
|
|
+ const data: {
|
|
|
+ messages: Message[]
|
|
|
+ total: number
|
|
|
+ } = await assist.session.message.bookmark_list(bookmarkOptions.value).catch(() => {
|
|
|
+ return {
|
|
|
+ list: [],
|
|
|
+ total: 0,
|
|
|
+ }
|
|
|
+ })
|
|
|
+ messages.value = data.messages ?? []
|
|
|
+ bookmarkTotal.value = data.total ?? 0
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ const data: {
|
|
|
+ messages: Message[]
|
|
|
+ total: number
|
|
|
+ } = await assist.session.message.list({ sessionId: id }).catch(() => {
|
|
|
+ return {
|
|
|
+ list: [],
|
|
|
+ total: 0,
|
|
|
+ }
|
|
|
+ })
|
|
|
+
|
|
|
+ messages.value = data.messages ?? []
|
|
|
+})
|
|
|
+
|
|
|
+// 选择会话
|
|
|
+const selectConversation = async (id: number) => {
|
|
|
+ if (activeConversationId.value === id) {
|
|
|
+ return
|
|
|
+ }
|
|
|
+ activeConversationId.value = id
|
|
|
+ await doLoadingMessage(id)
|
|
|
+}
|
|
|
+
|
|
|
+// 删除会话
|
|
|
+const currentDeletingConversation = ref(-1)
|
|
|
+const { loading: loadingDeleteConversation, doLoading: deleteConversation } = useLoading(async (id: number) => {
|
|
|
+ currentDeletingConversation.value = id
|
|
|
+ const res = await assist.session
|
|
|
+ .del([id])
|
|
|
+ .then(() => true)
|
|
|
+ .catch(() => false)
|
|
|
+ .finally(() => (currentDeletingConversation.value = -1))
|
|
|
+ if (!res) {
|
|
|
+ return
|
|
|
+ }
|
|
|
+ ElMessage.success(t('message.assistant.messages.deleteSuccess'))
|
|
|
+ activeConversationId.value = undefined
|
|
|
+ messages.value = []
|
|
|
+ conversations.value = conversations.value.filter((item) => item.session_id !== id)
|
|
|
+ // await nextTick()
|
|
|
+ // await doLoadConversations()
|
|
|
+})
|
|
|
+
|
|
|
+const multiDeleteConversationModel = ref({
|
|
|
+ visible: false,
|
|
|
+ selectedConversations: [] as number[],
|
|
|
+})
|
|
|
+
|
|
|
+const multiDeleteConversationClear = () => {
|
|
|
+ multiDeleteConversationModel.value.selectedConversations = []
|
|
|
+ multiDeleteConversationModel.value.visible = false
|
|
|
+}
|
|
|
+
|
|
|
+// 多选删除会话
|
|
|
+const startMultidelete = async () => {
|
|
|
+ if (multiDeleteConversationModel.value.selectedConversations.length === 0) {
|
|
|
+ ElMessage.warning(t('message.assistant.messages.selectConversationsToDelete'))
|
|
|
+ return
|
|
|
+ }
|
|
|
+ const confirm = await ElMessageBox.confirm(
|
|
|
+ t('message.assistant.messages.deleteConfirm', { count: multiDeleteConversationModel.value.selectedConversations.length }),
|
|
|
+ t('message.assistant.messages.warning'),
|
|
|
+ {
|
|
|
+ confirmButtonText: t('message.assistant.buttons.confirmDialog'),
|
|
|
+ cancelButtonText: t('message.assistant.buttons.cancelDialog'),
|
|
|
+ type: 'error',
|
|
|
+ }
|
|
|
+ )
|
|
|
+
|
|
|
+ if (confirm !== 'confirm') {
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ const res = await assist.session
|
|
|
+ .del(multiDeleteConversationModel.value.selectedConversations)
|
|
|
+ .then(() => true)
|
|
|
+ .catch(() => false)
|
|
|
+
|
|
|
+ if (!res) {
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ ElMessage.success(t('message.assistant.messages.deleteSuccess'))
|
|
|
+
|
|
|
+ await nextTick()
|
|
|
+ conversations.value = conversations.value.filter((item) => !multiDeleteConversationModel.value.selectedConversations.includes(item.session_id))
|
|
|
+}
|
|
|
+
|
|
|
+// 创建新对话
|
|
|
+const { loading: creatingConversation, doLoading: createConversationAndSetItActive } = useLoading(async () => {
|
|
|
+ // 调用API创建新对话,默认标题为"新对话"
|
|
|
+ const { id } = await assist.session.add(t('message.assistant.sidebar.createConversation'))
|
|
|
+ // 刷新对话列表
|
|
|
+ await doLoadConversations()
|
|
|
+
|
|
|
+ await nextTick()
|
|
|
+ activeConversationId.value = id
|
|
|
+ messages.value = []
|
|
|
+})
|
|
|
+
|
|
|
+// 编辑会话状态管理
|
|
|
+const editingConversationId = ref<number | undefined>(undefined)
|
|
|
+const editingTitle = ref('')
|
|
|
+// 编辑会话摘要
|
|
|
+const editSummary = (id: number) => {
|
|
|
+ const conversation = conversations.value.find((conv) => conv.session_id === id)
|
|
|
+ if (conversation) {
|
|
|
+ // 设置当前编辑的会话ID
|
|
|
+ editingConversationId.value = id
|
|
|
+ editingTitle.value = conversation.title
|
|
|
+ // 下一帧聚焦到输入框
|
|
|
+ nextTick(() => {
|
|
|
+ const editInput = document.querySelector('.edit-input .el-input__inner') as HTMLInputElement
|
|
|
+ if (editInput) {
|
|
|
+ editInput.focus()
|
|
|
+ editInput.select()
|
|
|
+ }
|
|
|
+ })
|
|
|
+ }
|
|
|
+}
|
|
|
+// 确认编辑
|
|
|
+const { doLoading: confirmEdit, loading: loadingConfirmEdit } = useLoading(async (id: number) => {
|
|
|
+ const conversation = conversations.value.find((conv) => conv.session_id === id)
|
|
|
+ if (conversation && editingTitle.value.trim()) {
|
|
|
+ const edit = await assist.session
|
|
|
+ .edit(id, editingTitle.value.trim())
|
|
|
+ .then(() => true)
|
|
|
+ .catch(() => false)
|
|
|
+ if (!edit) {
|
|
|
+ return
|
|
|
+ }
|
|
|
+ conversation.title = editingTitle.value.trim()
|
|
|
+ // 清除编辑状态
|
|
|
+ editingConversationId.value = undefined
|
|
|
+ editingTitle.value = ''
|
|
|
+ }
|
|
|
+})
|
|
|
+
|
|
|
+// 取消编辑
|
|
|
+const cancelEdit = () => {
|
|
|
+ // 清除编辑状态
|
|
|
+ editingConversationId.value = undefined
|
|
|
+ editingTitle.value = ''
|
|
|
+}
|
|
|
+
|
|
|
+onMounted(() => {
|
|
|
+ scrollToBottom()
|
|
|
+})
|
|
|
+
|
|
|
+//杂项
|
|
|
+const getUserInfos = ref<{
|
|
|
+ avatar: string
|
|
|
+ userName: string
|
|
|
+}>(Local.get('userInfo') || {})
|
|
|
+
|
|
|
+const canSendMessage = computed(() => {
|
|
|
+ return (
|
|
|
+ !inputMessage.value.trim() ||
|
|
|
+ loadingModels.value ||
|
|
|
+ loadingPromptList.value ||
|
|
|
+ loadConversations.value ||
|
|
|
+ loadingMessage.value ||
|
|
|
+ loadingUpload.value
|
|
|
+ )
|
|
|
+})
|
|
|
+
|
|
|
+const router = useRouter()
|
|
|
+const redirectToModelManager = () => router.push('model')
|
|
|
+
|
|
|
+// 设置面板相关状态
|
|
|
+const showSettingsPanel = ref(false)
|
|
|
+const showToolCalls = ref(false)
|
|
|
+
|
|
|
+// 收藏消息功能
|
|
|
+const favoriteMessageIdx = ref(-1)
|
|
|
+const { loading: loadingFavoriteMessage, doLoading: toggleFavorite } = useLoading(async (messageIndex: number) => {
|
|
|
+ favoriteMessageIdx.value = messageIndex
|
|
|
+ const data = messages.value[messageIndex]
|
|
|
+ //@ts-ignore
|
|
|
+ const active = activeConversationId.value === -1 ? data['session_id'] : activeConversationId.value ?? undefined
|
|
|
+ if (active === undefined) {
|
|
|
+ return
|
|
|
+ }
|
|
|
+ const success = assist.session.message
|
|
|
+ .bookmark(active, data.id, !(data.like ?? false))
|
|
|
+ .then(() => true)
|
|
|
+ .catch(() => false)
|
|
|
+ .finally(() => (favoriteMessageIdx.value = -1))
|
|
|
+ if (!success) {
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ messages.value[messageIndex] = {
|
|
|
+ ...data,
|
|
|
+ like: !(data.like ?? false),
|
|
|
+ }
|
|
|
+})
|
|
|
+
|
|
|
+// 收藏面板功能
|
|
|
+// 搜索功能
|
|
|
+const handleBookmarkSearch = async () => {
|
|
|
+ bookmarkOptions.value.pageNum = 1
|
|
|
+ await doLoadingMessage(-1)
|
|
|
+}
|
|
|
+
|
|
|
+// 分页变化
|
|
|
+const handleBookmarkPageChange = async (page: number) => {
|
|
|
+ bookmarkOptions.value.pageNum = page
|
|
|
+ await doLoadingMessage(-1)
|
|
|
+}
|
|
|
+
|
|
|
+// 每页数目变化
|
|
|
+const handleBookmarkPageSizeChange = async (pageSize: number) => {
|
|
|
+ bookmarkOptions.value.pageSize = pageSize
|
|
|
+ bookmarkOptions.value.pageNum = 1 // 重置到第一页
|
|
|
+ await doLoadingMessage(-1)
|
|
|
+}
|
|
|
+
|
|
|
+// 重置功能
|
|
|
+const handleBookmarkReset = async () => {
|
|
|
+ bookmarkOptions.value.keyWord = ''
|
|
|
+ bookmarkOptions.value.pageNum = 1
|
|
|
+ await doLoadingMessage(-1)
|
|
|
+}
|
|
|
+
|
|
|
+// 每页数目选项
|
|
|
+const pageSizeOptions = [3, 10, 20, 30, 50, 100]
|
|
|
+
|
|
|
+// 最大页数限制
|
|
|
+const maxPages = 100
|
|
|
+
|
|
|
+const exportToMarkDown = (message: Message) => {
|
|
|
+ // 创建CSV内容
|
|
|
+ const csvContent = message.render_content
|
|
|
+
|
|
|
+ download(csvContent, `export_${new Date().getTime()}.md`)
|
|
|
+}
|
|
|
+
|
|
|
+// 导出对话历史
|
|
|
+const exportId = ref<number>(-1)
|
|
|
+const { loading: exportConversationLoading, doLoading: exportConversation } = useLoading(async (id: number) => {
|
|
|
+ exportId.value = id
|
|
|
+ const result: {
|
|
|
+ messages: Message[]
|
|
|
+ total: number
|
|
|
+ } = await assist.session.message.list({ sessionId: id }).catch(() => {
|
|
|
+ return {
|
|
|
+ list: [],
|
|
|
+ total: 0,
|
|
|
+ }
|
|
|
+ })
|
|
|
+
|
|
|
+ let md = ''
|
|
|
+
|
|
|
+ for (const resultElement of result.messages) {
|
|
|
+ switch (resultElement.role) {
|
|
|
+ case 'user': {
|
|
|
+ md += '\n\n>' + resultElement.render_content + '\n\n'
|
|
|
+ continue
|
|
|
+ }
|
|
|
+ case 'assistant': {
|
|
|
+ md += '\n\n' + resultElement.render_content + '\n\n'
|
|
|
+ continue
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ exportToMarkDown({
|
|
|
+ id: 0,
|
|
|
+ role: 'assistant',
|
|
|
+ render_content: md,
|
|
|
+ content: md,
|
|
|
+ timestamp: Date.now(),
|
|
|
+ })
|
|
|
+
|
|
|
+ exportId.value = -1
|
|
|
+})
|
|
|
+
|
|
|
+const isBlank = (str: string) => {
|
|
|
+ return str == null || str.trim().length === 0
|
|
|
+}
|
|
|
+
|
|
|
+// 打开文件
|
|
|
+const openFile = (fullPath: string) => {
|
|
|
+ window.open(fullPath, '_blank')
|
|
|
+}
|
|
|
+
|
|
|
+// 格式化文件大小
|
|
|
+const formatFileSize = (bytes: number): string => {
|
|
|
+ if (bytes === 0) return '0 B'
|
|
|
+ const k = 1024
|
|
|
+ const sizes = ['B', 'KB', 'MB', 'GB', 'TB']
|
|
|
+ const i = Math.floor(Math.log(bytes) / Math.log(k))
|
|
|
+ return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
|
|
|
+}
|
|
|
+
|
|
|
+
|
|
|
+const isSystemLoading = computed(()=> {
|
|
|
+ return loadConversations.value ||
|
|
|
+ loadingModels.value ||
|
|
|
+ loadingPromptList.value ||
|
|
|
+ loadingEmbedding.value
|
|
|
+})
|
|
|
+</script>
|
|
|
+
|
|
|
+<template>
|
|
|
+ <el-container class="chat-container" ref="dropToUploadZone" v-loading="isSystemLoading">
|
|
|
+ <!-- 左侧会话列表 -->
|
|
|
+ <el-aside width="300px" class="chat-sidebar">
|
|
|
+ <div class="sidebar-header">
|
|
|
+ <h3>{{ t('message.assistant.sidebar.conversationHistory') }}</h3>
|
|
|
+ <el-dropdown v-model="showSettingsPanel" trigger="click" placement="bottom-end">
|
|
|
+ <el-button round :icon="EleSetting" size="small">
|
|
|
+ <el-icon class="el-icon--right">
|
|
|
+ <ArrowDown />
|
|
|
+ </el-icon>
|
|
|
+ </el-button>
|
|
|
+ <template #dropdown>
|
|
|
+ <el-dropdown-menu v-if="!multiDeleteConversationModel.visible">
|
|
|
+ <el-dropdown-item class="settings-item">
|
|
|
+ <div class="settings-row">
|
|
|
+ <span class="settings-label">{{ t('message.assistant.settings.autoRecordToolCalls') }}</span>
|
|
|
+ <el-switch v-model="showToolCalls" size="small" />
|
|
|
+ </div>
|
|
|
+ </el-dropdown-item>
|
|
|
+ <el-dropdown-item @click="redirectToModelManager">
|
|
|
+ <span class="settings-label">{{ t('message.assistant.settings.modelManagement') }}</span>
|
|
|
+ </el-dropdown-item>
|
|
|
+ <el-dropdown-item @click="promptDialogVisible = true">
|
|
|
+ <span class="settings-label">{{ t('message.assistant.settings.promptManagement') }}</span>
|
|
|
+ </el-dropdown-item>
|
|
|
+ <el-dropdown-item @click="multiDeleteConversationModel.visible = true">
|
|
|
+ <span class="settings-label">{{ t('message.assistant.settings.conversationManagement') }}</span>
|
|
|
+ </el-dropdown-item>
|
|
|
+ </el-dropdown-menu>
|
|
|
+ <el-dropdown-menu v-else>
|
|
|
+ <el-dropdown-item @click="multiDeleteConversationClear">
|
|
|
+ <el-icon>
|
|
|
+ <Close />
|
|
|
+ </el-icon>
|
|
|
+ <span>{{ t('message.assistant.settings.cancelSelection') }}</span>
|
|
|
+ </el-dropdown-item>
|
|
|
+ <el-dropdown-item @click="startMultidelete">
|
|
|
+ <el-icon>
|
|
|
+ <Delete />
|
|
|
+ </el-icon>
|
|
|
+ <span>{{ t('message.assistant.settings.deleteSelected') }}</span>
|
|
|
+ </el-dropdown-item>
|
|
|
+ </el-dropdown-menu>
|
|
|
+ </template>
|
|
|
+ </el-dropdown>
|
|
|
+ </div>
|
|
|
+ <el-scrollbar class="conversation-list" v-loading="loadConversations">
|
|
|
+ <div
|
|
|
+ v-for="conv in displayConversations"
|
|
|
+ :key="conv.session_id"
|
|
|
+ @click="editingConversationId !== conv.session_id && !multiDeleteConversationModel.visible ? selectConversation(conv.session_id) : () => {}"
|
|
|
+ :class="['conversation-item', { active: activeConversationId === conv.session_id, editing: editingConversationId === conv.session_id }]"
|
|
|
+ >
|
|
|
+ <!-- 非编辑状态 -->
|
|
|
+ <div v-if="editingConversationId !== conv.session_id" class="conversation-content">
|
|
|
+ <el-checkbox
|
|
|
+ v-if="multiDeleteConversationModel.visible && conv.session_id !== -1 && conv.session_id !== activeConversationId"
|
|
|
+ :model-value="multiDeleteConversationModel.selectedConversations.includes(conv.session_id)"
|
|
|
+ @change="(enable: boolean) => enable ? multiDeleteConversationModel.selectedConversations.push(conv.session_id) : multiDeleteConversationModel.selectedConversations = multiDeleteConversationModel.selectedConversations.filter((id: number) => id !== conv.session_id)"
|
|
|
+ :disabled="isConversationActive && activeConversationId === conv.session_id"
|
|
|
+ @click.stop
|
|
|
+ />
|
|
|
+ <span class="conversation-title">{{ conv.title }}</span>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- 编辑状态 -->
|
|
|
+ <div v-else class="conversation-edit-content">
|
|
|
+ <el-input
|
|
|
+ v-model="editingTitle"
|
|
|
+ size="small"
|
|
|
+ @keydown.enter="confirmEdit(conv.session_id)"
|
|
|
+ @keydown.esc="cancelEdit"
|
|
|
+ class="edit-input"
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- 操作按钮 -->
|
|
|
+ <div class="conversation-actions" v-if="conv.session_id !== -1 && !multiDeleteConversationModel.visible">
|
|
|
+ <!-- 非编辑状态的三点菜单 -->
|
|
|
+ <template v-if="editingConversationId !== conv.session_id">
|
|
|
+ <el-dropdown trigger="click" placement="bottom-end" @click.stop>
|
|
|
+ <el-button type="primary" size="small" :icon="MoreFilled" class="action-btn more-btn" :title="t('message.assistant.buttons.more')" plain circle @click.stop />
|
|
|
+ <template #dropdown>
|
|
|
+ <el-dropdown-menu>
|
|
|
+ <el-dropdown-item @click="exportConversation(conv.session_id)">
|
|
|
+ <el-button v-if="conv.session_id === exportId && exportConversationLoading" size="small" loading circle plain />
|
|
|
+ <el-icon v-else>
|
|
|
+ <Download />
|
|
|
+ </el-icon>
|
|
|
+ <span>{{ t('message.assistant.buttons.export') }}</span>
|
|
|
+ </el-dropdown-item>
|
|
|
+ <el-dropdown-item @click="editSummary(conv.session_id)">
|
|
|
+ <el-icon>
|
|
|
+ <Edit />
|
|
|
+ </el-icon>
|
|
|
+ <span>{{ t('message.assistant.buttons.edit') }}</span>
|
|
|
+ </el-dropdown-item>
|
|
|
+ <el-dropdown-item
|
|
|
+ @click="deleteConversation(conv.session_id)"
|
|
|
+ :disabled="isConversationActive || (loadingDeleteConversation && currentDeletingConversation == conv.session_id)"
|
|
|
+ >
|
|
|
+ <el-icon>
|
|
|
+ <Delete />
|
|
|
+ </el-icon>
|
|
|
+ <span>{{ t('message.assistant.buttons.delete') }}</span>
|
|
|
+ </el-dropdown-item>
|
|
|
+ </el-dropdown-menu>
|
|
|
+ </template>
|
|
|
+ </el-dropdown>
|
|
|
+ </template>
|
|
|
+
|
|
|
+ <!-- 编辑状态的按钮 -->
|
|
|
+ <template v-else>
|
|
|
+ <el-button
|
|
|
+ type="success"
|
|
|
+ size="small"
|
|
|
+ :icon="Check"
|
|
|
+ :loading="loadingConfirmEdit"
|
|
|
+ @click.stop="confirmEdit(conv.session_id)"
|
|
|
+ class="action-btn confirm-btn"
|
|
|
+ :title="t('message.assistant.buttons.confirm')"
|
|
|
+ plain
|
|
|
+ circle
|
|
|
+ />
|
|
|
+ <el-button
|
|
|
+ type="info"
|
|
|
+ size="small"
|
|
|
+ @click.stop="cancelEdit(conv.session_id)"
|
|
|
+ class="action-btn cancel-btn"
|
|
|
+ :title="t('message.assistant.buttons.cancel')"
|
|
|
+ plain
|
|
|
+ circle
|
|
|
+ >
|
|
|
+ <el-icon>
|
|
|
+ <Close />
|
|
|
+ </el-icon>
|
|
|
+ </el-button>
|
|
|
+ </template>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </el-scrollbar>
|
|
|
+ <el-button
|
|
|
+ type="primary"
|
|
|
+ size="large"
|
|
|
+ class="create-conversation-btn"
|
|
|
+ @click="createConversationAndSetItActive"
|
|
|
+ :loading="creatingConversation"
|
|
|
+ :disabled="isConversationActive"
|
|
|
+ >{{ t('message.assistant.sidebar.createConversation') }}
|
|
|
+ </el-button>
|
|
|
+ </el-aside>
|
|
|
+
|
|
|
+ <!-- 右侧聊天区域 -->
|
|
|
+ <el-main class="chat-main">
|
|
|
+ <!-- 消息展示区域 -->
|
|
|
+ <div class="messages-container" ref="messagesContainer" v-loading="loadingMessage">
|
|
|
+ <div v-if="messages.length !== 0">
|
|
|
+ <div v-for="(message, idx) in messages" :key="message.id" :class="['message-wrapper', message.role]">
|
|
|
+ <!-- AI消息 -->
|
|
|
+ <div v-if="message.role === 'assistant'" class="ai-message-container">
|
|
|
+ <el-avatar class="message-avatar" :icon="ChatDotRound" />
|
|
|
+ <div class="ai-message-content">
|
|
|
+ <div class="message-bubble ai-bubble">
|
|
|
+ <Markdown v-if="!isBlank(message.render_content)" :content="message.render_content" :plugins="plugins" class="markdown-content" />
|
|
|
+ <div v-else class="loading-container">
|
|
|
+ <div class="loading-dots">
|
|
|
+ <span class="dot"></span>
|
|
|
+ <span class="dot"></span>
|
|
|
+ <span class="dot"></span>
|
|
|
+ </div>
|
|
|
+ <span class="loading-text">{{ t('message.assistant.status.aiThinking') }}</span>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <div class="ai-message-actions">
|
|
|
+ <el-button
|
|
|
+ :loading="favoriteMessageIdx === idx && loadingFavoriteMessage"
|
|
|
+ @click="toggleFavorite(idx)"
|
|
|
+ class="favorite-btn"
|
|
|
+ :class="{ favorited: message.like ?? false }"
|
|
|
+ :disabled="isConversationActive"
|
|
|
+ :icon="message.like ?? false ? StarFilled : Star"
|
|
|
+ plain
|
|
|
+ circle
|
|
|
+ />
|
|
|
+
|
|
|
+ <el-button
|
|
|
+ :loading="favoriteMessageIdx === idx && loadingFavoriteMessage"
|
|
|
+ @click="exportToMarkDown(message)"
|
|
|
+ class="favorite-btn"
|
|
|
+ :disabled="isConversationActive"
|
|
|
+ :icon="Download"
|
|
|
+ plain
|
|
|
+ circle
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- 用户消息 -->
|
|
|
+ <div v-if="message.role === 'user'" class="user-message-container">
|
|
|
+ <div class="user-message-content">
|
|
|
+ <div class="message-bubble user-bubble">
|
|
|
+ {{ message.render_content }}
|
|
|
+ <div v-if="message.files !== undefined && message.files.length > 0" class="message-files">
|
|
|
+ <div
|
|
|
+ v-for="file in message.files"
|
|
|
+ :key="file.path"
|
|
|
+ class="file-item"
|
|
|
+ @click="openFile(file.full_path)"
|
|
|
+ :title="t('message.assistant.file.clickToOpen', { name: file.name })"
|
|
|
+ >
|
|
|
+ <el-icon class="file-icon">
|
|
|
+ <Document v-if="file.type.includes('text') || file.type.includes('document')" />
|
|
|
+ <Picture v-else-if="file.type.includes('image')" />
|
|
|
+ <VideoPlay v-else-if="file.type.includes('video')" />
|
|
|
+ <Headset v-else-if="file.type.includes('audio')" />
|
|
|
+ <Files v-else />
|
|
|
+ </el-icon>
|
|
|
+ <span class="file-name">{{ file.name }}</span>
|
|
|
+ <span class="file-size">{{ formatFileSize(file.size) }}</span>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <div class="user-message-actions">
|
|
|
+ <el-button
|
|
|
+ type="primary"
|
|
|
+ size="small"
|
|
|
+ @click="replaceMessage(messages.indexOf(message))"
|
|
|
+ class="retry-btn"
|
|
|
+ plain
|
|
|
+ :disabled="isConversationActive"
|
|
|
+ >
|
|
|
+ {{ t('message.assistant.buttons.retry') }}
|
|
|
+ </el-button>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <el-avatar class="message-avatar" :src="getUserInfos.avatar" :icon="User" />
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- 空状态页面 -->
|
|
|
+ <div v-else class="empty-content">
|
|
|
+ <!-- 收藏页面空状态 -->
|
|
|
+ <div v-if="activeConversationId === -1" class="bookmark-empty">
|
|
|
+ <div class="empty-icon">
|
|
|
+ <el-icon :size="80" color="#f59e0b">
|
|
|
+ <StarFilled />
|
|
|
+ </el-icon>
|
|
|
+ </div>
|
|
|
+ <div class="empty-text">
|
|
|
+ <h2 class="empty-title">{{ t('message.assistant.empty.noBookmarks') }}</h2>
|
|
|
+ <p class="empty-description">{{ t('message.assistant.empty.noBookmarksDescription') }}</p>
|
|
|
+ </div>
|
|
|
+ <div class="empty-tips">
|
|
|
+ <div class="tip-item">
|
|
|
+ <el-icon color="#409eff">
|
|
|
+ <InfoFilled />
|
|
|
+ </el-icon>
|
|
|
+ <span>{{ t('message.assistant.empty.bookmarkTip1') }}</span>
|
|
|
+ </div>
|
|
|
+ <div class="tip-item">
|
|
|
+ <el-icon color="#67c23a">
|
|
|
+ <Search />
|
|
|
+ </el-icon>
|
|
|
+ <span>{{ t('message.assistant.empty.bookmarkTip2') }}</span>
|
|
|
+ </div>
|
|
|
+ <div class="tip-item">
|
|
|
+ <el-icon color="#e6a23c">
|
|
|
+ <Collection />
|
|
|
+ </el-icon>
|
|
|
+ <span>{{ t('message.assistant.empty.bookmarkTip3') }}</span>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- 普通对话空状态 -->
|
|
|
+ <div v-else class="chat-empty">
|
|
|
+ <div class="empty-icon">
|
|
|
+ <el-icon :size="80" color="#d1d5db">
|
|
|
+ <ChatDotRound />
|
|
|
+ </el-icon>
|
|
|
+ </div>
|
|
|
+ <div class="empty-text">
|
|
|
+ <h2 class="empty-title">{{ t('message.assistant.empty.startNewConversation') }}</h2>
|
|
|
+ </div>
|
|
|
+ <div class="example-questions">
|
|
|
+ <h4>{{ t('message.assistant.empty.tryTheseQuestions') }}</h4>
|
|
|
+ <div class="question-tags">
|
|
|
+ <el-tag class="question-tag" @click="inputMessage = t('message.assistant.examples.deviceStatus')" type="info">
|
|
|
+ {{ t('message.assistant.examples.deviceStatus') }}
|
|
|
+ </el-tag>
|
|
|
+ <el-tag class="question-tag" @click="inputMessage = t('message.assistant.examples.userPermissions')" type="success">
|
|
|
+ {{ t('message.assistant.examples.userPermissions') }}
|
|
|
+ </el-tag>
|
|
|
+ <el-tag class="question-tag" @click="inputMessage = t('message.assistant.examples.systemPerformance')" type="warning"> {{ t('message.assistant.examples.systemPerformance') }} </el-tag>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div class="messages-spacer"></div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- 附件栏(内嵌到输入框上方) -->
|
|
|
+
|
|
|
+ <div class="input-container" v-if="activeConversationId !== -1">
|
|
|
+ <!-- 大输入框容器 -->
|
|
|
+ <div class="large-input-container">
|
|
|
+ <!-- 附件栏:紧贴输入框上方,位于容器内部 -->
|
|
|
+ <div class="attachments-inline">
|
|
|
+ <el-scrollbar>
|
|
|
+ <div class="attachments-inline-scroll">
|
|
|
+ <div
|
|
|
+ v-for="(file, fIdx) in selectedFiles"
|
|
|
+ :key="file.name + '_' + file.size"
|
|
|
+ class="attachment-card"
|
|
|
+ @click="openFile(file.full_path)"
|
|
|
+ title=""
|
|
|
+ >
|
|
|
+ <button class="control-btn attachment-item" :title="`${file.name}`">
|
|
|
+ <el-icon :size="10"><CopyDocument /></el-icon>
|
|
|
+ <span class="attachment-name">{{ file.name }}</span>
|
|
|
+ </button>
|
|
|
+ <button class="remove-attachment-icon" @click.stop="removeAttachment(fIdx)">
|
|
|
+ <el-icon><Close /></el-icon>
|
|
|
+ </button>
|
|
|
+ </div>
|
|
|
+ <!-- 无附件时的提示信息 -->
|
|
|
+ <div v-if="selectedFiles.length === 0" class="attachment-hint">
|
|
|
+ <span v-if="!isOverDropZone">{{ t('message.assistant.status.clickToUpload') }}</span>
|
|
|
+ <span v-else>{{ t('message.assistant.status.uploadProgress') }}</span>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </el-scrollbar>
|
|
|
+ <button class="control-btn add-attachment-btn" @click="open" :disabled="loadingUpload">
|
|
|
+ <el-icon :size="10">
|
|
|
+ <Plus v-if="!loadingUpload" />
|
|
|
+ <div v-else>{{ uploadProgress.toFixed(2) }}</div>
|
|
|
+ </el-icon>
|
|
|
+ </button>
|
|
|
+ </div>
|
|
|
+ <!-- 输入框 -->
|
|
|
+ <textarea
|
|
|
+ v-model="inputMessage"
|
|
|
+ class="large-textarea"
|
|
|
+ :placeholder="t('message.assistant.placeholders.inputQuestion')"
|
|
|
+ @keydown.enter.ctrl="sendMessage"
|
|
|
+ @keydown.enter.meta="sendMessage"
|
|
|
+ :disabled="isConversationActive"
|
|
|
+ rows="3"
|
|
|
+ ></textarea>
|
|
|
+
|
|
|
+ <!-- 内嵌按钮区域 -->
|
|
|
+ <div class="embedded-controls">
|
|
|
+ <!-- 左下角按钮组 -->
|
|
|
+ <div class="left-controls">
|
|
|
+ <!-- 模型选择按钮 -->
|
|
|
+ <el-dropdown trigger="click" placement="top-start" :disabled="loadingModels || modelLabel == t('message.assistant.status.noModelConfigured')">
|
|
|
+ <button class="control-btn model-btn">
|
|
|
+ <el-icon>
|
|
|
+ <Setting v-if="!loadingModels" />
|
|
|
+ <Loading v-else class="spin" />
|
|
|
+ </el-icon>
|
|
|
+ <span>{{ modelLabel }}</span>
|
|
|
+ </button>
|
|
|
+ <template #dropdown>
|
|
|
+ <el-dropdown-menu>
|
|
|
+ <el-dropdown-item
|
|
|
+ v-for="item in modelOptions"
|
|
|
+ :key="item.id"
|
|
|
+ @click="selectedModel = item.id"
|
|
|
+ :class="{ 'is-selected': selectedModel === item.id }"
|
|
|
+ >
|
|
|
+ {{ item.modelName }}
|
|
|
+ </el-dropdown-item>
|
|
|
+ </el-dropdown-menu>
|
|
|
+ </template>
|
|
|
+ </el-dropdown>
|
|
|
+
|
|
|
+ <!-- 词嵌入模型选择按钮 -->
|
|
|
+ <el-dropdown trigger="click" placement="top-start" :disabled="loadingModels || promptLabel == t('message.assistant.status.noPromptConfigured')">
|
|
|
+ <button class="control-btn embedding-model-btn">
|
|
|
+ <el-icon>
|
|
|
+ <CopyDocument v-if="!loadingPromptList" />
|
|
|
+ <Loading v-else class="spin" />
|
|
|
+ </el-icon>
|
|
|
+ <span>{{ promptLabel }}</span>
|
|
|
+ </button>
|
|
|
+ <template #dropdown>
|
|
|
+ <el-dropdown-menu>
|
|
|
+ <el-dropdown-item
|
|
|
+ v-for="item in displayPromptList"
|
|
|
+ :key="item.id"
|
|
|
+ @click="selectPromptId = item.id"
|
|
|
+ :class="{ 'is-selected': selectPromptId === item.id }"
|
|
|
+ >
|
|
|
+ {{ item.title }}
|
|
|
+ </el-dropdown-item>
|
|
|
+ </el-dropdown-menu>
|
|
|
+ </template>
|
|
|
+ </el-dropdown>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- 右下角按钮组 -->
|
|
|
+ <div class="right-controls">
|
|
|
+ <!-- 清空按钮 -->
|
|
|
+ <button v-show="messages.length !== 0" class="control-btn clear-btn" @click="clearMessage" :disabled="loadingClearMessage">
|
|
|
+ <el-icon v-if="loadingClearMessage"><Loading /></el-icon>
|
|
|
+ <el-icon v-else><Delete /></el-icon>
|
|
|
+ </button>
|
|
|
+
|
|
|
+ <!-- 发送按钮 -->
|
|
|
+ <button v-if="!isConversationActive" class="control-btn send-btn" @click="sendMessage" :disabled="canSendMessage">
|
|
|
+ <el-icon><Promotion /></el-icon>
|
|
|
+ </button>
|
|
|
+ <button v-else class="control-btn stop-btn" @click="stopConversation">
|
|
|
+ <el-icon><VideoPause /></el-icon>
|
|
|
+ </button>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <!-- 收藏面板底部栏 -->
|
|
|
+ <div class="input-container bookmark-footer" v-else>
|
|
|
+ <div class="bookmark-controls">
|
|
|
+ <!-- 搜索框 -->
|
|
|
+ <div class="bookmark-search">
|
|
|
+ <el-input
|
|
|
+ v-model="bookmarkOptions.keyWord"
|
|
|
+ :placeholder="t('message.assistant.placeholders.searchBookmarks')"
|
|
|
+ :prefix-icon="Search"
|
|
|
+ clearable
|
|
|
+ @keydown.enter="handleBookmarkSearch"
|
|
|
+ @clear="handleBookmarkReset"
|
|
|
+ style="width: 300px"
|
|
|
+ />
|
|
|
+ <el-button type="primary" :icon="Search" @click="handleBookmarkSearch" :loading="loadingMessage"> {{ t('message.assistant.buttons.search') }} </el-button>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- 分页和重置 -->
|
|
|
+ <div class="bookmark-pagination">
|
|
|
+ <el-button @click="handleBookmarkReset" :loading="loadingMessage"> {{ t('message.assistant.buttons.reset') }} </el-button>
|
|
|
+ <el-pagination
|
|
|
+ v-model:current-page="bookmarkOptions.pageNum"
|
|
|
+ v-model:page-size="bookmarkOptions.pageSize"
|
|
|
+ :total="bookmarkTotal"
|
|
|
+ :page-sizes="pageSizeOptions"
|
|
|
+ :pager-count="7"
|
|
|
+ :max-page="maxPages"
|
|
|
+ layout="total, sizes, prev, pager, next, jumper"
|
|
|
+ @current-change="handleBookmarkPageChange"
|
|
|
+ @size-change="handleBookmarkPageSizeChange"
|
|
|
+ :disabled="loadingMessage"
|
|
|
+ small
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </el-main>
|
|
|
+
|
|
|
+ <!-- 提示词管理对话框 -->
|
|
|
+ <el-dialog v-model="promptDialogVisible" :title="t('message.assistant.prompt.management')" width="60%" append-to-body>
|
|
|
+ <div class="prompt-dialog-content">
|
|
|
+ <div class="prompt-input-section">
|
|
|
+ <h4>{{ t('message.assistant.prompt.customPrompt') }}</h4>
|
|
|
+ <el-input
|
|
|
+ v-model="customPrompt"
|
|
|
+ type="textarea"
|
|
|
+ :autosize="{ minRows: 12, maxRows: 24 }"
|
|
|
+ :placeholder="t('message.assistant.placeholders.customPrompt')"
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <template #footer>
|
|
|
+ <div class="dialog-footer">
|
|
|
+ <el-button @click="promptDialogVisible = false">{{ t('message.assistant.buttons.cancelDialog') }}</el-button>
|
|
|
+ <el-button type="primary" @click="promptDialogVisible = false">{{ t('message.assistant.buttons.confirmDialog') }}</el-button>
|
|
|
+ </div>
|
|
|
+ </template>
|
|
|
+ </el-dialog>
|
|
|
+ </el-container>
|
|
|
+</template>
|
|
|
+
|
|
|
+<style scoped lang="scss">
|
|
|
+:deep(.el-icon) {
|
|
|
+ margin-right: 0 !important;
|
|
|
+}
|
|
|
+
|
|
|
+.chat-container {
|
|
|
+ height: 100%;
|
|
|
+ background: var(--el-bg-color-page);
|
|
|
+}
|
|
|
+
|
|
|
+.create-conversation-btn {
|
|
|
+ margin: 16px;
|
|
|
+}
|
|
|
+
|
|
|
+/* 左侧边栏样式 */
|
|
|
+.chat-sidebar {
|
|
|
+ background: var(--el-bg-color);
|
|
|
+ border-right: 1px solid var(--el-border-color-light);
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+}
|
|
|
+
|
|
|
+.sidebar-header {
|
|
|
+ padding: 20px;
|
|
|
+ border-bottom: 1px solid var(--el-border-color-light);
|
|
|
+ display: flex;
|
|
|
+ justify-content: space-between;
|
|
|
+ align-items: center;
|
|
|
+
|
|
|
+ h3 {
|
|
|
+ margin: 0;
|
|
|
+ color: var(--el-text-color-primary);
|
|
|
+ font-size: 16px;
|
|
|
+ font-weight: 600;
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+/* 设置面板样式 */
|
|
|
+:deep(.el-dropdown-menu) {
|
|
|
+ .settings-item {
|
|
|
+ padding: 0;
|
|
|
+
|
|
|
+ &:hover {
|
|
|
+ background: none;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ .settings-row {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: space-between;
|
|
|
+ padding: 8px 16px;
|
|
|
+ min-width: 160px;
|
|
|
+
|
|
|
+ &:hover {
|
|
|
+ background: var(--el-fill-color-light);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ .settings-label {
|
|
|
+ font-size: 14px;
|
|
|
+ color: var(--el-text-color-primary);
|
|
|
+ flex: 1;
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+.conversation-list {
|
|
|
+ flex: 1;
|
|
|
+ padding: 10px;
|
|
|
+}
|
|
|
+
|
|
|
+.conversation-item {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: space-between;
|
|
|
+ padding: 12px 16px;
|
|
|
+ margin-bottom: 8px;
|
|
|
+ border-radius: 8px;
|
|
|
+ transition: all 0.2s;
|
|
|
+ color: var(--el-text-color-regular);
|
|
|
+ border: 1px solid transparent;
|
|
|
+ position: relative;
|
|
|
+ cursor: pointer;
|
|
|
+
|
|
|
+ &:hover {
|
|
|
+ background: var(--el-fill-color-light);
|
|
|
+ color: var(--el-text-color-primary);
|
|
|
+
|
|
|
+ .conversation-actions {
|
|
|
+ opacity: 1;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ &.active {
|
|
|
+ background: var(--el-color-primary-light-9);
|
|
|
+ color: var(--el-color-primary);
|
|
|
+ border-color: var(--el-color-primary-light-7);
|
|
|
+
|
|
|
+ .conversation-actions {
|
|
|
+ opacity: 1;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ &.editing {
|
|
|
+ background: var(--el-color-warning-light-9);
|
|
|
+ border-color: var(--el-color-warning-light-7);
|
|
|
+
|
|
|
+ .conversation-actions {
|
|
|
+ opacity: 1;
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+.conversation-content {
|
|
|
+ flex: 1;
|
|
|
+ min-width: 0;
|
|
|
+ cursor: pointer;
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+
|
|
|
+ .el-checkbox {
|
|
|
+ margin-right: 8px;
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+.conversation-edit-content {
|
|
|
+ flex: 1;
|
|
|
+ min-width: 0;
|
|
|
+}
|
|
|
+
|
|
|
+.edit-input {
|
|
|
+ width: 100%;
|
|
|
+
|
|
|
+ :deep(.el-input__inner) {
|
|
|
+ font-size: 14px;
|
|
|
+ padding: 4px 8px;
|
|
|
+ height: 28px;
|
|
|
+ line-height: 20px;
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+.conversation-title {
|
|
|
+ display: block;
|
|
|
+ overflow: hidden;
|
|
|
+ text-overflow: ellipsis;
|
|
|
+ white-space: nowrap;
|
|
|
+}
|
|
|
+
|
|
|
+.conversation-actions {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ gap: 4px;
|
|
|
+ opacity: 0;
|
|
|
+ transition: opacity 0.2s;
|
|
|
+ flex-shrink: 0;
|
|
|
+}
|
|
|
+
|
|
|
+.action-btn {
|
|
|
+ width: 28px !important;
|
|
|
+ height: 28px !important;
|
|
|
+ padding: 0 !important;
|
|
|
+ margin: 0 2px;
|
|
|
+}
|
|
|
+
|
|
|
+.more-btn {
|
|
|
+ &:hover {
|
|
|
+ background: var(--el-color-primary-light-9) !important;
|
|
|
+ border-color: var(--el-color-primary-light-7) !important;
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+/* 对话历史三点菜单样式 */
|
|
|
+.conversation-actions {
|
|
|
+ :deep(.el-dropdown-menu) {
|
|
|
+ .el-dropdown-menu__item {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ gap: 8px;
|
|
|
+ padding: 8px 16px;
|
|
|
+
|
|
|
+ .el-icon {
|
|
|
+ font-size: 14px;
|
|
|
+ color: var(--el-text-color-regular);
|
|
|
+ }
|
|
|
+
|
|
|
+ span {
|
|
|
+ font-size: 14px;
|
|
|
+ color: var(--el-text-color-primary);
|
|
|
+ }
|
|
|
+
|
|
|
+ &:hover {
|
|
|
+ background: var(--el-fill-color-light);
|
|
|
+
|
|
|
+ .el-icon {
|
|
|
+ color: var(--el-color-primary);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ &.is-disabled {
|
|
|
+ .el-icon,
|
|
|
+ span {
|
|
|
+ color: var(--el-text-color-disabled);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+/* 右侧聊天区域样式 */
|
|
|
+.chat-main {
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ padding: 0;
|
|
|
+ background: var(--el-bg-color-page);
|
|
|
+
|
|
|
+ position: relative;
|
|
|
+}
|
|
|
+
|
|
|
+.messages-container {
|
|
|
+ flex: 1;
|
|
|
+ padding: 20px;
|
|
|
+ overflow-y: auto;
|
|
|
+}
|
|
|
+
|
|
|
+.messages-spacer {
|
|
|
+ height: 203px;
|
|
|
+}
|
|
|
+
|
|
|
+.message-wrapper {
|
|
|
+ margin-bottom: 20px;
|
|
|
+
|
|
|
+ &:last-child {
|
|
|
+ margin-bottom: 0;
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+/* AI消息样式 */
|
|
|
+.ai-message-container {
|
|
|
+ display: flex;
|
|
|
+ align-items: flex-start;
|
|
|
+ gap: 12px;
|
|
|
+}
|
|
|
+
|
|
|
+.ai-message-content {
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ align-items: flex-start;
|
|
|
+ gap: 8px;
|
|
|
+ width: 70%;
|
|
|
+}
|
|
|
+
|
|
|
+.ai-message-actions {
|
|
|
+ display: flex;
|
|
|
+ justify-content: flex-start;
|
|
|
+ transition: opacity 0.2s ease;
|
|
|
+}
|
|
|
+
|
|
|
+.favorite-btn {
|
|
|
+ width: 16px !important;
|
|
|
+ height: 16px !important;
|
|
|
+ padding: 0 !important;
|
|
|
+ border: none !important;
|
|
|
+ background: transparent !important;
|
|
|
+ color: var(--el-text-color-regular);
|
|
|
+ transition: all 0.2s ease;
|
|
|
+
|
|
|
+ &:hover {
|
|
|
+ background: var(--el-fill-color-light) !important;
|
|
|
+ }
|
|
|
+
|
|
|
+ &.favorited {
|
|
|
+ color: #ff4757;
|
|
|
+ }
|
|
|
+
|
|
|
+ .el-icon {
|
|
|
+ font-size: 16px;
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+.ai-bubble {
|
|
|
+ background: var(--el-fill-color-light);
|
|
|
+ color: var(--el-text-color-primary);
|
|
|
+ position: relative;
|
|
|
+ max-width: 100%;
|
|
|
+ border: 1px solid var(--el-border-color-lighter);
|
|
|
+
|
|
|
+ &::before {
|
|
|
+ content: '';
|
|
|
+ position: absolute;
|
|
|
+ left: -8px;
|
|
|
+ top: 12px;
|
|
|
+ width: 0;
|
|
|
+ height: 0;
|
|
|
+ border-right: 8px solid var(--el-fill-color-light);
|
|
|
+ border-top: 8px solid transparent;
|
|
|
+ border-bottom: 8px solid transparent;
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+/* 用户消息样式 */
|
|
|
+.user-message-container {
|
|
|
+ display: flex;
|
|
|
+ align-items: flex-start;
|
|
|
+ gap: 12px;
|
|
|
+ justify-content: flex-end;
|
|
|
+}
|
|
|
+
|
|
|
+.user-message-content {
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ align-items: flex-end;
|
|
|
+ gap: 8px;
|
|
|
+ width: 70%;
|
|
|
+}
|
|
|
+
|
|
|
+.user-message-actions {
|
|
|
+ display: flex;
|
|
|
+ justify-content: flex-end;
|
|
|
+ opacity: 0;
|
|
|
+ transition: opacity 0.2s ease;
|
|
|
+}
|
|
|
+
|
|
|
+.user-message-container:hover .user-message-actions {
|
|
|
+ opacity: 1;
|
|
|
+}
|
|
|
+
|
|
|
+.retry-btn {
|
|
|
+ font-size: 12px;
|
|
|
+ padding: 4px 12px;
|
|
|
+ height: 24px;
|
|
|
+ border-radius: 12px;
|
|
|
+}
|
|
|
+
|
|
|
+.user-bubble {
|
|
|
+ background: var(--el-color-primary);
|
|
|
+ color: white;
|
|
|
+ position: relative;
|
|
|
+ max-width: 100%;
|
|
|
+
|
|
|
+ &::after {
|
|
|
+ content: '';
|
|
|
+ position: absolute;
|
|
|
+ right: -8px;
|
|
|
+ top: 12px;
|
|
|
+ width: 0;
|
|
|
+ height: 0;
|
|
|
+ border-left: 8px solid var(--el-color-primary);
|
|
|
+ border-top: 8px solid transparent;
|
|
|
+ border-bottom: 8px solid transparent;
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+/* 消息气泡通用样式 */
|
|
|
+.message-bubble {
|
|
|
+ padding: 12px 16px;
|
|
|
+ border-radius: 12px;
|
|
|
+ line-height: 1.5;
|
|
|
+ word-wrap: break-word;
|
|
|
+ box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
|
|
|
+}
|
|
|
+
|
|
|
+/* 消息文件样式 */
|
|
|
+.message-files {
|
|
|
+ margin-top: 8px;
|
|
|
+ display: flex;
|
|
|
+ flex-direction: row;
|
|
|
+ gap: 6px;
|
|
|
+ flex-wrap: wrap;
|
|
|
+ overflow: visible;
|
|
|
+}
|
|
|
+
|
|
|
+.file-item {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ gap: 4px;
|
|
|
+ padding: 4px 6px;
|
|
|
+ background: rgba(255, 255, 255, 0.1);
|
|
|
+ border-radius: 4px;
|
|
|
+ cursor: pointer;
|
|
|
+ transition: all 0.2s ease;
|
|
|
+ font-size: 11px;
|
|
|
+ flex-shrink: 0;
|
|
|
+ min-width: 0;
|
|
|
+
|
|
|
+ &:hover {
|
|
|
+ background: rgba(255, 255, 255, 0.2);
|
|
|
+ }
|
|
|
+
|
|
|
+ .file-icon {
|
|
|
+ flex-shrink: 0;
|
|
|
+ font-size: 12px;
|
|
|
+ color: rgba(255, 255, 255, 0.8);
|
|
|
+ }
|
|
|
+
|
|
|
+ .file-name {
|
|
|
+ max-width: 80px;
|
|
|
+ overflow: hidden;
|
|
|
+ text-overflow: ellipsis;
|
|
|
+ white-space: nowrap;
|
|
|
+ color: rgba(255, 255, 255, 0.9);
|
|
|
+ }
|
|
|
+
|
|
|
+ .file-size {
|
|
|
+ flex-shrink: 0;
|
|
|
+ color: rgba(255, 255, 255, 0.6);
|
|
|
+ font-size: 10px;
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+.message-avatar {
|
|
|
+ flex-shrink: 0;
|
|
|
+ margin-top: 2px;
|
|
|
+}
|
|
|
+
|
|
|
+/* 加载动画样式 */
|
|
|
+.loading-container {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ gap: 12px;
|
|
|
+ padding: 8px 0;
|
|
|
+ color: var(--el-text-color-secondary);
|
|
|
+}
|
|
|
+
|
|
|
+.loading-dots {
|
|
|
+ display: flex;
|
|
|
+ gap: 4px;
|
|
|
+}
|
|
|
+
|
|
|
+.dot {
|
|
|
+ width: 6px;
|
|
|
+ height: 6px;
|
|
|
+ border-radius: 50%;
|
|
|
+ background-color: var(--el-color-primary);
|
|
|
+ animation: loading-bounce 1.4s ease-in-out infinite both;
|
|
|
+}
|
|
|
+
|
|
|
+.dot:nth-child(1) {
|
|
|
+ animation-delay: -0.32s;
|
|
|
+}
|
|
|
+
|
|
|
+.dot:nth-child(2) {
|
|
|
+ animation-delay: -0.16s;
|
|
|
+}
|
|
|
+
|
|
|
+.dot:nth-child(3) {
|
|
|
+ animation-delay: 0s;
|
|
|
+}
|
|
|
+
|
|
|
+@keyframes loading-bounce {
|
|
|
+ 0%,
|
|
|
+ 80%,
|
|
|
+ 100% {
|
|
|
+ transform: scale(0.8);
|
|
|
+ opacity: 0.5;
|
|
|
+ }
|
|
|
+ 40% {
|
|
|
+ transform: scale(1);
|
|
|
+ opacity: 1;
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+.loading-text {
|
|
|
+ font-size: 14px;
|
|
|
+ font-weight: 500;
|
|
|
+}
|
|
|
+
|
|
|
+.tools-button {
|
|
|
+ margin-bottom: 10px;
|
|
|
+ color: var(--el-text-color-regular);
|
|
|
+}
|
|
|
+
|
|
|
+.message-input {
|
|
|
+ margin-bottom: 12px;
|
|
|
+
|
|
|
+ :deep(.el-textarea__inner) {
|
|
|
+ border-radius: 8px;
|
|
|
+ resize: none;
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+.input-actions {
|
|
|
+ display: flex;
|
|
|
+ justify-content: flex-end;
|
|
|
+ gap: 12px;
|
|
|
+}
|
|
|
+
|
|
|
+/* Markdown 内容样式优化 - 接入Element Plus颜色系统 */
|
|
|
+:deep(.markdown-content) {
|
|
|
+ /* 标题样式 */
|
|
|
+ h1,
|
|
|
+ h2,
|
|
|
+ h3,
|
|
|
+ h4,
|
|
|
+ h5,
|
|
|
+ h6 {
|
|
|
+ margin-top: 24px;
|
|
|
+ margin-bottom: 16px;
|
|
|
+ font-weight: 600;
|
|
|
+ line-height: 1.25;
|
|
|
+ color: var(--el-text-color-primary);
|
|
|
+ }
|
|
|
+
|
|
|
+ h1 {
|
|
|
+ font-size: 2em;
|
|
|
+ border-bottom: 1px solid var(--el-border-color-light);
|
|
|
+ padding-bottom: 8px;
|
|
|
+ }
|
|
|
+
|
|
|
+ h2 {
|
|
|
+ font-size: 1.5em;
|
|
|
+ border-bottom: 1px solid var(--el-border-color-lighter);
|
|
|
+ padding-bottom: 6px;
|
|
|
+ }
|
|
|
+
|
|
|
+ h3 {
|
|
|
+ font-size: 1.25em;
|
|
|
+ }
|
|
|
+
|
|
|
+ /* 段落样式 */
|
|
|
+ p {
|
|
|
+ margin-bottom: 16px;
|
|
|
+ line-height: 1.6;
|
|
|
+ color: var(--el-text-color-regular);
|
|
|
+ }
|
|
|
+
|
|
|
+ /* 代码块样式 */
|
|
|
+ pre {
|
|
|
+ background-color: var(--el-fill-color-light);
|
|
|
+ border: 1px solid var(--el-border-color-light);
|
|
|
+ border-radius: 6px;
|
|
|
+ padding: 16px;
|
|
|
+ overflow: auto;
|
|
|
+ margin: 16px 0;
|
|
|
+ color: var(--el-text-color-primary);
|
|
|
+ }
|
|
|
+
|
|
|
+ /* 行内代码样式 */
|
|
|
+ code {
|
|
|
+ background-color: var(--el-fill-color-light);
|
|
|
+ color: var(--el-color-danger);
|
|
|
+ padding: 2px 4px;
|
|
|
+ border-radius: 3px;
|
|
|
+ font-size: 85%;
|
|
|
+ border: 1px solid var(--el-border-color-lighter);
|
|
|
+ }
|
|
|
+
|
|
|
+ /* 引用块样式 */
|
|
|
+ blockquote {
|
|
|
+ border-left: 4px solid var(--el-color-primary);
|
|
|
+ padding-left: 16px;
|
|
|
+ margin: 16px 0;
|
|
|
+ color: var(--el-text-color-secondary);
|
|
|
+ background-color: var(--el-fill-color-extra-light);
|
|
|
+ padding: 12px 16px;
|
|
|
+ border-radius: 4px;
|
|
|
+ }
|
|
|
+
|
|
|
+ /* 列表样式 */
|
|
|
+ ul,
|
|
|
+ ol {
|
|
|
+ margin: 16px 0;
|
|
|
+ padding-left: 32px;
|
|
|
+ color: var(--el-text-color-regular);
|
|
|
+ }
|
|
|
+
|
|
|
+ li {
|
|
|
+ margin: 4px 0;
|
|
|
+ line-height: 1.6;
|
|
|
+ }
|
|
|
+
|
|
|
+ /* 表格样式 */
|
|
|
+ table {
|
|
|
+ border-collapse: collapse;
|
|
|
+ margin: 16px 0;
|
|
|
+ width: 100%;
|
|
|
+ border: 1px solid var(--el-border-color-light);
|
|
|
+ }
|
|
|
+
|
|
|
+ th,
|
|
|
+ td {
|
|
|
+ border: 1px solid var(--el-border-color-light);
|
|
|
+ padding: 8px 12px;
|
|
|
+ text-align: left;
|
|
|
+ }
|
|
|
+
|
|
|
+ th {
|
|
|
+ background-color: var(--el-fill-color-light);
|
|
|
+ font-weight: 600;
|
|
|
+ color: var(--el-text-color-primary);
|
|
|
+ }
|
|
|
+
|
|
|
+ td {
|
|
|
+ color: var(--el-text-color-regular);
|
|
|
+ }
|
|
|
+
|
|
|
+ /* 分割线样式 */
|
|
|
+ hr {
|
|
|
+ border: none;
|
|
|
+ border-top: 1px solid var(--el-border-color-light);
|
|
|
+ margin: 24px 0;
|
|
|
+ }
|
|
|
+
|
|
|
+ /* 链接样式 */
|
|
|
+ a {
|
|
|
+ color: var(--el-color-primary);
|
|
|
+ text-decoration: none;
|
|
|
+
|
|
|
+ &:hover {
|
|
|
+ color: var(--el-color-primary-light-3);
|
|
|
+ text-decoration: underline;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /* 强调文本样式 */
|
|
|
+ strong {
|
|
|
+ font-weight: 600;
|
|
|
+ color: var(--el-text-color-primary);
|
|
|
+ }
|
|
|
+
|
|
|
+ em {
|
|
|
+ font-style: italic;
|
|
|
+ color: var(--el-text-color-regular);
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+/* 新的输入容器样式 */
|
|
|
+.input-container {
|
|
|
+ position: absolute;
|
|
|
+ width: 90%;
|
|
|
+ left: 50%;
|
|
|
+ bottom: 25px;
|
|
|
+ transform: translate(-50%, 0);
|
|
|
+ background: var(--el-bg-color);
|
|
|
+ border: 1px solid var(--el-border-color-light);
|
|
|
+ border-radius: 8px;
|
|
|
+ padding: 0;
|
|
|
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
|
|
+}
|
|
|
+
|
|
|
+/* 附件栏(内嵌)样式 */
|
|
|
+.attachments-inline {
|
|
|
+ width: 100%;
|
|
|
+ padding: 4px;
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ gap: 8px;
|
|
|
+ background: var(--el-fill-color-extra-light);
|
|
|
+ border-radius: 8px 8px 0 0;
|
|
|
+ border-bottom: 1px solid var(--el-border-color-lighter);
|
|
|
+}
|
|
|
+
|
|
|
+.attachments-inline-scroll {
|
|
|
+ flex: 1;
|
|
|
+ //overflow-x: auto;
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ gap: 8px;
|
|
|
+ background: var(--el-fill-color-extra-light);
|
|
|
+ padding: 6px;
|
|
|
+}
|
|
|
+
|
|
|
+.attachment-card {
|
|
|
+ position: relative;
|
|
|
+ display: inline-flex;
|
|
|
+ align-items: center;
|
|
|
+ gap: 0;
|
|
|
+}
|
|
|
+
|
|
|
+.attachment-item {
|
|
|
+ /* 对齐下方控制按钮的尺寸与风格 */
|
|
|
+ max-width: 260px;
|
|
|
+ white-space: nowrap;
|
|
|
+ overflow: hidden;
|
|
|
+ text-overflow: ellipsis;
|
|
|
+
|
|
|
+ font-size: 10px !important;
|
|
|
+
|
|
|
+ padding: 4px 8px !important;
|
|
|
+}
|
|
|
+
|
|
|
+.attachment-name {
|
|
|
+ max-width: 200px;
|
|
|
+ white-space: nowrap;
|
|
|
+ overflow: hidden;
|
|
|
+ text-overflow: ellipsis;
|
|
|
+}
|
|
|
+
|
|
|
+.add-attachment-btn {
|
|
|
+ flex-shrink: 0;
|
|
|
+ font-size: 10px !important;
|
|
|
+ margin: 5px;
|
|
|
+}
|
|
|
+
|
|
|
+.attachment-hint {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: center;
|
|
|
+ padding: 4px;
|
|
|
+ color: var(--el-text-color-placeholder);
|
|
|
+ font-size: 12px;
|
|
|
+ opacity: 0.8;
|
|
|
+ height: 21.5px;
|
|
|
+ line-height: 1;
|
|
|
+}
|
|
|
+
|
|
|
+.remove-attachment-icon {
|
|
|
+ position: absolute;
|
|
|
+ top: -5px;
|
|
|
+ right: -5px;
|
|
|
+ width: 15px;
|
|
|
+ height: 15px;
|
|
|
+ border-radius: 50%;
|
|
|
+ border: 1px solid var(--el-border-color-light);
|
|
|
+ background: var(--el-bg-color);
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: center;
|
|
|
+ cursor: pointer;
|
|
|
+ color: var(--el-text-color-regular);
|
|
|
+ padding: 0;
|
|
|
+}
|
|
|
+
|
|
|
+.large-input-container {
|
|
|
+ position: relative;
|
|
|
+ width: 100%;
|
|
|
+}
|
|
|
+
|
|
|
+.large-textarea {
|
|
|
+ width: 100%;
|
|
|
+ min-height: 120px;
|
|
|
+ padding: 16px 16px 60px 16px;
|
|
|
+ border: none;
|
|
|
+ outline: none;
|
|
|
+ resize: none;
|
|
|
+ font-size: 14px;
|
|
|
+ line-height: 1.5;
|
|
|
+ background: transparent;
|
|
|
+ color: var(--el-text-color-primary);
|
|
|
+ border-radius: 8px;
|
|
|
+ font-family: inherit;
|
|
|
+}
|
|
|
+
|
|
|
+.large-textarea::placeholder {
|
|
|
+ color: var(--el-text-color-placeholder);
|
|
|
+}
|
|
|
+
|
|
|
+.large-textarea:disabled {
|
|
|
+ color: var(--el-text-color-disabled);
|
|
|
+ cursor: not-allowed;
|
|
|
+}
|
|
|
+
|
|
|
+.embedded-controls {
|
|
|
+ position: absolute;
|
|
|
+ bottom: 0;
|
|
|
+ left: 0;
|
|
|
+ right: 0;
|
|
|
+ display: flex;
|
|
|
+ justify-content: space-between;
|
|
|
+ align-items: center;
|
|
|
+ padding: 12px 16px;
|
|
|
+ border-top: 1px solid var(--el-border-color-lighter);
|
|
|
+ background: var(--el-fill-color-extra-light);
|
|
|
+ border-radius: 0 0 8px 8px;
|
|
|
+}
|
|
|
+
|
|
|
+.left-controls,
|
|
|
+.right-controls {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ gap: 8px;
|
|
|
+}
|
|
|
+
|
|
|
+.control-btn {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ gap: 6px;
|
|
|
+ padding: 6px 12px;
|
|
|
+ border: 1px solid var(--el-border-color-light);
|
|
|
+ border-radius: 6px;
|
|
|
+ background: var(--el-bg-color);
|
|
|
+ color: var(--el-text-color-regular);
|
|
|
+ font-size: 12px;
|
|
|
+ cursor: pointer;
|
|
|
+ transition: all 0.2s ease;
|
|
|
+ white-space: nowrap;
|
|
|
+}
|
|
|
+
|
|
|
+.control-btn:hover:not(:disabled) {
|
|
|
+ background: var(--el-fill-color-light);
|
|
|
+ border-color: var(--el-color-primary-light-7);
|
|
|
+ color: var(--el-color-primary);
|
|
|
+}
|
|
|
+
|
|
|
+.control-btn:disabled {
|
|
|
+ opacity: 0.5;
|
|
|
+ cursor: not-allowed;
|
|
|
+}
|
|
|
+
|
|
|
+.control-btn .el-icon {
|
|
|
+ font-size: 14px;
|
|
|
+}
|
|
|
+
|
|
|
+/* 特定按钮样式 */
|
|
|
+.send-btn {
|
|
|
+ background: var(--el-color-primary);
|
|
|
+ color: white;
|
|
|
+ border-color: var(--el-color-primary);
|
|
|
+}
|
|
|
+
|
|
|
+.send-btn:hover:not(:disabled) {
|
|
|
+ background: var(--el-color-primary-light-3);
|
|
|
+ border-color: var(--el-color-primary-light-3);
|
|
|
+}
|
|
|
+
|
|
|
+.stop-btn {
|
|
|
+ background: var(--el-color-danger);
|
|
|
+ color: white;
|
|
|
+ border-color: var(--el-color-danger);
|
|
|
+}
|
|
|
+
|
|
|
+.stop-btn:hover:not(:disabled) {
|
|
|
+ background: var(--el-color-danger-light-3);
|
|
|
+ border-color: var(--el-color-danger-light-3);
|
|
|
+}
|
|
|
+
|
|
|
+.clear-btn:hover:not(:disabled) {
|
|
|
+ background: var(--el-color-warning-light-9);
|
|
|
+ border-color: var(--el-color-warning-light-7);
|
|
|
+ color: var(--el-color-warning);
|
|
|
+}
|
|
|
+
|
|
|
+/* 下拉菜单样式 */
|
|
|
+:deep(.el-dropdown-menu) {
|
|
|
+ .el-dropdown-menu__item.is-selected {
|
|
|
+ background: var(--el-color-primary-light-9);
|
|
|
+ color: var(--el-color-primary);
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+/* 对话框样式 */
|
|
|
+.dialog-footer {
|
|
|
+ text-align: right;
|
|
|
+}
|
|
|
+
|
|
|
+/* 提示词对话框样式 */
|
|
|
+.prompt-dialog-content {
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ gap: 24px;
|
|
|
+ max-height: 600px;
|
|
|
+}
|
|
|
+
|
|
|
+.prompt-input-section {
|
|
|
+ h4 {
|
|
|
+ margin: 0 0 12px 0;
|
|
|
+ color: var(--el-text-color-primary);
|
|
|
+ font-size: 14px;
|
|
|
+ font-weight: 600;
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+.prompt-list-section {
|
|
|
+ border-top: 1px solid var(--el-border-color-lighter);
|
|
|
+ padding-top: 20px;
|
|
|
+
|
|
|
+ h4 {
|
|
|
+ margin: 0 0 16px 0;
|
|
|
+ color: var(--el-text-color-primary);
|
|
|
+ font-size: 14px;
|
|
|
+ font-weight: 600;
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+.prompt-list-header {
|
|
|
+ display: flex;
|
|
|
+ justify-content: space-between;
|
|
|
+ align-items: center;
|
|
|
+ margin-bottom: 16px;
|
|
|
+
|
|
|
+ .prompt-search {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ gap: 8px;
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+.prompt-list-container {
|
|
|
+ min-height: 200px;
|
|
|
+ max-height: 300px;
|
|
|
+ overflow-y: auto;
|
|
|
+ border: 1px solid var(--el-border-color-light);
|
|
|
+ border-radius: 6px;
|
|
|
+ background: var(--el-fill-color-extra-light);
|
|
|
+}
|
|
|
+
|
|
|
+.prompt-list-empty {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: center;
|
|
|
+ height: 200px;
|
|
|
+}
|
|
|
+
|
|
|
+.prompt-list {
|
|
|
+ padding: 8px;
|
|
|
+}
|
|
|
+
|
|
|
+.prompt-item {
|
|
|
+ padding: 12px;
|
|
|
+ margin-bottom: 8px;
|
|
|
+ border: 1px solid var(--el-border-color-lighter);
|
|
|
+ border-radius: 6px;
|
|
|
+ background: var(--el-bg-color);
|
|
|
+ cursor: pointer;
|
|
|
+ transition: all 0.2s ease;
|
|
|
+
|
|
|
+ &:hover {
|
|
|
+ border-color: var(--el-color-primary-light-7);
|
|
|
+ background: var(--el-color-primary-light-9);
|
|
|
+ transform: translateY(-1px);
|
|
|
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
|
|
+ }
|
|
|
+
|
|
|
+ &:last-child {
|
|
|
+ margin-bottom: 0;
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+.prompt-item-header {
|
|
|
+ display: flex;
|
|
|
+ justify-content: space-between;
|
|
|
+ align-items: center;
|
|
|
+ margin-bottom: 8px;
|
|
|
+}
|
|
|
+
|
|
|
+.prompt-title {
|
|
|
+ font-weight: 600;
|
|
|
+ color: var(--el-text-color-primary);
|
|
|
+ font-size: 14px;
|
|
|
+}
|
|
|
+
|
|
|
+.prompt-preview {
|
|
|
+ color: var(--el-text-color-regular);
|
|
|
+ font-size: 12px;
|
|
|
+ line-height: 1.4;
|
|
|
+ word-break: break-all;
|
|
|
+}
|
|
|
+
|
|
|
+.prompt-pagination {
|
|
|
+ margin-top: 16px;
|
|
|
+ display: flex;
|
|
|
+ justify-content: center;
|
|
|
+}
|
|
|
+
|
|
|
+/* 响应式调整 */
|
|
|
+/* 空状态页面样式 */
|
|
|
+.empty-content {
|
|
|
+ text-align: center;
|
|
|
+ max-width: 600px;
|
|
|
+ width: 100%;
|
|
|
+
|
|
|
+ position: absolute;
|
|
|
+ left: 50%;
|
|
|
+ top: 50%;
|
|
|
+ transform: translate(-50%, -50%);
|
|
|
+}
|
|
|
+
|
|
|
+.empty-icon {
|
|
|
+ margin-bottom: 24px;
|
|
|
+ opacity: 0.6;
|
|
|
+}
|
|
|
+
|
|
|
+.empty-text {
|
|
|
+ margin-bottom: 40px;
|
|
|
+}
|
|
|
+
|
|
|
+.empty-title {
|
|
|
+ font-size: 28px;
|
|
|
+ font-weight: 600;
|
|
|
+ color: var(--el-text-color-primary);
|
|
|
+ margin: 0 0 12px 0;
|
|
|
+}
|
|
|
+
|
|
|
+.empty-description {
|
|
|
+ font-size: 16px;
|
|
|
+ color: var(--el-text-color-regular);
|
|
|
+ margin: 0;
|
|
|
+ line-height: 1.5;
|
|
|
+}
|
|
|
+
|
|
|
+.quick-start {
|
|
|
+ display: flex;
|
|
|
+ justify-content: center;
|
|
|
+ gap: 40px;
|
|
|
+ margin-bottom: 40px;
|
|
|
+ flex-wrap: wrap;
|
|
|
+}
|
|
|
+
|
|
|
+.quick-start-item {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ gap: 12px;
|
|
|
+ color: var(--el-text-color-regular);
|
|
|
+}
|
|
|
+
|
|
|
+.step-number {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: center;
|
|
|
+ width: 24px;
|
|
|
+ height: 24px;
|
|
|
+ background: var(--el-color-primary);
|
|
|
+ color: white;
|
|
|
+ border-radius: 50%;
|
|
|
+ font-size: 12px;
|
|
|
+ font-weight: 600;
|
|
|
+ flex-shrink: 0;
|
|
|
+}
|
|
|
+
|
|
|
+.step-text {
|
|
|
+ font-size: 14px;
|
|
|
+ font-weight: 500;
|
|
|
+}
|
|
|
+
|
|
|
+.example-questions {
|
|
|
+ h4 {
|
|
|
+ font-size: 16px;
|
|
|
+ font-weight: 600;
|
|
|
+ color: var(--el-text-color-primary);
|
|
|
+ margin: 0 0 16px 0;
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+.question-tags {
|
|
|
+ display: flex;
|
|
|
+ flex-wrap: wrap;
|
|
|
+ gap: 12px;
|
|
|
+ justify-content: center;
|
|
|
+}
|
|
|
+
|
|
|
+.question-tag {
|
|
|
+ cursor: pointer;
|
|
|
+ transition: all 0.2s ease;
|
|
|
+ font-size: 13px;
|
|
|
+ padding: 8px 16px;
|
|
|
+ border-radius: 20px;
|
|
|
+
|
|
|
+ &:hover {
|
|
|
+ transform: translateY(-1px);
|
|
|
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+/* 收藏面板底部栏样式 */
|
|
|
+.bookmark-footer {
|
|
|
+ border-radius: 8px;
|
|
|
+ padding: 16px;
|
|
|
+
|
|
|
+ .bookmark-controls {
|
|
|
+ display: flex;
|
|
|
+ justify-content: space-between;
|
|
|
+ align-items: center;
|
|
|
+ gap: 16px;
|
|
|
+ width: 100%;
|
|
|
+ }
|
|
|
+
|
|
|
+ .bookmark-search {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ gap: 12px;
|
|
|
+ }
|
|
|
+
|
|
|
+ .bookmark-pagination {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ gap: 16px;
|
|
|
+ flex-wrap: wrap;
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+/* 响应式调整 */
|
|
|
+@media (max-width: 768px) {
|
|
|
+ .bookmark-footer {
|
|
|
+ .bookmark-controls {
|
|
|
+ flex-direction: column;
|
|
|
+ gap: 12px;
|
|
|
+ }
|
|
|
+
|
|
|
+ .bookmark-search {
|
|
|
+ width: 100%;
|
|
|
+ justify-content: center;
|
|
|
+
|
|
|
+ .el-input {
|
|
|
+ width: 100% !important;
|
|
|
+ max-width: 300px;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ .bookmark-pagination {
|
|
|
+ width: 100%;
|
|
|
+ justify-content: center;
|
|
|
+ flex-direction: column;
|
|
|
+ gap: 8px;
|
|
|
+
|
|
|
+ .el-pagination {
|
|
|
+ justify-content: center;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// 无限旋转动画
|
|
|
+@keyframes spin {
|
|
|
+ to {
|
|
|
+ transform: rotate(360deg);
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// 基础旋转类
|
|
|
+.spin {
|
|
|
+ display: inline-block;
|
|
|
+ animation: spin 1s linear infinite;
|
|
|
+ transform-origin: center center;
|
|
|
+
|
|
|
+ // 变体:慢速
|
|
|
+ &-slow {
|
|
|
+ animation-duration: 3s;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 变体:快速
|
|
|
+ &-fast {
|
|
|
+ animation-duration: 0.6s;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 变体:反向
|
|
|
+ &-rev {
|
|
|
+ animation-direction: reverse;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 变体:悬停暂停
|
|
|
+ &-pause:hover {
|
|
|
+ animation-play-state: paused;
|
|
|
+ }
|
|
|
+}
|
|
|
+</style>
|