Bläddra i källkod

Merge branch 'feature-mcp' into feature-workflow-and-mcp

# Conflicts:
#	package.json
#	src/i18n/index.ts
#	yarn.lock
kagg886 1 månad sedan
förälder
incheckning
7fc8720365
33 ändrade filer med 8848 tillägg och 530 borttagningar
  1. 3 0
      package.json
  2. 377 0
      src/api/assist/index.ts
  3. 227 0
      src/api/assist/type.ts
  4. 38 0
      src/api/common/index.ts
  5. 8 0
      src/api/common/type.ts
  6. 356 0
      src/components/assistant/ComponentLibrary.vue
  7. 212 0
      src/components/assistant/DashboardDesigner.vue
  8. 118 0
      src/components/assistant/DashboardViewer.vue
  9. 710 0
      src/components/assistant/DraggableCard.vue
  10. 134 0
      src/components/assistant/ViewerCard.vue
  11. 76 0
      src/components/assistant/types.ts
  12. 133 0
      src/components/markdown/Markdown.vue
  13. 74 0
      src/components/markdown/plugins/echarts.ts
  14. 190 0
      src/components/markdown/plugins/impl/ToolsLoadingCard.vue
  15. 113 0
      src/components/markdown/plugins/impl/VueCharts.vue
  16. 380 0
      src/components/markdown/plugins/impl/VueStructData.vue
  17. 225 0
      src/components/markdown/plugins/impl/VueTable.vue
  18. 60 0
      src/components/markdown/plugins/struct-data.ts
  19. 163 0
      src/components/markdown/plugins/table.ts
  20. 62 0
      src/components/markdown/plugins/tools-loading.ts
  21. 23 0
      src/components/markdown/type/markdown.ts
  22. 10 0
      src/i18n/index.ts
  23. 253 0
      src/i18n/pages/assistant/en.ts
  24. 253 0
      src/i18n/pages/assistant/zh-cn.ts
  25. 253 0
      src/i18n/pages/assistant/zh-tw.ts
  26. 17 20
      src/utils/loading-util.ts
  27. 567 0
      src/views/assistant/dashboard/edit.vue
  28. 306 0
      src/views/assistant/dashboard/index.vue
  29. 27 0
      src/views/assistant/dashboard/view.vue
  30. 2530 0
      src/views/assistant/index.vue
  31. 459 0
      src/views/assistant/model.vue
  32. 391 0
      src/views/assistant/prompt.vue
  33. 100 510
      yarn.lock

+ 3 - 0
package.json

@@ -46,10 +46,12 @@
     "element-plus": "2.2.28",
     "event-source-polyfill": "1.0.31",
     "html2canvas": "^1.4.1",
+    "htmlparser2": "^10.0.0",
     "js-cookie": "3.0.5",
     "jsplumb": "2.15.6",
     "jsrsasign": "10.8.6",
     "loadsh": "0.0.4",
+    "markdown-it": "^14.1.0",
     "mitt": "3.0.0",
     "monaco-editor": "^0.52.2",
     "nprogress": "0.2.0",
@@ -83,6 +85,7 @@
   "devDependencies": {
     "@types/downloadjs": "^1.4.6",
     "@types/js-cookie": "3.0.6",
+    "@types/markdown-it": "^14.1.2",
     "@types/node": "17.0.21",
     "@types/nprogress": "0.2.0",
     "@types/sortablejs": "1.10.7",

+ 377 - 0
src/api/assist/index.ts

@@ -0,0 +1,377 @@
+import {
+	ChatRequest,
+	ChatResponse,
+	ErrorResponse,
+	LmConfigListParams,
+	LmConfigAddReq,
+	LmConfigEditReq,
+	LmConfigStatusReq,
+	LmConfigDeleteParams,
+	LmConfigGetParams,
+	SessionMessagesListParams,
+	SessionMessagesSaveReq,
+	LmDashboard,
+	Prompt,
+	PromptListParams,
+	PromptAddReq,
+	PromptEditReq,
+	PromptDeleteParams,
+	PromptGetParams,
+} from '/@/api/assist/type'
+import { get, post, del, put } from '/@/utils/request'
+import getOrigin from '/@/utils/origin'
+import { getToken } from '/@/utils/auth'
+
+export default {
+	model: {
+		// 大语言模型配置列表
+		getList: (params?: LmConfigListParams) => get('/ai/lmconfig/list', params),
+		// 获取大语言模型配置信息
+		detail: (params: LmConfigGetParams) => get('/ai/lmconfig/get', params),
+		// 添加大语言模型配置
+		add: (data: LmConfigAddReq) => post('/ai/lmconfig/add', data),
+		// 修改大语言模型配置
+		edit: (data: LmConfigEditReq) => put('/ai/lmconfig/edit', data),
+		// 删除大语言模型配置
+		del: (params: LmConfigDeleteParams) => del('/ai/lmconfig/delete', params),
+		// 设置大语言模型配置状态
+		setStatus: (data: LmConfigStatusReq) => put('/ai/lmconfig/status', data),
+	},
+
+	dashboard: {
+		// 获取仪表盘列表
+		list: (params: any) => get('/ai/lmdashboards/list', params),
+		// 获取仪表盘详情
+		detail: (id: number) => get('/ai/lmdashboards/get', { id }),
+		// 添加仪表盘
+		add: (data: Omit<LmDashboard, 'id'>) => post('/ai/lmdashboards/add', data),
+		// 编辑仪表盘
+		edit: (data: LmDashboard) => put('/ai/lmdashboards/edit', data),
+		// 删除仪表盘
+		del: (ids: number[]) => del('/ai/lmdashboards/delete', { ids }),
+	},
+
+	session: {
+		list: (params: any) => get('/ai/lmsessions/list', params),
+		// 添加大模型会话
+		add: (title: string): Promise<{ id: number }> => post('/ai/lmsessions/add', { title }),
+		// 删除大模型会话
+		del: (ids: number[]) => del('/ai/lmsessions/delete', { ids }),
+
+		edit: (id: number, title: string): Promise<void> => put('/ai/lmsessions/edit', { sessionId: id, title }),
+
+		message: {
+			// 获取会话消息列表
+			list: (params: SessionMessagesListParams) => get('/ai/lmsessions/messages/list', params),
+			// 保存会话消息
+			save: (data: SessionMessagesSaveReq) => post('/ai/lmsessions/messages/save', data),
+
+			bookmark: (sessionId: number, messageId: number, like: boolean) =>
+				put('/ai/lmsessions/messages/like', {
+					sessionId,
+					messageId,
+					like,
+				}),
+			bookmark_list: (param: { keyWord?: string; pageNum?: number; pageSize?: number } = {}) => get('/ai/lmsessions/messages/like/list', param),
+		},
+	},
+
+
+	chat: {
+		prompt: {
+			// Prompt列表
+			list: (params?: PromptListParams) => get('/ai/lmprompt/list', params),
+			// 获取Prompt详情
+			detail: (params: PromptGetParams) => get('/ai/lmprompt/get', params),
+			// 添加Prompt
+			add: (data: PromptAddReq) => post('/ai/lmprompt/add', data),
+			// 编辑Prompt
+			edit: (data: PromptEditReq) => put('/ai/lmprompt/edit', data),
+			// 删除Prompt
+			del: (params: PromptDeleteParams) => del('/ai/lmprompt/delete', params),
+		},
+		struct: (uuid: string, refresh?: boolean) => post('/ai/chat/structdata', { uuid,refresh }),
+		sse: (data: {
+			chatRequest: ChatRequest
+			// eslint-disable-next-line no-unused-vars
+			onReceive: (resp: ChatResponse) => void
+
+			// eslint-disable-next-line no-unused-vars
+			onComplete?: (error: Error | undefined) => void
+		}) => {
+			const { chatRequest, onReceive, onComplete } = data
+
+			//FIXME 需要抹掉
+			chatRequest.modelMcpId = 1 as unknown as number[]
+			chatRequest['UserId'] = 10
+
+			//FIXME 后端没有处理id的能力
+			chatRequest.message.map((value,index)=>{
+				value.id = index
+			})
+
+			// 构建SSE URL
+			const baseURL = getOrigin()
+			const url = `${baseURL}/ai/chat`
+
+			// 使用fetch API实现SSE POST请求(EventSource不支持POST和自定义headers)
+			const controller = new AbortController()
+
+			// 使用fetch API实现SSE POST请求
+			const startSSE = async () => {
+				try {
+					const headers: Record<string, string> = {
+						'Content-Type': 'application/json',
+						Accept: 'text/event-stream',
+						'Cache-Control': 'no-cache',
+					}
+
+					// 添加认证token
+					const token = getToken()
+					if (token) {
+						headers['Authorization'] = `Bearer ${token}`
+					}
+
+					const response = await fetch(url, {
+						method: 'POST',
+						headers,
+						body: JSON.stringify(chatRequest),
+						signal: controller.signal,
+					}).catch((e: Error) => e)
+
+					if (response instanceof Error) {
+						throw new Error('无法连接到服务器')
+					}
+
+					if (!response.ok) {
+						throw new Error(`HTTP error! status: ${response.status}`)
+					}
+
+					const reader = response.body?.getReader()
+					const decoder = new TextDecoder()
+
+					if (!reader) {
+						throw new Error('Response body is not readable')
+					}
+
+					// 读取SSE流
+					let currentEvent = ''
+					let buffer = ''
+
+					while (true) {
+						const { done, value } = await reader.read()
+
+						if (done) {
+							break
+						}
+
+						buffer += decoder.decode(value, { stream: true })
+
+						// 按双换行符分割事件块
+						const eventBlocks = buffer.split('\n\n')
+
+						// 保留最后一个可能不完整的事件块
+						buffer = eventBlocks.pop() || ''
+
+						for (const eventBlock of eventBlocks) {
+							if (!eventBlock.trim()) continue
+
+							const lines = eventBlock.split('\n')
+							let eventType = ''
+							let dataLines: string[] = []
+
+							// 解析事件块中的每一行
+							for (const line of lines) {
+								const trimmedLine = line.trim()
+
+								if (trimmedLine.startsWith('event:')) {
+									eventType = trimmedLine.substring(6).trim()
+								} else if (trimmedLine.startsWith('data:')) {
+									// 提取data后的内容,保留原始格式(包括可能的空格)
+									const dataContent = line.substring(line.indexOf('data:') + 5)
+									dataLines.push(dataContent)
+								}
+							}
+
+							// 如果有事件类型,更新当前事件
+							if (eventType) {
+								currentEvent = eventType
+							}
+
+							// 如果有数据行,处理数据
+							if (dataLines.length > 0) {
+								// 将多行data合并,用换行符连接
+								const data = dataLines.join('\n')
+
+								// 根据事件类型处理数据
+								switch (currentEvent) {
+									case 'message': {
+										const messageResponse: ChatResponse = {
+											type: 'message',
+											message: data,
+										}
+										onReceive(messageResponse)
+										break
+									}
+
+									case 'toolcall': {
+										const tools = JSON.parse(data)
+										const toolcallResponse: ChatResponse = {
+											type: 'toolcall',
+											request: {
+												id: tools['tool_call_id'],
+												name: tools['name'],
+												data: tools['arguments'],
+											},
+										}
+										onReceive(toolcallResponse)
+										break
+									}
+
+									case 'structdata': {
+										const structDataResponse: ChatResponse = {
+											type: 'structdata',
+											uuid: data,
+										}
+										onReceive(structDataResponse)
+										break
+									}
+
+									case 'toolres': {
+										const tools = JSON.parse(data)
+										const toolresResponse: ChatResponse = {
+											type: 'toolres',
+											response: {
+												id: tools['tool_call_id'],
+												name: tools['name'],
+												data: tools['response'],
+											},
+										}
+										onReceive(toolresResponse)
+										break
+									}
+
+									case 'error': {
+										const errorResponse: ErrorResponse = {
+											type: 'error',
+											error: data,
+										}
+										onReceive(errorResponse)
+										break
+									}
+
+									case 'meta': {
+										break
+									}
+
+									default: {
+										// 如果没有明确的事件类型,默认作为消息处理
+										const defaultResponse: ChatResponse = {
+											type: 'message',
+											message: data,
+										}
+										onReceive(defaultResponse)
+										break
+									}
+								}
+							}
+						}
+
+						// 处理buffer中剩余的可能完整的单行事件
+						const remainingLines = buffer.split('\n')
+						const completeLines = remainingLines.slice(0, -1)
+						buffer = remainingLines[remainingLines.length - 1] || ''
+
+						for (const line of completeLines) {
+							const trimmedLine = line.trim()
+
+							if (trimmedLine === '') {
+								continue
+							}
+
+							if (trimmedLine.startsWith('event:')) {
+								currentEvent = trimmedLine.substring(6).trim()
+								continue
+							}
+
+							if (trimmedLine.startsWith('data:')) {
+								const data = line.substring(line.indexOf('data:') + 5)
+
+								// 根据事件类型处理数据
+								switch (currentEvent) {
+									case 'message': {
+										const messageResponse: ChatResponse = {
+											type: 'message',
+											message: data,
+										}
+										onReceive(messageResponse)
+										break
+									}
+
+									case 'toolcall': {
+										try {
+											const tools = JSON.parse(data)
+											const toolcallResponse: ChatResponse = {
+												type: 'toolcall',
+												request: {
+													name: tools['name'],
+													data: tools['arguments'],
+												},
+											}
+											onReceive(toolcallResponse)
+										} catch (error) {
+											console.error('解析toolcall数据失败:', error, '原始数据:', data)
+										}
+										break
+									}
+
+									case 'toolres': {
+										try {
+											const tools = JSON.parse(data)
+											const toolresResponse: ChatResponse = {
+												type: 'toolres',
+												response: {
+													name: tools['name'],
+													data: tools['response'],
+												},
+											}
+											onReceive(toolresResponse)
+										} catch (error) {
+											console.error('解析toolres数据失败:', error, '原始数据:', data)
+										}
+										break
+									}
+
+									default: {
+										// 如果没有明确的事件类型,默认作为消息处理
+										const defaultResponse: ChatResponse = {
+											type: 'message',
+											message: data,
+										}
+										onReceive(defaultResponse)
+										break
+									}
+								}
+							}
+						}
+					}
+
+					onComplete?.(undefined)
+				} catch (error: any) {
+					if (error.name !== 'AbortError') {
+						console.error('SSE连接错误:', error)
+					}
+					onComplete?.(error)
+				}
+			}
+
+			// 启动SSE连接
+			startSSE()
+
+			// 返回关闭连接的函数
+			return () => {
+				controller.abort()
+			}
+		}
+	},
+}

+ 227 - 0
src/api/assist/type.ts

@@ -0,0 +1,227 @@
+/**
+ * {
+ *     "modelClassId": 41,
+ *     "modelMcpId": 56,
+ *     "message": [
+ *         {
+ *             "role": "user",
+ *             "timestamp": 1719242380731,
+ *             "id": "88",
+ *             "content": "分析一下最近10条登录日志"
+ *         }
+ *     ],
+ *     "UserId": 10
+ * }
+ */
+import { UploadFile } from '/@/api/common/type'
+
+// 消息类型定义
+export type Message = {
+	id: number
+	role: 'user' | 'assistant' | 'system' | 'meta' | 'tool'
+	//仅markdown渲染器需要
+	render_content: string
+	//实际传给AI的内容
+	content: string
+	timestamp: number
+
+	//仅role为tool时需要
+	name?: string
+	tool_call_id?: string
+
+	//仅role为assistant时需要
+	like?: boolean
+	tool_calls?: FunctionCall[]
+
+	//仅role为user时需要
+	files?: Array<UploadFile> | undefined
+}
+
+export type FunctionCall = {
+	id: string
+	type: 'function'
+	function: {
+		name: string
+		arguments: string
+	}
+}
+
+export type ChatRequest = {
+	session_id: number
+	modelClassId?: number
+	modelEmbeddingId?: number
+	modelMcpId?: number[]
+	message: Message[]
+}
+
+export type ChatResponseType = 'message' | 'toolcall' | 'toolres' | 'error' | 'datamsg' | 'structdata'
+
+export type ChatResponseBase<T = ChatResponseType> = {
+	type: T
+}
+
+export type Text = ChatResponseBase<'message'> & {
+	message: string
+}
+
+export type ToolCallRequest = ChatResponseBase<'toolcall'> & {
+	request: {
+		id: string
+		name: string
+		data: string
+	}
+}
+
+export type ToolCallResponse = ChatResponseBase<'toolres'> & {
+	response: {
+		id: string
+		name: string
+		data: string
+	}
+}
+
+export type ErrorResponse = ChatResponseBase<'error'> & {
+	error: string
+}
+
+export type MetaResponse = ChatResponseBase<'meta'> & {
+	meta: string
+}
+
+export type DataResponse = ChatResponseBase<'datamsg'> & {
+	data: string
+}
+
+export type StructDataResponse = ChatResponseBase<'structdata'> & {
+	uuid: string
+}
+
+export type ChatResponse = Text | ToolCallRequest | ToolCallResponse | ErrorResponse | DataResponse | StructDataResponse
+
+export type ModelType = 'embedding' | 'chat'
+// 大语言模型配置相关类型定义
+
+// 大语言模型配置列表查询参数
+export type LmConfigListParams = {
+	keyWord?: string // 搜索关键字
+	dateRange?: string[] // 日期范围
+	OrderBy?: string // 排序
+	pageNum?: number // 分页号码,默认1
+	pageSize?: number // 分页数量,最大500,默认10
+	modelClass?: string // 模型分类
+	modelName?: string // 模型名称
+	modelType?: ModelType // 模型类型
+	status?: string // 是否启用
+	createdAt?: string // 创建时间
+}
+
+// 大语言模型配置基础信息
+export type LmConfigInfo = {
+	id?: number
+	modelClass?: string // 模型分类
+	modelName?: string // 模型名称
+	apiKey?: string // API密钥
+	baseUrl?: string // 基础URL
+	modelType?: ModelType // 模型类型
+	isCallFun?: boolean // 是否调用函数
+	maxToken?: number // 最大令牌数
+	status: boolean // 是否启用
+	createdAt?: string // 创建时间
+	updatedAt?: string // 更新时间
+	createdBy?: number // 创建者ID
+	updatedBy?: number // 更新者ID
+	createdUser?: any // 创建用户信息
+	actionBtn?: any // 操作按钮
+	[key: string]: any // 允许其他字段
+}
+
+// 大语言模型配置添加请求
+export type LmConfigAddReq = Omit<LmConfigInfo, 'id' | 'createdAt' | 'updatedAt'>
+
+// 大语言模型配置编辑请求
+export type LmConfigEditReq = LmConfigInfo & {
+	id: number // 编辑时ID必须
+}
+
+// 大语言模型配置状态设置请求
+export type LmConfigStatusReq = {
+	id: number
+	status: string
+}
+
+// 删除请求参数
+export type LmConfigDeleteParams = {
+	ids: number[]
+}
+
+// 获取单个配置参数
+export type LmConfigGetParams = {
+	id: number
+}
+
+export type LmSession = {
+	session_id: number
+	title: string
+}
+
+// 会话消息列表查询参数
+export type SessionMessagesListParams = {
+	sessionId: number
+}
+
+// 保存会话消息请求
+export type SessionMessagesSaveReq = {
+	sessionId: number
+	messages: Message[]
+}
+
+export type LmDashboard = {
+	id: number
+	title: string
+	data: string
+	remark?: string // 备注
+	createdAt?: string // 创建时间
+	updatedAt?: string // 更新时间
+	createdBy?: number // 创建者ID
+	updatedBy?: number // 更新者ID
+}
+
+export type Prompt = {
+	id: number
+	title: string
+	prompt: string
+	placeholder?: string
+	createdAt?: string // 创建时间
+	updatedAt?: string // 更新时间
+	createdBy?: number // 创建者ID
+	updatedBy?: number // 更新者ID
+}
+
+// Prompt列表查询参数
+export type PromptListParams = {
+	keyWord?: string // 搜索关键字
+	dateRange?: string[] // 日期范围
+	OrderBy?: string // 排序
+	pageNum?: number // 分页号码,默认1
+	pageSize?: number // 分页数量,最大500,默认10
+	title?: string // 标题
+	createdAt?: string // 创建时间
+}
+
+// Prompt添加请求
+export type PromptAddReq = Omit<Prompt, 'id' | 'createdAt' | 'updatedAt'>
+
+// Prompt编辑请求
+export type PromptEditReq = Prompt & {
+	id: number // 编辑时ID必须
+}
+
+// Prompt删除请求参数
+export type PromptDeleteParams = {
+	ids: number[]
+}
+
+// 获取单个Prompt参数
+export type PromptGetParams = {
+	id: number
+}

+ 38 - 0
src/api/common/index.ts

@@ -0,0 +1,38 @@
+import service from '/@/utils/request'
+import { UploadFile } from '/@/api/common/type'
+
+export default {
+	upload: {
+		// eslint-disable-next-line no-unused-vars
+		single: (blob: File,progress: ((progress: number) => void) | undefined = undefined):Promise<UploadFile> => {
+			const data = new FormData()
+			data.append('file', blob)
+			return service({
+				url: '/common/singleFile',
+				method: "post",
+				data,
+				onUploadProgress: (progressEvent) => {
+					if (progress) {
+						progress(progressEvent.loaded / progressEvent.total)
+					}
+				}
+			}) as unknown as Promise<UploadFile>
+		},
+		// eslint-disable-next-line no-unused-vars
+		multi: (blob: File[],progress: ((progress: number) => void) | undefined = undefined):Promise<UploadFile[]> => {
+			const data = new FormData()
+			blob.forEach(file => data.append('file', file))
+			// return post('/common/multipleFile', data)
+			return service({
+				url: '/common/multipleFile',
+				method: "post",
+				data,
+				onUploadProgress: (progressEvent) => {
+					if (progress) {
+						progress(progressEvent.loaded / progressEvent.total)
+					}
+				}
+			}) as unknown as Promise<UploadFile[]>
+		}
+	}
+}

+ 8 - 0
src/api/common/type.ts

@@ -0,0 +1,8 @@
+
+export type UploadFile = {
+	size: number
+	path: string
+	name: string
+	type: string
+	full_path: string
+}

+ 356 - 0
src/components/assistant/ComponentLibrary.vue

@@ -0,0 +1,356 @@
+<script setup lang="ts">
+import { computed, ref } from 'vue'
+import { Plus } from '@element-plus/icons-vue'
+import Markdown from '/@/components/markdown/Markdown.vue'
+import type { ComponentLibraryItem, Content } from './types'
+import { MarkdownPlugin } from '/@/components/markdown/type/markdown'
+import EChartsPlugin from '/@/components/markdown/plugins/echarts'
+import VueCharts from '/@/components/markdown/plugins/impl/VueCharts.vue'
+import VueStructData from '/@/components/markdown/plugins/impl/VueStructData.vue'
+
+const plugin: Array<MarkdownPlugin<any>> = [EChartsPlugin()]
+
+const props = defineProps<{
+	library?: ComponentLibraryItem[]
+}>()
+
+const componentLibrary = computed(() => props.library || [])
+
+const emit = defineEmits<{
+	// eslint-disable-next-line no-unused-vars
+	(e: 'addCard', cardData: Content): void
+}>()
+
+// 拖拽开始
+const handleDragStart = (event: DragEvent, component: any) => {
+	if (!event.dataTransfer) return
+
+	event.dataTransfer.setData('text/plain', JSON.stringify({
+		title: component.title,
+		data: component.data,
+		type: component.type
+	}))
+
+	event.dataTransfer.effectAllowed = 'copy'
+}
+
+// 添加组件到画布
+const addComponent = (component: any) => {
+	emit('addCard', {
+		title: component.title,
+		data: component.data,
+		type: component.type
+	})
+}
+
+// 预览组件
+const previewComponent = ref<any>(null)
+const showPreview = ref(false)
+
+const openPreview = (component: any) => {
+	previewComponent.value = component
+	showPreview.value = true
+}
+</script>
+
+<template>
+	<div class="component-library">
+		<div class="library-header">
+			<h3>组件库</h3>
+			<div class="library-info">
+				<span>{{ componentLibrary.length }} 个组件</span>
+			</div>
+		</div>
+
+		<div class="library-content">
+			<div class="component-list">
+				<div
+					v-for="component in componentLibrary"
+					:key="component.id"
+					class="component-item"
+					draggable="true"
+					@dragstart="handleDragStart($event, component)"
+				>
+					<div class="component-header">
+						<div class="component-icon">
+							<el-icon :size="20">
+								<component :is="component.icon" />
+							</el-icon>
+						</div>
+						<div class="component-info">
+							<h4 class="component-title">{{ component.title }}</h4>
+							<p class="component-description">{{ component.description }}</p>
+						</div>
+					</div>
+
+					<div class="component-preview">
+
+						<Markdown
+							v-if="component.type === 'markdown'"
+							:content="component.preview"
+							:plugins="plugin"
+							class="preview-content"
+						/>
+						<vue-charts style="width: 100%;height: 200px" :data="component.data" v-if="component.type === 'echarts'"/>
+						<vue-struct-data :uuid="component.data" v-if="component.type === 'structdata'" refresh/>
+					</div>
+
+					<div class="component-actions">
+						<el-button
+							type="primary"
+							size="small"
+							:icon="Plus"
+							@click="addComponent(component)"
+							class="add-btn"
+						>
+							添加到画布
+						</el-button>
+						<el-button
+							type="text"
+							size="small"
+							@click="openPreview(component)"
+							class="preview-btn"
+						>
+							预览
+						</el-button>
+					</div>
+				</div>
+			</div>
+		</div>
+
+		<!-- 使用说明 -->
+		<div class="library-footer">
+			<div class="usage-tips">
+				<h4>使用说明</h4>
+				<ul>
+					<li>拖拽组件到左侧画布</li>
+					<li>或点击"添加到画布"按钮</li>
+					<li>在画布中拖拽标题栏调整位置</li>
+				</ul>
+			</div>
+		</div>
+
+		<!-- 预览对话框 -->
+		<el-dialog
+			v-model="showPreview"
+			:title="`预览 - ${previewComponent?.title}`"
+			width="600px"
+			v-if="previewComponent"
+		>
+			<div class="preview-dialog-content">
+				<vue-charts style="width: 100%;height: 400px" :data="previewComponent.data" v-if="previewComponent.type === 'echarts'"/>
+				<Markdown
+					v-if="previewComponent.type === 'markdown'"
+					:content="previewComponent.data"
+					:plugins="plugin"
+					class="full-preview-content"
+				/>
+				<vue-struct-data :uuid="previewComponent.data" v-if="previewComponent.type === 'structdata'" refresh/>
+			</div>
+			<template #footer>
+				<div class="dialog-footer">
+					<el-button @click="showPreview = false">关闭</el-button>
+					<el-button
+						type="primary"
+						@click="addComponent(previewComponent); showPreview = false"
+					>
+						添加到画布
+					</el-button>
+				</div>
+			</template>
+		</el-dialog>
+	</div>
+</template>
+
+<style scoped lang="scss">
+.component-library {
+	height: 100%;
+	display: flex;
+	flex-direction: column;
+}
+
+.library-header {
+	display: flex;
+	justify-content: space-between;
+	align-items: center;
+	padding: 16px 20px;
+	border-bottom: 1px solid var(--el-border-color-light);
+	background: var(--el-fill-color-extra-light);
+
+	h3 {
+		margin: 0;
+		color: var(--el-text-color-primary);
+		font-size: 16px;
+		font-weight: 600;
+	}
+
+	.library-info {
+		font-size: 14px;
+		color: var(--el-text-color-regular);
+	}
+}
+
+.library-content {
+	flex: 1;
+	overflow-y: auto;
+	padding: 16px;
+}
+
+.component-list {
+	display: flex;
+	flex-direction: column;
+	gap: 16px;
+}
+
+.component-item {
+	border: 1px solid var(--el-border-color-light);
+	border-radius: 8px;
+	background: var(--el-bg-color);
+	transition: all 0.2s ease;
+	cursor: grab;
+
+	&:hover {
+		border-color: var(--el-color-primary-light-7);
+		box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
+	}
+
+	&:active {
+		cursor: grabbing;
+	}
+}
+
+.component-header {
+	display: flex;
+	align-items: flex-start;
+	gap: 12px;
+	padding: 16px 16px 12px 16px;
+}
+
+.component-icon {
+	flex-shrink: 0;
+	width: 40px;
+	height: 40px;
+	display: flex;
+	align-items: center;
+	justify-content: center;
+	background: var(--el-color-primary-light-9);
+	border-radius: 8px;
+	color: var(--el-color-primary);
+}
+
+.component-info {
+	flex: 1;
+	min-width: 0;
+}
+
+.component-title {
+	margin: 0 0 4px 0;
+	font-size: 14px;
+	font-weight: 600;
+	color: var(--el-text-color-primary);
+}
+
+.component-description {
+	margin: 0;
+	font-size: 12px;
+	color: var(--el-text-color-secondary);
+	line-height: 1.4;
+}
+
+.component-preview {
+	padding: 0 16px 12px 16px;
+	border-top: 1px solid var(--el-border-color-extra-light);
+	margin-top: 8px;
+	padding-top: 12px;
+}
+
+.preview-content {
+	font-size: 12px;
+	line-height: 1.4;
+	max-height: 80px;
+	overflow: hidden;
+
+	:deep(blockquote) {
+		margin: 4px 0;
+		padding: 6px 10px;
+		font-size: 11px;
+	}
+
+	:deep(table) {
+		font-size: 10px;
+		margin: 4px 0;
+	}
+
+	:deep(th), :deep(td) {
+		padding: 2px 6px;
+	}
+}
+
+.component-actions {
+	display: flex;
+	justify-content: space-between;
+	align-items: center;
+	padding: 12px 16px;
+	border-top: 1px solid var(--el-border-color-extra-light);
+	background: var(--el-fill-color-extra-light);
+	border-radius: 0 0 8px 8px;
+}
+
+.add-btn {
+	flex: 1;
+	margin-right: 8px;
+}
+
+.preview-btn {
+	color: var(--el-text-color-regular);
+
+	&:hover {
+		color: var(--el-color-primary);
+	}
+}
+
+.library-footer {
+	border-top: 1px solid var(--el-border-color-light);
+	padding: 16px 20px;
+	background: var(--el-fill-color-extra-light);
+}
+
+.usage-tips {
+	h4 {
+		margin: 0 0 8px 0;
+		font-size: 14px;
+		font-weight: 600;
+		color: var(--el-text-color-primary);
+	}
+
+	ul {
+		margin: 0;
+		padding-left: 16px;
+
+		li {
+			font-size: 12px;
+			color: var(--el-text-color-secondary);
+			line-height: 1.5;
+			margin-bottom: 4px;
+		}
+	}
+}
+
+.preview-dialog-content {
+	max-height: 400px;
+	overflow-y: auto;
+	padding: 16px;
+	border: 1px solid var(--el-border-color-light);
+	border-radius: 6px;
+	background: var(--el-fill-color-extra-light);
+}
+
+.full-preview-content {
+	font-size: 14px;
+	line-height: 1.6;
+}
+
+.dialog-footer {
+	text-align: right;
+}
+</style>

+ 212 - 0
src/components/assistant/DashboardDesigner.vue

@@ -0,0 +1,212 @@
+<script setup lang="ts">
+import { ref } from 'vue'
+import DraggableCard from './DraggableCard.vue'
+import { Document } from '@element-plus/icons-vue'
+import type { MarkdownDashBoard, Position, Size, Content, AddCardData } from './types'
+
+defineProps<{
+	cards: MarkdownDashBoard[]
+}>()
+
+const emit = defineEmits<{
+	// eslint-disable-next-line no-unused-vars
+	(e: 'updatePosition', id: string, position: Position): void
+	// eslint-disable-next-line no-unused-vars
+	(e: 'updateSize', id: string, size: Size): void
+	// eslint-disable-next-line no-unused-vars
+	(e: 'updateContent', id: string, content: Content): void
+	// eslint-disable-next-line no-unused-vars
+	(e: 'removeCard', id: string): void
+	// eslint-disable-next-line no-unused-vars
+	(e: 'addCard', cardData: AddCardData): void
+	// eslint-disable-next-line no-unused-vars
+	(e: 'moveCardUp', id: string): void
+	// eslint-disable-next-line no-unused-vars
+	(e: 'moveCardDown', id: string): void
+}>()
+
+const designerContainer = ref<HTMLElement>()
+
+// 处理卡片位置更新
+const handleCardPositionUpdate = (id: string, position: Position) => {
+	emit('updatePosition', id, position)
+}
+
+// 处理卡片大小更新
+const handleCardSizeUpdate = (id: string, size: Size) => {
+	emit('updateSize', id, size)
+}
+
+// 处理卡片内容更新
+const handleCardContentUpdate = (id: string, content: Content) => {
+	emit('updateContent', id, content)
+}
+
+// 处理卡片删除
+const handleCardRemove = (id: string) => {
+	emit('removeCard', id)
+}
+
+// 处理卡片上移
+const handleCardMoveUp = (id: string) => {
+	emit('moveCardUp', id)
+}
+
+// 处理卡片下移
+const handleCardMoveDown = (id: string) => {
+	emit('moveCardDown', id)
+}
+
+// 处理拖拽放置
+const handleDrop = (event: DragEvent) => {
+	event.preventDefault()
+
+	if (!designerContainer.value) return
+
+	const rect = designerContainer.value.getBoundingClientRect()
+	const x = ((event.clientX - rect.left) / rect.width) * 100
+	const y = ((event.clientY - rect.top) / rect.height) * 100
+
+	// 从拖拽数据中获取组件信息
+	const dragData = event.dataTransfer?.getData('text/plain')
+	if (dragData) {
+		try {
+			const componentData = JSON.parse(dragData)
+			// 限制在画布范围内
+			const constrainedX = Math.max(0, Math.min(70, x)) // 预留30%宽度
+			const constrainedY = Math.max(0, Math.min(75, y)) // 预留25%高度
+			emit('addCard', {
+				...componentData,
+				x: constrainedX,
+				y: constrainedY
+			})
+		} catch (error) {
+			console.error('Invalid drag data:', error)
+		}
+	}
+}
+
+const handleDragOver = (event: DragEvent) => {
+	event.preventDefault()
+}
+</script>
+
+<template>
+	<div class="dashboard-designer">
+		<div
+			ref="designerContainer"
+			class="designer-canvas"
+			@drop="handleDrop"
+			@dragover="handleDragOver"
+		>
+			<!-- 网格背景 -->
+			<div class="grid-background"></div>
+
+			<!-- 渲染所有卡片 -->
+			<DraggableCard
+				v-for="card in cards"
+				:key="card.id"
+				:card="card"
+				@update-position="handleCardPositionUpdate"
+				@update-size="handleCardSizeUpdate"
+				@update-content="handleCardContentUpdate"
+				@remove="handleCardRemove"
+				@move-up="handleCardMoveUp"
+				@move-down="handleCardMoveDown"
+			/>
+
+			<!-- 空状态提示 -->
+			<div v-if="cards.length === 0" class="empty-canvas">
+				<div class="empty-icon">
+					<el-icon :size="60" color="#d1d5db">
+						<Document />
+					</el-icon>
+				</div>
+				<div class="empty-text">
+					<h3>开始设计您的仪表板</h3>
+					<p>从右侧组件库拖拽组件到此处</p>
+				</div>
+			</div>
+		</div>
+	</div>
+</template>
+
+<style scoped lang="scss">
+.dashboard-designer {
+	height: 100%;
+	display: flex;
+	flex-direction: column;
+}
+
+.designer-header {
+	display: flex;
+	justify-content: space-between;
+	align-items: center;
+	padding: 16px 20px;
+	border-bottom: 1px solid var(--el-border-color-light);
+	background: var(--el-fill-color-extra-light);
+
+	h3 {
+		margin: 0;
+		color: var(--el-text-color-primary);
+		font-size: 16px;
+		font-weight: 600;
+	}
+
+	.designer-info {
+		font-size: 14px;
+		color: var(--el-text-color-regular);
+	}
+}
+
+.designer-canvas {
+	flex: 1;
+	position: relative;
+	overflow: hidden;
+	background: var(--el-bg-color-page);
+	min-height: 500px;
+}
+
+.grid-background {
+	position: absolute;
+	top: 0;
+	left: 0;
+	right: 0;
+	bottom: 0;
+	background-image:
+		linear-gradient(to right, var(--el-border-color-extra-light) 1px, transparent 1px),
+		linear-gradient(to bottom, var(--el-border-color-extra-light) 1px, transparent 1px);
+	background-size: 20px 20px;
+	pointer-events: none;
+	opacity: 0.5;
+}
+
+.empty-canvas {
+	position: absolute;
+	top: 50%;
+	left: 50%;
+	transform: translate(-50%, -50%);
+	text-align: center;
+	color: var(--el-text-color-secondary);
+
+	.empty-icon {
+		margin-bottom: 16px;
+		opacity: 0.6;
+	}
+
+	.empty-text {
+		h3 {
+			margin: 0 0 8px 0;
+			font-size: 18px;
+			font-weight: 500;
+			color: var(--el-text-color-regular);
+		}
+
+		p {
+			margin: 0;
+			font-size: 14px;
+			color: var(--el-text-color-secondary);
+		}
+	}
+}
+</style>

+ 118 - 0
src/components/assistant/DashboardViewer.vue

@@ -0,0 +1,118 @@
+<script setup lang="ts">
+import ViewerCard from './ViewerCard.vue'
+import { Document } from '@element-plus/icons-vue'
+import type { MarkdownDashBoard } from './types'
+
+defineProps<{
+	cards: MarkdownDashBoard[]
+}>()
+</script>
+
+<template>
+	<div class="dashboard-viewer">
+		<div class="viewer-canvas">
+			<!-- 网格背景 -->
+			<div class="grid-background"></div>
+
+			<!-- 渲染所有卡片 -->
+			<ViewerCard
+				v-for="card in cards"
+				:key="card.id"
+				:card="card"
+			/>
+
+			<!-- 空状态提示 -->
+			<div v-if="cards.length === 0" class="empty-canvas">
+				<div class="empty-icon">
+					<el-icon :size="60" color="#d1d5db">
+						<Document />
+					</el-icon>
+				</div>
+				<div class="empty-text">
+					<h3>暂无内容</h3>
+					<p>仪表板中还没有任何组件</p>
+				</div>
+			</div>
+		</div>
+	</div>
+</template>
+
+<style scoped lang="scss">
+.dashboard-viewer {
+	height: 100%;
+	display: flex;
+	flex-direction: column;
+}
+
+.viewer-header {
+	display: flex;
+	justify-content: space-between;
+	align-items: center;
+	padding: 16px 20px;
+	border-bottom: 1px solid var(--el-border-color-light);
+	background: var(--el-fill-color-extra-light);
+
+	h3 {
+		margin: 0;
+		color: var(--el-text-color-primary);
+		font-size: 16px;
+		font-weight: 600;
+	}
+
+	.viewer-info {
+		font-size: 14px;
+		color: var(--el-text-color-regular);
+	}
+}
+
+.viewer-canvas {
+	flex: 1;
+	position: relative;
+	overflow: hidden;
+	background: var(--el-bg-color-page);
+	min-height: 500px;
+}
+
+.grid-background {
+	position: absolute;
+	top: 0;
+	left: 0;
+	right: 0;
+	bottom: 0;
+	background-image:
+		linear-gradient(to right, var(--el-border-color-extra-light) 1px, transparent 1px),
+		linear-gradient(to bottom, var(--el-border-color-extra-light) 1px, transparent 1px);
+	background-size: 20px 20px;
+	pointer-events: none;
+	opacity: 0.5;
+}
+
+.empty-canvas {
+	position: absolute;
+	top: 50%;
+	left: 50%;
+	transform: translate(-50%, -50%);
+	text-align: center;
+	color: var(--el-text-color-secondary);
+
+	.empty-icon {
+		margin-bottom: 16px;
+		opacity: 0.6;
+	}
+
+	.empty-text {
+		h3 {
+			margin: 0 0 8px 0;
+			font-size: 18px;
+			font-weight: 500;
+			color: var(--el-text-color-regular);
+		}
+
+		p {
+			margin: 0;
+			font-size: 14px;
+			color: var(--el-text-color-secondary);
+		}
+	}
+}
+</style>

+ 710 - 0
src/components/assistant/DraggableCard.vue

@@ -0,0 +1,710 @@
+<script setup lang="ts">
+import { ref, computed, onUnmounted, watch, nextTick } from 'vue'
+import { Delete, Edit, Top, Bottom } from '@element-plus/icons-vue'
+import Markdown from '/@/components/markdown/Markdown.vue'
+import type { MarkdownDashBoard, Position, Size, Content, ResizeType } from './types'
+import { MarkdownPlugin } from '/@/components/markdown/type/markdown'
+import EChartsPlugin from '/@/components/markdown/plugins/echarts'
+import VueCharts from '/@/components/markdown/plugins/impl/VueCharts.vue'
+import VueStructData from '/@/components/markdown/plugins/impl/VueStructData.vue'
+
+const plugin: Array<MarkdownPlugin<any>> = [EChartsPlugin()]
+
+const props = defineProps<{
+	card: MarkdownDashBoard
+}>()
+
+const emit = defineEmits<{
+	// eslint-disable-next-line no-unused-vars
+	(e: 'updatePosition', id: string, position: Position): void
+	// eslint-disable-next-line no-unused-vars
+	(e: 'updateSize', id: string, size: Size): void
+	// eslint-disable-next-line no-unused-vars
+	(e: 'updateContent', id: string, content: Content): void
+	// eslint-disable-next-line no-unused-vars
+	(e: 'remove', id: string): void
+	// eslint-disable-next-line no-unused-vars
+	(e: 'moveUp', id: string): void
+	// eslint-disable-next-line no-unused-vars
+	(e: 'moveDown', id: string): void
+}>()
+
+const cardRef = ref<HTMLElement>()
+const isDragging = ref(false)
+const isResizing = ref(false)
+const dragOffset = ref({ x: 0, y: 0 })
+const resizeType = ref<ResizeType>('')
+const initialSize = ref({ w: 0, h: 0 })
+const initialPosition = ref({ x: 0, y: 0 })
+
+// 编辑相关状态
+const showEditDialog = ref(false)
+const editTitle = ref<string>()
+const editType = ref<MarkdownDashBoard['type']>()
+const editData = ref('')
+const editCharts = ref()
+
+watch(editData,()=>{
+	editCharts.value?.resize()
+})
+
+// 右键菜单相关状态
+const showContextMenu = ref(false)
+const contextMenuPosition = ref({ x: 0, y: 0 })
+
+// 计算卡片样式
+const cardStyle = computed(() => ({
+	position: 'absolute' as const,
+	left: `${props.card.x}%`,
+	top: `${props.card.y}%`,
+	width: `${props.card.w}%`,
+	height: `${props.card.h}%`,
+	zIndex: props.card.z + 100
+}))
+
+// 开始拖拽移动
+const startDrag = (event: MouseEvent) => {
+	if (!cardRef.value || isResizing.value) return
+
+	isDragging.value = true
+
+	const rect = cardRef.value.getBoundingClientRect()
+	const parentRect = cardRef.value.parentElement?.getBoundingClientRect()
+
+	if (!parentRect) return
+
+	dragOffset.value = {
+		x: event.clientX - rect.left,
+		y: event.clientY - rect.top
+	}
+
+	document.addEventListener('mousemove', handleDrag)
+	document.addEventListener('mouseup', stopDrag)
+
+	// 防止文本选择
+	event.preventDefault()
+}
+
+// 开始拖拽调整大小
+const startResize = (event: MouseEvent, type: 'se' | 'e' | 's') => {
+	if (!cardRef.value) return
+
+	isResizing.value = true
+	resizeType.value = type
+
+	initialSize.value = { w: props.card.w, h: props.card.h }
+	initialPosition.value = { x: event.clientX, y: event.clientY }
+
+	document.addEventListener('mousemove', handleResize)
+	document.addEventListener('mouseup', stopResize)
+
+	// 防止文本选择和事件冒泡
+	event.preventDefault()
+	event.stopPropagation()
+}
+
+// 处理拖拽移动
+const handleDrag = (event: MouseEvent) => {
+	if (!isDragging.value || !cardRef.value) return
+
+	const parentRect = cardRef.value.parentElement?.getBoundingClientRect()
+	if (!parentRect) return
+
+	const newX = ((event.clientX - dragOffset.value.x - parentRect.left) / parentRect.width) * 100
+	const newY = ((event.clientY - dragOffset.value.y - parentRect.top) / parentRect.height) * 100
+
+	// 限制在画布范围内
+	const constrainedX = Math.max(0, Math.min(100 - props.card.w, newX))
+	const constrainedY = Math.max(0, Math.min(100 - props.card.h, newY))
+
+	emit('updatePosition', props.card.id, { x: constrainedX, y: constrainedY })
+}
+
+// 处理拖拽调整大小
+const handleResize = (event: MouseEvent) => {
+	if (!isResizing.value || !cardRef.value) return
+
+	const parentRect = cardRef.value.parentElement?.getBoundingClientRect()
+	if (!parentRect) return
+
+	const deltaX = event.clientX - initialPosition.value.x
+	const deltaY = event.clientY - initialPosition.value.y
+
+	const deltaXPercent = (deltaX / parentRect.width) * 100
+	const deltaYPercent = (deltaY / parentRect.height) * 100
+
+	let newW = initialSize.value.w
+	let newH = initialSize.value.h
+
+	if (resizeType.value === 'se' || resizeType.value === 'e') {
+		newW = Math.max(15, Math.min(100 - props.card.x, initialSize.value.w + deltaXPercent))
+	}
+
+	if (resizeType.value === 'se' || resizeType.value === 's') {
+		newH = Math.max(10, Math.min(100 - props.card.y, initialSize.value.h + deltaYPercent))
+	}
+
+	emit('updateSize', props.card.id, { w: newW, h: newH })
+}
+
+// 停止拖拽移动
+const stopDrag = () => {
+	isDragging.value = false
+	document.removeEventListener('mousemove', handleDrag)
+	document.removeEventListener('mouseup', stopDrag)
+}
+
+// 停止拖拽调整大小
+const stopResize = () => {
+	isResizing.value = false
+	resizeType.value = ''
+	document.removeEventListener('mousemove', handleResize)
+	document.removeEventListener('mouseup', stopResize)
+}
+
+// 编辑卡片
+const handleEdit = async () => {
+	editTitle.value = props.card.title
+	editData.value = props.card.data
+	editType.value = props.card.type
+	showEditDialog.value = true
+	await nextTick()
+	editCharts.value?.resize()
+}
+
+// 确认编辑
+const confirmEdit = () => {
+	if ((editTitle.value === undefined && editData.value.trim()) || (editTitle.value !== undefined && (editTitle.value.trim() && editData.value.trim()))) {
+		emit('updateContent', props.card.id, {
+			title: editTitle.value?.trim(),
+			data: editData.value.trim()
+		})
+		showEditDialog.value = false
+	}
+}
+
+// 取消编辑
+const cancelEdit = () => {
+	showEditDialog.value = false
+	editTitle.value = ''
+	editData.value = ''
+}
+
+// 删除卡片
+const handleRemove = () => {
+	emit('remove', props.card.id)
+	showContextMenu.value = false
+}
+
+// 处理右键菜单
+const handleContextMenu = (event: MouseEvent) => {
+	event.preventDefault()
+	event.stopPropagation()
+
+	contextMenuPosition.value = {
+		x: event.clientX,
+		y: event.clientY
+	}
+	showContextMenu.value = true
+
+	// 点击其他地方关闭菜单
+	const closeMenu = () => {
+		showContextMenu.value = false
+		document.removeEventListener('click', closeMenu)
+	}
+
+	// 延迟添加事件监听器,避免立即触发
+	setTimeout(() => {
+		document.addEventListener('click', closeMenu)
+	}, 0)
+}
+
+// 处理右键菜单编辑
+const handleContextEdit = () => {
+	handleEdit()
+	showContextMenu.value = false
+}
+
+// 处理上移一层
+const handleMoveUp = () => {
+	emit('moveUp', props.card.id)
+	showContextMenu.value = false
+}
+
+// 处理下移一层
+const handleMoveDown = () => {
+	emit('moveDown', props.card.id)
+	showContextMenu.value = false
+}
+
+// 清理事件监听器
+onUnmounted(() => {
+	document.removeEventListener('mousemove', handleDrag)
+	document.removeEventListener('mouseup', stopDrag)
+	document.removeEventListener('mousemove', handleResize)
+	document.removeEventListener('mouseup', stopResize)
+})
+
+const charts = ref()
+
+watch(()=> props.card.w, () => {
+	charts.value?.resize()
+})
+watch(()=> props.card.h, () => {
+	charts.value?.resize()
+})
+
+</script>
+
+<template>
+	<div
+		ref="cardRef"
+		:style="cardStyle"
+		:class="['draggable-card', { 'is-dragging': isDragging, 'is-resizing': isResizing }]"
+		@contextmenu="handleContextMenu"
+	>
+		<el-card class="card-content" shadow="hover" @mousedown="startDrag">
+			<!-- 卡片标题栏 - 可拖拽区域 -->
+			<template #header v-if="card.title !== undefined">
+				<div class="card-header">
+					<span class="card-title">{{ card.title }}</span>
+				</div>
+			</template>
+
+			<!-- 卡片内容 -->
+			<div class="card-body">
+				<Markdown
+					v-if="card.type === 'markdown'"
+					:content="card.data"
+					:plugins="plugin"
+					class="markdown-content"
+				/>
+				<vue-charts ref="charts" style="width: 100%;height: 100%" :data="card.data" v-if="card.type === 'echarts'"/>
+				<vue-struct-data :uuid="card.data" v-if="card.type === 'structdata'" refresh/>
+			</div>
+		</el-card>
+
+		<!-- 调整大小的拖拽手柄 -->
+		<div class="resize-handles">
+			<!-- 右边缘 -->
+			<div
+				class="resize-handle resize-handle-e"
+				@mousedown="startResize($event, 'e')"
+			></div>
+
+			<!-- 底边缘 -->
+			<div
+				class="resize-handle resize-handle-s"
+				@mousedown="startResize($event, 's')"
+			></div>
+
+			<!-- 右下角 -->
+			<div
+				class="resize-handle resize-handle-se"
+				@mousedown="startResize($event, 'se')"
+			></div>
+		</div>
+
+	<!-- 右键菜单 -->
+	<Teleport to="body">
+		<div
+			v-if="showContextMenu"
+			class="context-menu"
+			:style="{
+				left: contextMenuPosition.x + 'px',
+				top: contextMenuPosition.y + 'px'
+			}"
+		>
+			<div class="context-menu-item" @click="handleContextEdit">
+				<el-icon><Edit /></el-icon>
+				<span>编辑</span>
+			</div>
+			<div class="context-menu-divider"></div>
+			<div class="context-menu-item" @click="handleMoveUp">
+				<el-icon><Top /></el-icon>
+				<span>上移一层</span>
+			</div>
+			<div class="context-menu-item" @click="handleMoveDown">
+				<el-icon><Bottom /></el-icon>
+				<span>下移一层</span>
+			</div>
+			<div class="context-menu-divider"></div>
+			<div class="context-menu-item context-menu-item-danger" @click="handleRemove">
+				<el-icon><Delete /></el-icon>
+				<span>删除</span>
+			</div>
+		</div>
+	</Teleport>
+
+	<!-- 编辑对话框 -->
+	<el-dialog
+		v-model="showEditDialog"
+		title="编辑卡片内容"
+		width="900px"
+		:before-close="cancelEdit"
+		append-to-body
+	>
+		<div class="edit-dialog-content">
+			<!-- 左侧编辑区 -->
+			<div class="edit-panel">
+				<div class="form-item" v-if="editTitle !== undefined">
+					<label class="form-label">卡片标题</label>
+					<el-input
+						v-model="editTitle"
+						placeholder="请输入卡片标题"
+						maxlength="50"
+						show-word-limit
+					/>
+				</div>
+
+				<div class="form-item">
+					<label class="form-label">卡片内容 (支持Markdown)</label>
+					<el-input
+						v-model="editData"
+						type="textarea"
+						placeholder="请输入卡片内容,支持Markdown语法"
+						:rows="16"
+						resize="none"
+						class="edit-textarea"
+					/>
+				</div>
+			</div>
+
+			<!-- 右侧预览区 -->
+			<div class="preview-panel">
+				<div class="preview-header">
+					<label class="form-label">预览效果</label>
+				</div>
+				<div class="preview-container">
+					<Markdown
+						v-if="editType === 'markdown'"
+						:content="editData"
+						:plugins="plugin"
+						class="preview-content"
+					/>
+					<vue-charts ref="editCharts" style="width: 100%;height: 100%" :data="editData" v-if="editType === 'echarts'"/>
+				</div>
+			</div>
+		</div>
+
+		<template #footer>
+			<div class="dialog-footer">
+				<el-button @click="cancelEdit">取消</el-button>
+				<el-button
+					type="primary"
+					@click="confirmEdit"
+					:disabled="(editTitle === undefined && !editData.trim()) || (editTitle !== undefined && (!editTitle.trim() || !editData.trim()))"
+				>
+					确定
+				</el-button>
+			</div>
+		</template>
+	</el-dialog>
+</div>
+</template>
+
+<style scoped lang="scss">
+.draggable-card {
+	transition: box-shadow 0.2s ease;
+
+	&.is-dragging {
+		.card-content {
+			box-shadow: 0 8px 25px rgba(0, 0, 0, 0.15);
+		}
+	}
+
+	&.is-resizing {
+		.card-content {
+			box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
+		}
+	}
+
+	&:hover {
+		.resize-handles {
+			opacity: 1;
+		}
+	}
+}
+
+.card-content {
+	height: 100%;
+	display: flex;
+	flex-direction: column;
+
+	:deep(.el-card__header) {
+		padding: 12px 16px;
+		border-bottom: 1px solid var(--el-border-color-lighter);
+		background: var(--el-fill-color-extra-light);
+	}
+
+	:deep(.el-card__body) {
+		flex: 1;
+		padding: 16px;
+		overflow: auto;
+	}
+}
+
+.card-header {
+	display: flex;
+	justify-content: space-between;
+	align-items: center;
+	cursor: move;
+	user-select: none;
+
+	&:hover {
+		background: var(--el-fill-color-light);
+		margin: -12px -16px;
+		padding: 12px 16px;
+		border-radius: 4px;
+	}
+}
+
+.card-title {
+	font-weight: 600;
+	color: var(--el-text-color-primary);
+	font-size: 14px;
+}
+
+.card-body {
+	height: 100%;
+	overflow: auto;
+}
+
+.markdown-content {
+	font-size: 13px;
+	line-height: 1.5;
+
+	:deep(h1), :deep(h2), :deep(h3), :deep(h4), :deep(h5), :deep(h6) {
+		margin-top: 12px;
+		margin-bottom: 8px;
+		font-size: 14px;
+	}
+
+	:deep(p) {
+		margin-bottom: 8px;
+	}
+
+	:deep(blockquote) {
+		margin: 8px 0;
+		padding: 8px 12px;
+		font-size: 12px;
+	}
+
+	:deep(table) {
+		font-size: 12px;
+		margin: 8px 0;
+	}
+
+	:deep(th), :deep(td) {
+		padding: 4px 8px;
+	}
+}
+
+/* 调整大小手柄样式 */
+.resize-handles {
+	position: absolute;
+	top: 0;
+	left: 0;
+	right: 0;
+	bottom: 0;
+	pointer-events: none;
+	opacity: 0;
+	transition: opacity 0.2s ease;
+}
+
+.resize-handle {
+	position: absolute;
+	pointer-events: all;
+	background: var(--el-color-primary);
+	transition: all 0.2s ease;
+
+	&:hover {
+		background: var(--el-color-primary-light-3);
+	}
+}
+
+.resize-handle-e {
+	top: 0;
+	right: -2px;
+	bottom: 0;
+	width: 4px;
+	cursor: e-resize;
+}
+
+.resize-handle-s {
+	left: 0;
+	right: 0;
+	bottom: -2px;
+	height: 4px;
+	cursor: s-resize;
+}
+
+.resize-handle-se {
+	right: -2px;
+	bottom: -2px;
+	width: 8px;
+	height: 8px;
+	cursor: se-resize;
+	border-radius: 0 0 4px 0;
+}
+
+/* 编辑对话框样式 */
+.edit-dialog-content {
+	display: flex;
+	gap: 20px;
+	height: 500px;
+}
+
+.edit-panel {
+	flex: 1;
+	display: flex;
+	flex-direction: column;
+
+	.form-item {
+		margin-bottom: 16px;
+
+		&:last-child {
+			flex: 1;
+			display: flex;
+			flex-direction: column;
+		}
+	}
+
+	.form-label {
+		display: block;
+		margin-bottom: 8px;
+		font-size: 14px;
+		font-weight: 600;
+		color: var(--el-text-color-primary);
+	}
+
+	.edit-textarea {
+		flex: 1;
+
+		:deep(.el-textarea__inner) {
+			height: 100% !important;
+			resize: none;
+			font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
+			font-size: 13px;
+			line-height: 1.5;
+		}
+	}
+}
+
+.preview-panel {
+	flex: 1;
+	display: flex;
+	flex-direction: column;
+	border-left: 1px solid var(--el-border-color-lighter);
+	padding-left: 20px;
+
+	.preview-header {
+		margin-bottom: 8px;
+
+		.form-label {
+			display: block;
+			font-size: 14px;
+			font-weight: 600;
+			color: var(--el-text-color-primary);
+		}
+	}
+
+	.preview-container {
+		flex: 1;
+		border: 1px solid var(--el-border-color-light);
+		border-radius: 6px;
+		padding: 16px;
+		background: var(--el-fill-color-extra-light);
+		overflow-y: auto;
+	}
+
+	.preview-content {
+		font-size: 14px;
+		line-height: 1.6;
+
+		:deep(h1), :deep(h2), :deep(h3), :deep(h4), :deep(h5), :deep(h6) {
+			margin-top: 16px;
+			margin-bottom: 12px;
+
+			&:first-child {
+				margin-top: 0;
+			}
+		}
+
+		:deep(p) {
+			margin-bottom: 12px;
+
+			&:last-child {
+				margin-bottom: 0;
+			}
+		}
+
+		:deep(blockquote) {
+			margin: 12px 0;
+			padding: 12px 16px;
+		}
+
+		:deep(table) {
+			margin: 12px 0;
+		}
+	}
+}
+
+.dialog-footer {
+	text-align: right;
+}
+
+/* 右键菜单样式 */
+.context-menu {
+	position: fixed;
+	z-index: 9999;
+	background: var(--el-bg-color-overlay);
+	border: 1px solid var(--el-border-color-light);
+	border-radius: 6px;
+	box-shadow: var(--el-box-shadow-light);
+	padding: 4px 0;
+	min-width: 120px;
+	backdrop-filter: blur(12px);
+}
+
+.context-menu-item {
+	display: flex;
+	align-items: center;
+	gap: 8px;
+	padding: 8px 16px;
+	cursor: pointer;
+	transition: all 0.2s ease;
+	font-size: 14px;
+	color: var(--el-text-color-primary);
+
+	.el-icon {
+		font-size: 14px;
+		color: var(--el-text-color-regular);
+	}
+
+	&:hover {
+		background: var(--el-fill-color-light);
+
+		.el-icon {
+			color: var(--el-color-primary);
+		}
+	}
+
+	&.context-menu-item-danger {
+		&:hover {
+			background: var(--el-color-danger-light-9);
+			color: var(--el-color-danger);
+
+			.el-icon {
+				color: var(--el-color-danger);
+			}
+		}
+	}
+}
+
+.context-menu-divider {
+	height: 1px;
+	background: var(--el-border-color-lighter);
+	margin: 4px 0;
+}
+</style>

+ 134 - 0
src/components/assistant/ViewerCard.vue

@@ -0,0 +1,134 @@
+<script setup lang="ts">
+import { computed, ref, watch } from 'vue'
+import Markdown from '/@/components/markdown/Markdown.vue'
+import type { MarkdownDashBoard } from './types'
+import { MarkdownPlugin } from '/@/components/markdown/type/markdown'
+import EChartsPlugin from '/@/components/markdown/plugins/echarts'
+import VueCharts from '/@/components/markdown/plugins/impl/VueCharts.vue'
+import VueStructData from '/@/components/markdown/plugins/impl/VueStructData.vue'
+
+const plugin: Array<MarkdownPlugin<any>> = [EChartsPlugin()]
+
+const props = defineProps<{
+	card: MarkdownDashBoard
+}>()
+
+// 计算卡片样式
+const cardStyle = computed(() => ({
+	position: 'absolute' as const,
+	left: `${props.card.x}%`,
+	top: `${props.card.y}%`,
+	width: `${props.card.w}%`,
+	height: `${props.card.h}%`,
+	zIndex: props.card.z + 100
+}))
+
+const charts = ref()
+
+watch(()=> props.card.w, () => {
+	charts.value?.resize()
+})
+watch(()=> props.card.h, () => {
+	charts.value?.resize()
+})
+</script>
+
+<template>
+	<div
+		:style="cardStyle"
+		class="viewer-card"
+	>
+		<el-card class="card-content" shadow="hover">
+			<!-- 卡片标题栏 -->
+			<template #header v-if="card.title !== undefined">
+				<div class="card-header">
+					<span class="card-title">{{ card.title }}</span>
+				</div>
+			</template>
+
+			<!-- 卡片内容 -->
+			<div class="card-body">
+				<Markdown
+					v-if="card.type === 'markdown'"
+					:content="card.data"
+					:plugins="plugin"
+					class="markdown-content"
+				/>
+				<vue-charts ref="charts" style="width: 100%;height: 100%" :data="card.data" v-if="card.type === 'echarts'"/>
+				<vue-struct-data :uuid="card.data" v-if="card.type === 'structdata'" refresh/>
+			</div>
+		</el-card>
+	</div>
+</template>
+
+<style scoped lang="scss">
+.viewer-card {
+	transition: none;
+}
+
+.card-content {
+	height: 100%;
+	display: flex;
+	flex-direction: column;
+
+	:deep(.el-card__header) {
+		padding: 12px 16px;
+		border-bottom: 1px solid var(--el-border-color-lighter);
+		background: var(--el-fill-color-extra-light);
+	}
+
+	:deep(.el-card__body) {
+		flex: 1;
+		padding: 16px;
+		overflow: auto;
+	}
+}
+
+.card-header {
+	display: flex;
+	justify-content: space-between;
+	align-items: center;
+	user-select: none;
+}
+
+.card-title {
+	font-weight: 600;
+	color: var(--el-text-color-primary);
+	font-size: 14px;
+}
+
+.card-body {
+	height: 100%;
+	overflow: auto;
+}
+
+.markdown-content {
+	font-size: 13px;
+	line-height: 1.5;
+
+	:deep(h1), :deep(h2), :deep(h3), :deep(h4), :deep(h5), :deep(h6) {
+		margin-top: 12px;
+		margin-bottom: 8px;
+		font-size: 14px;
+	}
+
+	:deep(p) {
+		margin-bottom: 8px;
+	}
+
+	:deep(blockquote) {
+		margin: 8px 0;
+		padding: 8px 12px;
+		font-size: 12px;
+	}
+
+	:deep(table) {
+		font-size: 12px;
+		margin: 8px 0;
+	}
+
+	:deep(th), :deep(td) {
+		padding: 4px 8px;
+	}
+}
+</style>

+ 76 - 0
src/components/assistant/types.ts

@@ -0,0 +1,76 @@
+/**
+ * 仪表板助手相关类型定义
+ */
+
+export type MarkdownDashBoardType = 'echarts' | 'markdown' | 'structdata'
+
+// 仪表板卡片类型
+export interface MarkdownDashBoard {
+	/** 卡片唯一标识 */
+	id: string
+	/** X坐标 (百分比 0-100) */
+	x: number
+	/** Y坐标 (百分比 0-100) */
+	y: number
+	/** 宽度 (百分比 0-100) */
+	w: number
+	/** 高度 (百分比 0-100) */
+	h: number
+	/** 层级 (z-index) */
+	z: number
+	/** 卡片标题 */
+	title?: string
+	type: MarkdownDashBoardType
+	/** 卡片内容 (Markdown格式) */
+	data: string
+}
+
+// 位置信息类型
+export interface Position {
+	x: number
+	y: number
+}
+
+// 尺寸信息类型
+export interface Size {
+	w: number
+	h: number
+}
+
+// 内容信息类型
+export interface Content {
+	title?: string
+	data: string
+}
+
+// 组件库项目类型
+export interface ComponentLibraryItem {
+	/** 组件唯一标识 */
+	id: string
+	/** 组件标题 */
+	title?: string
+	/** 组件图标 */
+	icon: any
+	/** 组件描述 */
+	description: string
+
+	type: MarkdownDashBoardType
+	/** 组件完整数据 */
+	data: string
+	/** 组件预览数据 */
+	preview: string
+}
+
+// 拖拽调整大小类型
+export type ResizeType = 'se' | 'e' | 's' | ''
+
+// 卡片添加数据类型
+export interface AddCardData {
+	title?: string
+	type: MarkdownDashBoardType
+	data: string
+	x?: number
+	y?: number
+}
+
+

+ 133 - 0
src/components/markdown/Markdown.vue

@@ -0,0 +1,133 @@
+<template>
+  <div>
+    <MarkdownNodeRenderer v-for="(node, index) in renderedContent" :key="index" :node="node"/>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { computed, defineComponent, h } from 'vue'
+import MarkdownIt from 'markdown-it';
+import {parseDocument} from 'htmlparser2'
+import { MarkdownPlugin } from '/@/components/markdown/type/markdown'
+
+interface Props {
+  content: string
+  plugins?: Array<MarkdownPlugin<any>>
+}
+
+const props = withDefaults(defineProps<Props>(), {
+  plugins: undefined
+})
+
+const md = new MarkdownIt({
+  html: true,
+  linkify: true,
+  typographer: true
+})
+
+for (const plugin of props.plugins ?? []) {
+  md.use(plugin.mdItPlugin, plugin.settings)
+}
+
+const renderedContent = computed(() => {
+  // Markdown模式添加安全过滤和样式类,并处理成dom ast
+  return parseDocument(
+      md.render(props.content),
+  ).children
+})
+
+
+const MarkdownNodeRenderer = defineComponent({
+  name: 'MarkdownNodeRenderer',
+  props: {
+    node: {
+      type: Object,
+      required: true,
+    },
+  },
+  setup(subprops) {
+    return () => {
+
+      const {node} = subprops;
+      if (node.type === 'text') {
+        return node.data
+      }
+
+      for (let i = 0; i < (props.plugins ?? []).length; i++) {
+        const plugin = (props.plugins ?? [])[i]
+				if (plugin.tagName === node.tagName) {
+          return plugin.renderer(node as {attribs: Record<string,string>})
+        }
+      }
+
+      return h(
+          node.tagName,
+          {...node.attribs},
+          node.children.map((child, index) =>
+              h(MarkdownNodeRenderer, {node: child, key: index})
+          )
+      )
+
+    }
+  },
+})
+</script>
+
+<style scoped>
+
+/* 基本的markdown样式 */
+:deep(h1), :deep(h2), :deep(h3), :deep(h4), :deep(h5), :deep(h6) {
+  margin-top: 24px;
+  margin-bottom: 16px;
+  font-weight: 600;
+  line-height: 1.25;
+}
+
+:deep(h1) {
+  font-size: 2em;
+}
+
+:deep(h2) {
+  font-size: 1.5em;
+}
+
+:deep(h3) {
+  font-size: 1.25em;
+}
+
+:deep(p) {
+  margin-bottom: 16px;
+  line-height: 1.6;
+}
+
+:deep(pre) {
+  background-color: #f6f8fa;
+  border-radius: 6px;
+  padding: 16px;
+  overflow: auto;
+  margin: 16px 0;
+}
+
+:deep(code) {
+  background-color: #f6f8fa;
+  padding: 2px 4px;
+  border-radius: 3px;
+  font-size: 85%;
+}
+
+:deep(blockquote) {
+  border-left: 4px solid #d1d5da;
+  padding-left: 16px;
+  margin: 16px 0;
+  color: #6a737d;
+}
+
+:deep(ul), :deep(ol) {
+  margin: 16px 0;
+  padding-left: 32px;
+}
+
+:deep(li) {
+  margin: 4px 0;
+}
+</style>

+ 74 - 0
src/components/markdown/plugins/echarts.ts

@@ -0,0 +1,74 @@
+import MarkdownIt from 'markdown-it'
+import type { RenderRule } from 'markdown-it/lib/renderer.mjs'
+import type Token from 'markdown-it/lib/token.mjs'
+import { defineMarkdownPlugin } from '../type/markdown.ts'
+import { h } from 'vue'
+import VueCharts from '/@/components/markdown/plugins/impl/VueCharts.vue'
+
+// 验证JSON格式
+function isValidJSON(str: string): boolean {
+	if (str.startsWith('[')) {
+		return false
+	}
+	try {
+		const expr = JSON.parse(str)
+
+		return expr["series"] !== undefined;
+
+	} catch {
+		return false
+	}
+}
+
+// 渲染echarts代码块
+const renderEcharts: RenderRule = (tokens: Token[], idx: number) => {
+	const token = tokens[idx]
+	const content = token.content.trim()
+
+	if (!content) {
+		return '<div style="padding: 16px;background-color: #fff5f5;border: 1px solid #fed7d7;border-radius: 6px;color: #c53030;margin: 16px 0;">ECharts配置不能为空</div>'
+	}
+	// 生成完整HTML
+	return `<echarts-container style="width: 100%;height: 350px;margin: 16px 0; border-radius: 6px" data="${encodeURIComponent(
+		content
+	)}"></echarts-container>`
+}
+
+const EChartsPlugin = defineMarkdownPlugin({
+	tagName: 'echarts-container',
+	mdItPlugin: function (md: MarkdownIt) {
+		// 保存原始的fence渲染器
+		const defaultRender =
+			md.renderer.rules.fence ??
+			function (tokens, idx, options, _env, renderer) {
+				return renderer.renderToken(tokens, idx, options)
+			}
+
+		// if (customElements.get('echarts-container') === undefined) {
+		//   customElements.define('echarts-container', EChartsElement, { extends: 'div' })
+		// }
+
+		// 重写fence渲染器
+		md.renderer.rules.fence = function (tokens, idx, options, env, renderer) {
+			const token = tokens[idx]
+			const info = token.info ? token.info.trim() : ''
+
+			// 检查是否是echarts代码块
+			if ((info === 'echarts' || info == 'json') && isValidJSON(token.content.trim())) {
+				return renderEcharts(tokens, idx, options, env, renderer)
+			}
+
+			// 其他代码块使用默认渲染器
+			return defaultRender(tokens, idx, options, env, renderer)
+		}
+	},
+	renderer: (node: { attribs: Record<string, string> }) => {
+		return h(VueCharts, {
+			data: node.attribs.data,
+			charts: node.attribs.id,
+			style: 'width: 100%;height: 350px;margin: 16px 0; border-radius: 6px',
+		})
+	},
+})
+
+export default EChartsPlugin

+ 190 - 0
src/components/markdown/plugins/impl/ToolsLoadingCard.vue

@@ -0,0 +1,190 @@
+<template>
+  <el-collapse v-model="activeCollapse" class="tools-collapse">
+    <el-collapse-item :name="collapseKey" class="tools-collapse-item">
+      <template #title>
+        <div class="collapse-header">
+          <el-tag :type="getRequestTagType()" size="small" class="type-tag">
+            {{ toolData.requestType }}
+          </el-tag>
+          <span class="tool-name">{{ toolData.toolName || '未知工具' }}</span>
+        </div>
+      </template>
+
+      <div class="collapse-content">
+        <div v-if="toolData.data">
+          <pre class="json-content">{{ formatData(toolData.data) }}</pre>
+        </div>
+        <el-empty v-else description="暂无数据" />
+      </div>
+    </el-collapse-item>
+  </el-collapse>
+</template>
+
+<script setup lang="ts">
+import { ref, onMounted } from 'vue'
+
+type Props = {
+  data: string
+}
+
+interface ToolData {
+  requestType: string // request/response
+  toolName: string // 工具名
+  data: string // 参数或返回结果
+}
+
+const props = defineProps<Props>()
+
+const activeCollapse = ref<string[]>([])
+const collapseKey = ref('tool-collapse')
+const toolData = ref<ToolData>({
+  requestType: '',
+  toolName: '',
+  data: ''
+})
+
+
+
+// 解析传入的数据
+onMounted(() => {
+  try {
+    const decodedData = decodeURIComponent(props.data)
+    const lines = decodedData.trim().split('\n')
+
+    if (lines.length >= 2) {
+      toolData.value = {
+        requestType: lines[0] || 'request',
+        toolName: lines[1] || '未知工具',
+        data: lines[2] || '' // 第三行为参数/结果,可能不存在
+      }
+    } else {
+      toolData.value = {
+        requestType: 'error',
+        toolName: '数据格式错误',
+        data: '应为2-3行格式:request/response、工具名、参数/结果'
+      }
+    }
+  } catch (e) {
+    console.error('解析工具数据失败:', e)
+    toolData.value = {
+      requestType: 'error',
+      toolName: '数据解析失败',
+      data: '解析错误'
+    }
+  }
+})
+
+// 获取请求类型标签类型
+const getRequestTagType = () => {
+  const type = toolData.value.requestType.toLowerCase()
+  switch (type) {
+    case 'request':
+      return 'primary'
+    case 'response':
+      return 'success'
+    case 'error':
+      return 'danger'
+    default:
+      return 'info'
+  }
+}
+
+
+
+// 格式化数据显示
+const formatData = (data: string) => {
+  try {
+    // 尝试解析为JSON并格式化
+    const parsed = JSON.parse(data)
+    return JSON.stringify(parsed, null, 2)
+  } catch {
+    // 如果不是JSON,直接返回原始数据
+    return data
+  }
+}
+</script>
+
+<style scoped>
+.tools-collapse {
+  border: 1px solid var(--el-border-color-light);
+  border-radius: 6px;
+  overflow: hidden;
+  margin: 8px 0;
+}
+
+.tools-collapse-item {
+  border: none;
+}
+
+.tools-collapse-item :deep(.el-collapse-item__header) {
+  padding: 0;
+  border: none;
+  background: transparent;
+  height: auto;
+  min-height: 16px;
+  position: relative;
+}
+
+.tools-collapse-item :deep(.el-collapse-item__content) {
+  padding: 0;
+  border: none;
+}
+
+/* 调整展开箭头位置 */
+:deep(.el-collapse-item__arrow) {
+	margin-top: 8px;
+	margin-bottom: 8px;
+}
+
+.collapse-header {
+  padding: 2px 8px;
+  width: 100%;
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  line-height: 1;
+}
+
+.type-tag {
+  font-size: 10px;
+  flex-shrink: 0;
+  padding: 1px 4px;
+  line-height: 1;
+  height: auto;
+}
+
+.tool-name {
+  font-size: 12px;
+  font-weight: 500;
+  color: var(--el-text-color-primary);
+  word-break: break-all;
+  flex: 1;
+  line-height: 1;
+  padding: 0;
+  margin: 0;
+}
+
+.collapse-content {
+  padding: 4px 8px 4px 8px;
+  border-top: 1px solid var(--el-border-color-lighter);
+}
+
+.json-content {
+  background-color: var(--el-fill-color-light);
+  border: 1px solid var(--el-border-color-light);
+  border-radius: 3px;
+  padding: 4px 6px;
+  margin: 0;
+  font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
+  font-size: 11px;
+  line-height: 1.2;
+  color: var(--el-text-color-primary);
+  white-space: pre-wrap;
+  word-break: break-all;
+  overflow-x: auto;
+  max-height: 120px;
+  overflow-y: auto;
+}
+
+
+</style>

+ 113 - 0
src/components/markdown/plugins/impl/VueCharts.vue

@@ -0,0 +1,113 @@
+<script setup lang="ts">
+import { onMounted, onUnmounted, ref, watch } from 'vue'
+import * as echarts from 'echarts'
+
+type Props = {
+	data: string | echarts.EChartsOption
+}
+
+const prop = defineProps<Props>()
+
+let instance: echarts.ECharts
+const dom = ref<HTMLDivElement>()
+
+const resizeHandler = () => {
+	instance?.resize()
+}
+
+const showOrigin = ref(false)
+
+const setOption = () => {
+	let data: echarts.EChartsOption
+	try {
+		if (typeof prop.data === 'object') {
+			data = prop.data
+		} else {
+			try {
+				data = JSON.parse(decodeURIComponent(prop.data))
+			} catch {
+				data = JSON.parse(prop.data)
+			}
+		}
+	} catch (e) {
+		console.error(e)
+		return
+	}
+
+	data = {
+		...data,
+		grid: {
+			left: '3%',
+			right: '4%',
+			bottom: '3%',
+			containLabel: true,
+		},
+		tooltip: {
+			...data.tooltip,
+			position: function (point, params, dom, rect, size) {
+				//其中point为当前鼠标的位置,
+				//size中有两个属性:viewSize和contentSize,分别为外层div和tooltip提示框的大小
+				// 鼠标坐标和提示框位置的参考坐标系是:以外层div的左上角那一点为原点,x轴向右,y轴向下
+				// 提示框位置
+				let x: number // x坐标位置
+				let y: number // y坐标位置
+				// 当前鼠标位置
+				const pointX = point[0]
+				const pointY = point[1]
+				// 提示框大小
+				const boxWidth = size.contentSize[0]
+				const boxHeight = size.contentSize[1]
+				// boxWidth > pointX 说明鼠标左边放不下提示框
+				if (boxWidth > pointX) {
+					x = 5
+				} else {
+					// 左边放的下
+					x = pointX - boxWidth
+				}
+				// boxHeight > pointY 说明鼠标上边放不下提示框
+				if (boxHeight > pointY) {
+					y = 5
+				} else {
+					// 上边放得下
+					y = pointY - boxHeight
+				}
+				return [x, y]
+			},
+		},
+	}
+
+	instance = echarts.init(dom.value)
+	try {
+		instance.setOption(data)
+	} catch (e) {
+		showOrigin.value = true
+	}
+}
+
+watch(
+	() => prop.data,
+	() => {
+		setOption()
+	}
+)
+
+onMounted(() => {
+	setOption()
+	window.addEventListener('resize', resizeHandler)
+})
+
+onUnmounted(() => {
+	window.removeEventListener('resize', resizeHandler)
+	instance?.dispose()
+})
+
+defineExpose<{
+	resize: () => void
+}>({ resize: resizeHandler })
+</script>
+
+<template>
+	<div ref="dom"></div>
+</template>
+
+<style scoped></style>

+ 380 - 0
src/components/markdown/plugins/impl/VueStructData.vue

@@ -0,0 +1,380 @@
+<script setup lang="ts">
+import { computed, nextTick, onMounted, ref, watch } from 'vue'
+import { ElMessage } from 'element-plus'
+import { DataLine, Download, Grid, TrendCharts } from '@element-plus/icons-vue'
+import assist from '/@/api/assist'
+import { useLoading } from '/@/utils/loading-util'
+import download from 'downloadjs'
+import VueCharts from './VueCharts.vue'
+import { EChartsOption } from 'echarts'
+
+const props = withDefaults(defineProps<{
+	uuid: string
+	refresh?: boolean
+}>(),{
+	refresh: false,
+})
+
+// 定义数据结构类型
+interface StructData {
+	fields: string[]
+	data: { [key: string]: string }[]
+}
+
+const data = ref<StructData>()
+
+const { loading: loadingStruct, doLoading: doLoadingStruct } = useLoading(async () => {
+	data.value = await assist.chat.struct(props.uuid,props.refresh)
+})
+
+// 导出数据为CSV格式
+const exportToCSV = () => {
+	if (!data.value || !data.value.fields || !data.value.data) {
+		ElMessage.warning('暂无数据可导出')
+		return
+	}
+
+	try {
+		// 构建CSV内容
+		const headers = data.value.fields.map((it) => `"${it}"`).join(',')
+		const rows = data.value.data.map((rowJson) => {
+			return data.value!.fields.map((field) => `"${rowJson[field]}"`).join(',')
+		})
+
+		const csvContent = [headers, ...rows].join('\n')
+
+		download(csvContent, 'export.csv', 'text/csv,charset=utf-8;')
+
+		ElMessage.success('数据导出成功')
+	} catch (error) {
+		console.error('导出失败:', error)
+		ElMessage.error('导出失败,请重试')
+	}
+}
+
+// 组件挂载时加载数据
+onMounted(doLoadingStruct)
+
+// 显示模式:表格、柱状图、折线图
+const display = ref<'table' | 'bar' | 'line'>('table')
+
+// 图表组件引用
+const chart = ref()
+
+// 图表配置选项
+const chartOptions = ref<EChartsOption>()
+
+// 生成柱状图配置 - 统计每列数据的分布情况
+const generateBarChartOptions: () => EChartsOption | undefined = () => {
+	if (!data.value || !data.value.fields || !data.value.data) return undefined
+
+	// 统计每个字段的数据分布
+	const fieldStats: { [field: string]: { [value: string]: number } } = {}
+
+	// 初始化统计对象
+	data.value.fields.forEach((field) => {
+		fieldStats[field] = {}
+	})
+
+	// 统计每个字段中每个值的出现次数
+	data.value.data.forEach((row) => {
+		data.value!.fields.forEach((field) => {
+			const value = row[field] || '空值'
+			fieldStats[field][value] = (fieldStats[field][value] || 0) + 1
+		})
+	})
+
+	// 准备图表数据
+	const xAxisData = data.value.fields.map((field) => {
+		return field.length > 6 ? field.slice(0, 3) + '...' : field
+	}) // 横坐标是表头(字段名)
+	const seriesData: { [value: string]: number[] } = {}
+
+	// 收集所有可能的值
+	const allValues = new Set<string>()
+	Object.values(fieldStats).forEach((fieldStat) => {
+		Object.keys(fieldStat).forEach((value) => allValues.add(value))
+	})
+
+	// 为每个值创建一个系列
+	Array.from(allValues).forEach((value) => {
+		seriesData[value] = data.value!.fields.map((field) => fieldStats[field][value] || 0)
+	})
+
+	// 生成系列配置
+	const series = Object.entries(seriesData).map(([value, counts]) => ({
+		name: value,
+		type: 'bar',
+		data: counts,
+		stack: 'total', // 使用堆叠柱状图更好地展示分布,
+	}))
+
+	return {
+		xAxis: {
+			type: 'category',
+			data: xAxisData,
+		},
+		legend: {
+			show: true,       // 显示图例
+			type: 'scroll',   // 如果图例太多,可以加滚动
+			top: 'top',       // 位置:上方
+		},
+		tooltip: {
+			show: true,
+			trigger: 'item',
+		},
+		yAxis: {
+			type: 'value',
+			name: '出现次数',
+		},
+		series: series,
+	} as EChartsOption
+}
+
+// 生成折线图配置 - 统计每列数据的分布情况(与柱状图算法同步)
+const generateLineChartOptions: () => EChartsOption | undefined = () => {
+	if (!data.value || !data.value.fields || !data.value.data) return undefined
+
+	// 统计每个字段的数据分布
+	const fieldStats: { [field: string]: { [value: string]: number } } = {}
+
+	// 初始化统计对象
+	data.value.fields.forEach((field) => {
+		fieldStats[field] = {}
+	})
+
+	// 统计每个字段中每个值的出现次数
+	data.value.data.forEach((row) => {
+		data.value!.fields.forEach((field) => {
+			const value = row[field] || '空值'
+			fieldStats[field][value] = (fieldStats[field][value] || 0) + 1
+		})
+	})
+
+	// 准备图表数据
+	const xAxisData = data.value.fields.map((field) => {
+		return field.length > 6 ? field.slice(0, 3) + '...' : field
+	}) // 横坐标是表头(字段名)
+	const seriesData: { [value: string]: number[] } = {}
+
+	// 收集所有可能的值
+	const allValues = new Set<string>()
+	Object.values(fieldStats).forEach((fieldStat) => {
+		Object.keys(fieldStat).forEach((value) => allValues.add(value))
+	})
+
+	// 为每个值创建一个系列
+	Array.from(allValues).forEach((value) => {
+		seriesData[value] = data.value!.fields.map((field) => fieldStats[field][value] || 0)
+	})
+
+	// 生成系列配置
+	const series = Object.entries(seriesData).map(([value, counts]) => ({
+		name: value,
+		type: 'line',
+		smooth: true,
+		data: counts,
+		connectNulls: false,
+	}))
+
+	return {
+		xAxis: {
+			type: 'category',
+			data: xAxisData,
+		},
+		legend: {
+			show: true,       // 显示图例
+			type: 'scroll',   // 如果图例太多,可以加滚动
+			top: 'top',       // 位置:上方
+		},
+		tooltip: {
+			show: true,
+			trigger: 'axis',
+		},
+		yAxis: {
+			type: 'value',
+			name: '出现次数',
+		},
+		series: series,
+	} as EChartsOption
+}
+
+// 监听显示模式变化,生成对应的图表配置
+watch(display, async (newVal) => {
+	if (newVal === 'table') return
+
+	// 根据显示模式生成对应的图表配置
+	switch (newVal) {
+		case 'bar':
+			chartOptions.value = generateBarChartOptions()
+			break
+		case 'line':
+			chartOptions.value = generateLineChartOptions()
+			break
+	}
+
+	await nextTick()
+	// 通知图表组件重新计算大小
+	chart.value?.resize()
+})
+
+const cardHeaderRef = ref<HTMLElement | null>(null)
+
+const cardBodyStyle = computed(()=> {
+	return {
+		height: `calc(100% - ${cardHeaderRef.value?.offsetHeight}px - 24px)`
+	}
+})
+</script>
+
+<template>
+	<div class="struct-data-container">
+		<el-card v-loading="loadingStruct" shadow="none" :body-style="{ padding: 0, margin: 0, height: '100%' }" style="height: 100%">
+			<template #header>
+				<div class="card-header" ref="cardHeaderRef">
+					<span>结构化数据{{ display === 'table' ? '表格' : display === 'bar' ? '柱状图' : '折线图' }}</span>
+					<div class="header-controls">
+						<!-- 显示模式切换按钮组 -->
+						<el-button-group class="display-toggle">
+							<el-button
+								:type="display === 'table' ? 'primary' : ''"
+								:icon="Grid"
+								size="small"
+								@click="display = 'table'"
+								:disabled="!data || !data.fields || !data.data || data.data.length === 0"
+							>
+								表格
+							</el-button>
+							<el-button
+								:type="display === 'bar' ? 'primary' : ''"
+								:icon="TrendCharts"
+								size="small"
+								@click="display = 'bar'"
+								:disabled="!data || !data.fields || !data.data || data.data.length === 0"
+							>
+								柱状图
+							</el-button>
+							<el-button
+								:type="display === 'line' ? 'primary' : ''"
+								:icon="DataLine"
+								size="small"
+								@click="display = 'line'"
+								:disabled="!data || !data.fields || !data.data || data.data.length === 0"
+							>
+								折线图
+							</el-button>
+						</el-button-group>
+
+						<!-- 导出按钮 -->
+						<el-popover placement="top" :width="120" trigger="hover" content="导出为CSV文件">
+							<template #reference>
+								<el-button
+									:icon="Download"
+									size="small"
+									text
+									circle
+									@click="exportToCSV"
+									:disabled="!data || !data.fields || !data.data || data.data.length === 0"
+									class="export-btn"
+								/>
+							</template>
+						</el-popover>
+					</div>
+				</div>
+			</template>
+			<!-- 内容区域 -->
+			<div v-if="data && data.fields && data.data" :style="cardBodyStyle">
+				<el-table v-if="display === 'table'" :data="data.data" stripe border size="small" style="height: 100%">
+					<el-table-column v-for="field in data.fields" :key="field" :prop="field" :label="field" align="center" min-width="120">
+						<template #default="{ row }">
+							<span>{{ row[field] || '-' }}</span>
+						</template>
+					</el-table-column>
+				</el-table>
+<!---->
+				<!-- 图表视图 -->
+				<VueCharts ref="chart" :data="chartOptions" v-else-if="chartOptions" style="height: 100%" />
+			</div>
+
+			<!-- 空数据状态 -->
+			<div v-else-if="!loadingStruct" class="empty-state">
+				<el-empty description="暂无数据" />
+			</div>
+		</el-card>
+	</div>
+</template>
+
+<style scoped lang="scss">
+.struct-data-container {
+	height: calc(100% - 32px);
+	margin: 16px 0;
+
+	.card-header {
+		display: flex;
+		justify-content: space-between;
+		align-items: center;
+		font-weight: 500;
+		color: var(--el-text-color-primary);
+
+		.header-controls {
+			display: flex;
+			align-items: center;
+			gap: 12px;
+
+			.display-toggle {
+				.el-button {
+					padding: 6px 12px;
+					font-size: 12px;
+
+					&:disabled {
+						color: var(--el-text-color-disabled);
+						cursor: not-allowed;
+					}
+				}
+			}
+
+			.export-btn {
+				color: var(--el-color-primary);
+
+				&:hover {
+					background-color: var(--el-color-primary-light-9);
+				}
+
+				&:disabled {
+					color: var(--el-text-color-disabled);
+					cursor: not-allowed;
+				}
+			}
+		}
+	}
+
+	.table-wrapper {
+		height: 100%;
+	}
+
+	.chart-wrapper {
+		padding: 20px;
+
+		:deep(div) {
+			width: 100% !important;
+		}
+	}
+
+	.empty-state {
+		padding: 40px 0;
+		text-align: center;
+	}
+
+	:deep(table) {
+		margin: 0 !important;
+	}
+}
+
+.el-card {
+	background: transparent !important;
+}
+
+:deep(.el-table__cell) {
+	background: transparent !important;
+}
+</style>

+ 225 - 0
src/components/markdown/plugins/impl/VueTable.vue

@@ -0,0 +1,225 @@
+<script setup lang="ts">
+import { computed, ref } from 'vue'
+import { ElDialog, ElButton } from 'element-plus'
+import { View, Download } from '@element-plus/icons-vue'
+
+interface TableData {
+  headers: string[]
+  rows: string[][]
+}
+
+interface Props {
+  data: string
+}
+
+const props = defineProps<Props>()
+
+const showDialog = ref(false)
+
+const tableData = computed<TableData>(() => {
+  try {
+    return JSON.parse(decodeURIComponent(props.data))
+  } catch (e) {
+    console.error('解析表格数据失败:', e)
+    return { headers: [], rows: [] }
+  }
+})
+
+const handleView = () => {
+  showDialog.value = true
+}
+
+const handleExport = () => {
+  try {
+    // 创建CSV内容
+    const csvContent = generateCSV(tableData.value)
+
+    // 创建Blob对象
+    const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' })
+
+    // 创建下载链接
+    const link = document.createElement('a')
+    const url = URL.createObjectURL(blob)
+    link.setAttribute('href', url)
+    link.setAttribute('download', `table_data_${new Date().getTime()}.csv`)
+    link.style.visibility = 'hidden'
+
+    // 触发下载
+    document.body.appendChild(link)
+    link.click()
+    document.body.removeChild(link)
+
+    // 清理URL对象
+    URL.revokeObjectURL(url)
+  } catch (error) {
+    console.error('导出CSV失败:', error)
+  }
+}
+
+// 生成CSV内容的辅助函数
+const generateCSV = (data: TableData): string => {
+  const { headers, rows } = data
+
+  // 处理CSV字段的函数,处理包含逗号、引号和换行符的情况
+  const escapeCSVField = (field: string): string => {
+    // 移除HTML标签
+    const cleanField = field.replace(/<[^>]*>/g, '')
+
+    // 如果字段包含逗号、引号或换行符,需要用引号包围并转义内部引号
+    if (cleanField.includes(',') || cleanField.includes('"') || cleanField.includes('\n')) {
+      return `"${cleanField.replace(/"/g, '""')}"`
+    }
+    return cleanField
+  }
+
+  // 构建CSV内容
+  const csvLines: string[] = []
+
+  // 添加表头
+  if (headers.length > 0) {
+    csvLines.push(headers.map(escapeCSVField).join(','))
+  }
+
+  // 添加数据行
+  rows.forEach(row => {
+    csvLines.push(row.map(escapeCSVField).join(','))
+  })
+
+  // 添加BOM以支持Excel正确显示中文
+  return '\uFEFF' + csvLines.join('\n')
+}
+
+</script>
+
+<template>
+  <div>
+    <!-- 操作按钮 -->
+    <div class="table-actions" v-if="tableData.headers.length > 0">
+			<el-button
+				type="success"
+				:icon="Download"
+				size="small"
+				@click="handleExport"
+				plain
+			>
+				导出
+			</el-button>
+      <el-button
+        type="primary"
+        :icon="View"
+        size="small"
+        @click="handleView"
+        plain
+      >
+        查看
+      </el-button>
+
+    </div>
+
+    <table v-if="tableData.headers.length > 0">
+      <thead>
+        <tr>
+          <th
+            v-for="(header, index) in tableData.headers"
+            :key="index"
+            class="table-header"
+          >
+            {{ header }}
+          </th>
+        </tr>
+      </thead>
+      <tbody>
+        <tr
+          v-for="(row, rowIndex) in tableData.rows"
+          :key="rowIndex"
+          class="table-row"
+          :class="{ 'striped': rowIndex % 2 === 1 }"
+        >
+          <td
+            v-for="(cell, cellIndex) in row"
+            :key="cellIndex"
+            class="table-cell"
+          >
+            <span class="table-cell-content" v-html="cell"></span>
+          </td>
+        </tr>
+      </tbody>
+    </table>
+    <div v-else class="empty-table">
+      暂无数据
+    </div>
+
+    <!-- 查看对话框 -->
+    <el-dialog
+      v-model="showDialog"
+      title="表格详情"
+      width="80%"
+      :before-close="() => showDialog = false"
+    >
+      <div>
+        <table class="dialog-table" v-if="tableData.headers.length > 0">
+          <thead>
+            <tr>
+              <th
+                v-for="(header, index) in tableData.headers"
+                :key="index"
+                class="dialog-table-header"
+              >
+                {{ header }}
+              </th>
+            </tr>
+          </thead>
+          <tbody>
+            <tr
+              v-for="(row, rowIndex) in tableData.rows"
+              :key="rowIndex"
+              class="dialog-table-row"
+              :class="{ 'striped': rowIndex % 2 === 1 }"
+            >
+              <td
+                v-for="(cell, cellIndex) in row"
+                :key="cellIndex"
+                class="dialog-table-cell"
+              >
+                <span class="dialog-table-cell-content" v-html="cell"></span>
+              </td>
+            </tr>
+          </tbody>
+        </table>
+      </div>
+      <template #footer>
+        <span class="dialog-footer">
+          <el-button @click="showDialog = false">关闭</el-button>
+          <el-button type="primary" @click="handleExport">导出数据</el-button>
+        </span>
+      </template>
+    </el-dialog>
+  </div>
+</template>
+
+<style scoped lang="scss">
+
+.table-actions {
+  z-index: 10;
+  display: flex;
+	flex-direction: row-reverse;
+  gap: 8px;
+
+  .el-button {
+    --el-button-size: 24px;
+  }
+}
+
+.empty-table {
+  padding: 40px;
+  text-align: center;
+  color: var(--el-text-color-placeholder);
+  font-size: 14px;
+}
+
+.dialog-footer {
+  display: flex;
+  justify-content: flex-end;
+  gap: 12px;
+}
+</style>

+ 60 - 0
src/components/markdown/plugins/struct-data.ts

@@ -0,0 +1,60 @@
+import MarkdownIt from 'markdown-it'
+import type { RenderRule } from 'markdown-it/lib/renderer.mjs'
+import type Token from 'markdown-it/lib/token.mjs'
+import { defineMarkdownPlugin } from '../type/markdown.ts'
+import { h } from 'vue'
+import VueStructData from '/@/components/markdown/plugins/impl/VueStructData.vue'
+
+// 渲染结构化数据代码块
+const renderStructData: RenderRule = (tokens: Token[], idx: number) => {
+	const token = tokens[idx]
+	const content = token.content.trim()
+
+	if (!content) {
+		return '<div style="padding: 16px;background-color: #fff5f5;border: 1px solid #fed7d7;border-radius: 6px;color: #c53030;margin: 16px 0;">结构化数据UUID不能为空</div>'
+	}
+	// 生成完整HTML
+	return `<structdata style="width: 100%;margin: 16px 0; border-radius: 6px" uuid="${encodeURIComponent(
+		content
+	)}"></structdata>`
+}
+
+const StructDataPlugin = defineMarkdownPlugin({
+	tagName: 'structdata',
+	mdItPlugin: function (md: MarkdownIt) {
+		// 保存原始的fence渲染器
+		const defaultRender =
+			md.renderer.rules.fence ??
+			function (tokens, idx, options, _env, renderer) {
+				return renderer.renderToken(tokens, idx, options)
+			}
+
+		// if (customElements.get('struct-data') === undefined) {
+		//   customElements.define('struct-data', EChartsElement, { extends: 'div' })
+		// }
+
+		// 重写fence渲染器
+		md.renderer.rules.fence = function (tokens, idx, options, env, renderer) {
+			const token = tokens[idx]
+			const info = token.info ? token.info.trim() : ''
+
+			// 检查是否是结构化数据代码块
+			if (info === 'structdata') {
+				return renderStructData(tokens, idx, options, env, renderer)
+			}
+
+			// 其他代码块使用默认渲染器
+			return defaultRender(tokens, idx, options, env, renderer)
+		}
+	},
+	renderer: (node: { attribs: Record<string, string> }) => {
+		return h(VueStructData, {
+			uuid: decodeURIComponent(node.attribs.uuid || ''),
+			style: {
+				'height': '400px'
+			}
+		})
+	},
+})
+
+export default StructDataPlugin

+ 163 - 0
src/components/markdown/plugins/table.ts

@@ -0,0 +1,163 @@
+import MarkdownIt from "markdown-it";
+import type {RenderRule} from "markdown-it/lib/renderer.mjs";
+import type Token from "markdown-it/lib/token.mjs";
+import {defineMarkdownPlugin} from "../type/markdown.ts";
+import {h} from "vue";
+import VueTable from "/@/components/markdown/plugins/impl/VueTable.vue";
+
+// 解析表格数据的接口
+interface TableData {
+    headers: string[];
+    rows: string[][];
+}
+
+// 解析表格tokens获取数据
+function parseTableData(tokens: Token[], startIdx: number): TableData {
+    const headers: string[] = [];
+    const rows: string[][] = [];
+    let currentRow: string[] = [];
+    let inHeader = false;
+    let inBody = false;
+    let cellContent = '';
+
+    for (let i = startIdx; i < tokens.length; i++) {
+        const token = tokens[i];
+
+        if (token.type === 'table_close') {
+            break;
+        }
+
+        if (token.type === 'thead_open') {
+            inHeader = true;
+            continue;
+        }
+
+        if (token.type === 'thead_close') {
+            inHeader = false;
+            continue;
+        }
+
+        if (token.type === 'tbody_open') {
+            inBody = true;
+            continue;
+        }
+
+        if (token.type === 'tbody_close') {
+            inBody = false;
+            continue;
+        }
+
+        if (token.type === 'tr_open') {
+            currentRow = [];
+            continue;
+        }
+
+        if (token.type === 'tr_close') {
+            if (inHeader && currentRow.length > 0) {
+                headers.push(...currentRow);
+            } else if (inBody && currentRow.length > 0) {
+                rows.push([...currentRow]);
+            }
+            currentRow = [];
+            continue;
+        }
+
+        if (token.type === 'th_open' || token.type === 'td_open') {
+            cellContent = '';
+            continue;
+        }
+
+        if (token.type === 'th_close' || token.type === 'td_close') {
+            currentRow.push(cellContent.trim());
+            cellContent = '';
+            continue;
+        }
+
+        if (token.type === 'inline' && token.children) {
+            // 处理内联内容
+            for (const child of token.children) {
+                if (child.type === 'text') {
+                    cellContent += child.content;
+                } else if (child.type === 'code_inline') {
+                    cellContent += `\`${child.content}\``;
+                } else if (child.type === 'strong_open') {
+                    cellContent += '**';
+                } else if (child.type === 'strong_close') {
+                    cellContent += '**';
+                } else if (child.type === 'em_open') {
+                    cellContent += '*';
+                } else if (child.type === 'em_close') {
+                    cellContent += '*';
+                }
+            }
+        }
+    }
+
+    return { headers, rows };
+}
+
+// 渲染表格
+const renderTable: RenderRule = (tokens: Token[], idx: number) => {
+    const tableData = parseTableData(tokens, idx + 1);
+
+    if (tableData.headers.length === 0 && tableData.rows.length === 0) {
+        return '<div style="padding: 16px;background-color: #fff5f5;border: 1px solid #fed7d7;border-radius: 6px;color: #c53030;margin: 16px 0;">表格数据为空</div>';
+    }
+
+    // 生成完整HTML
+    return `<table-container style="width: 100%;margin: 16px 0;" data="${encodeURIComponent(JSON.stringify(tableData))}"></table-container>`;
+}
+
+const TablePlugin = defineMarkdownPlugin({
+    tagName: 'table-container',
+    mdItPlugin: function (md: MarkdownIt) {
+        // 保存原始的表格渲染器
+        const defaultTableOpen = md.renderer.rules.table_open ?? function (tokens, idx, options, _env, renderer) {
+            return renderer.renderToken(tokens, idx, options);
+        }
+
+        const defaultTableClose = md.renderer.rules.table_close ?? function (tokens, idx, options, _env, renderer) {
+            return renderer.renderToken(tokens, idx, options);
+        }
+
+        // 重写表格渲染器
+        md.renderer.rules.table_open = function (tokens, idx, options, env, renderer) {
+            return renderTable(tokens, idx, options, env, renderer);
+        }
+
+        // 表格关闭时不输出任何内容,因为我们在table_open时已经处理了整个表格
+        md.renderer.rules.table_close = function () {
+            return '';
+        }
+
+        // // 隐藏所有其他表格相关的token渲染
+        const hideTokens = ['thead_open', 'thead_close','tbody_open','tbody_close','tr_open','tr_close'];
+        hideTokens.forEach(tokenType => {
+            md.renderer.rules[tokenType] = function () {
+                return ''
+            }
+        });
+
+
+        const notDisplayTokens = ['th_open','td_open']
+        notDisplayTokens.forEach(tokenType => {
+            md.renderer.rules[tokenType] = function () {
+                return '<div style="display: none">'
+            }
+        });
+        const notDisplayTokensEnd = ['th_close','td_close']
+        notDisplayTokensEnd.forEach(tokenType => {
+            md.renderer.rules[tokenType] = function () {
+                return '</div>'
+            }
+        });
+    },
+    renderer: (node: {attribs: Record<string, string>}) => {
+        return h(VueTable, {
+            data: node.attribs.data,
+            style: 'width: 100%;margin: 16px 0;'
+        });
+    }
+});
+
+export default TablePlugin;

+ 62 - 0
src/components/markdown/plugins/tools-loading.ts

@@ -0,0 +1,62 @@
+import MarkdownIt from "markdown-it";
+import type {RenderRule} from "markdown-it/lib/renderer.mjs";
+import type Token from "markdown-it/lib/token.mjs";
+import {defineMarkdownPlugin} from "../type/markdown.ts";
+import {h} from "vue";
+import ToolsLoadingCard from "./impl/ToolsLoadingCard.vue";
+
+// 验证2-3行格式
+function isValidLineFormat(str: string): boolean {
+    const lines = str.trim().split('\n')
+    return lines.length === 2 || lines.length === 3
+}
+
+// 渲染tools-loading代码块
+const renderToolsLoading: RenderRule = (tokens: Token[], idx: number) => {
+    const token = tokens[idx]
+    const content = token.content.trim()
+
+    if (!content) {
+        return '<div style="padding: 16px;background-color: #fff5f5;border: 1px solid #fed7d7;border-radius: 6px;color: #c53030;margin: 16px 0;">工具调用数据不能为空</div>'
+    }
+
+    if (!isValidLineFormat(content)) {
+        return '<div style="padding: 16px;background-color: #fff5f5;border: 1px solid #fed7d7;border-radius: 6px;color: #c53030;margin: 16px 0;">工具调用数据格式错误,应为2-3行格式:工具名、参数、[结果]</div>'
+    }
+
+    // 生成完整HTML
+    return `<tools-loading-container style="width: 100%;margin: 16px 0; border-radius: 6px" data="${encodeURIComponent(content)}"></tools-loading-container>`
+}
+
+const ToolsLoadingPlugin = defineMarkdownPlugin({
+    tagName: 'tools-loading-container',
+    mdItPlugin: function (md: MarkdownIt) {
+        // 保存原始的fence渲染器
+        const defaultRender = md.renderer.rules.fence ?? function (tokens, idx, options, _env, renderer) {
+            return renderer.renderToken(tokens, idx, options)
+        }
+
+        // 重写fence渲染器
+        md.renderer.rules.fence = function (tokens, idx, options, env, renderer) {
+            const token = tokens[idx]
+            const info = token.info ? token.info.trim() : ''
+
+            // 检查是否是tools-loading代码块
+            if (info === 'tools-loading' && isValidLineFormat(token.content.trim())) {
+                return renderToolsLoading(tokens, idx, options, env, renderer)
+            }
+
+            // 其他代码块使用默认渲染器
+            return defaultRender(tokens, idx, options, env, renderer)
+        }
+    },
+    renderer: (node: {attribs: Record<string, string>}) => {
+        return h(ToolsLoadingCard, {
+                data: node.attribs.data,
+                style: 'width: 100%;margin: 16px 0; border-radius: 6px'
+            }
+        )
+    }
+})
+
+export default ToolsLoadingPlugin

+ 23 - 0
src/components/markdown/type/markdown.ts

@@ -0,0 +1,23 @@
+import type { PluginWithOptions } from 'markdown-it/index.mjs'
+import type { VNode } from 'vue'
+
+export type MarkdownPlugin<Settings> = {
+	tagName: string
+	mdItPlugin: PluginWithOptions<Settings>
+	// eslint-disable-next-line no-unused-vars
+	renderer: (element: {attribs: Record<string, string>}) => VNode
+	settings: Settings
+}
+
+export function defineMarkdownPlugin<Settings = {}>(
+	data: Omit<MarkdownPlugin<Settings>, 'settings'>
+
+	// eslint-disable-next-line no-unused-vars
+): (arg0?: Settings) => MarkdownPlugin<Settings> {
+	return (arg0) => {
+		return {
+			...data,
+			settings: arg0 as Settings,
+		}
+	}
+}

+ 10 - 0
src/i18n/index.ts

@@ -46,6 +46,10 @@ import pagesDateCenterZhcn from './pages/dateCenter/zh-cn';
 import pagesDateCenterEn from './pages/dateCenter/en';
 import pagesDateCenterZhtw from './pages/dateCenter/zh-tw';
 
+import pagesAssistantZhcn from './pages/assistant/zh-cn';
+import pagesAssistantEn from './pages/assistant/en';
+import pagesAssistantZhtw from './pages/assistant/zh-tw';
+
 
 // 数据分析
 import pagesDataAnalysisZhcn from './pages/dataAnalysis/zh-cn';
@@ -111,6 +115,8 @@ const messages = {
 			flowComponent: componentFlowModelZhcn,
 			flowDemo: pagesFlowDemoZhcn,
 			flowCraft: pagesFlowCraftZhcn
+			dateCenter: pagesDateCenterZhcn,
+			assistant: pagesAssistantZhcn
 		}
 	},
 	[enLocale.name]: {
@@ -136,6 +142,9 @@ const messages = {
 			flowDemo: pagesFlowDemoEn,
 			flowCraft: pagesFlowCraftEn
 		},
+			dateCenter: pagesDateCenterEn,
+			assistant: pagesAssistantEn
+		},
 	},
 	[zhtwLocale.name]: {
 		...zhtwLocale,
@@ -149,6 +158,7 @@ const messages = {
 			projects: pagesProjectsZhtw,
 			property: pagesPropertyZhtw,
 			dateCenter: pagesDateCenterZhtw,
+			assistant: pagesAssistantZhtw
 			dataAnalysis: pagesDataAnalysisZhtw,
 			certificate: pagesCertificateZhtw,
 			alarmCenter: pagesAlarmCenterZhtw,

+ 253 - 0
src/i18n/pages/assistant/en.ts

@@ -0,0 +1,253 @@
+// 定义内容
+export default {
+	sidebar: {
+		conversationHistory: 'Conversation History',
+		bookmark: 'Bookmarks',
+		createConversation: 'Create Conversation',
+	},
+	settings: {
+		autoRecordToolCalls: 'Auto-record tool calls for new conversations',
+		modelManagement: 'Model Management',
+		promptManagement: 'Prompt Management',
+		conversationManagement: 'Conversation Management',
+		cancelSelection: 'Cancel Selection',
+		deleteSelected: 'Delete Selected',
+	},
+	buttons: {
+		more: 'More Actions',
+		export: 'Export',
+		edit: 'Edit',
+		delete: 'Delete',
+		confirm: 'Confirm Changes',
+		cancel: 'Cancel Edit',
+		retry: 'Retry',
+		search: 'Search',
+		reset: 'Reset',
+		confirmDialog: 'Confirm',
+		cancelDialog: 'Cancel',
+	},
+	status: {
+		noModelConfigured: 'No Model Configured',
+		loading: 'Loading',
+		noPromptConfigured: 'No Prompt Configured',
+		aiThinking: 'AI is thinking...',
+		uploading: 'Please wait for upload to complete',
+		uploadProgress: 'Release mouse to upload files...',
+		clickToUpload: 'Click + on the right to upload files',
+	},
+	messages: {
+		replaceMessageWarning: 'Replacing the message will modify the bookmarked content. Do you want to continue?',
+		messageSaveFailed: 'Message save failed. Switching conversations will cause message history to be lost',
+		deleteSuccess: 'Delete successful',
+		selectConversationsToDelete: 'Please select conversations to delete',
+		deleteConfirm: 'Are you sure you want to delete the selected {count} conversations? This action cannot be undone!',
+		warning: 'Warning',
+		prompt: 'Prompt',
+	},
+	placeholders: {
+		inputQuestion: 'Please enter your question...',
+		searchBookmarks: 'Search bookmarked messages...',
+		customPrompt: 'Write your prompt here... (will be used as system prompt for this session)',
+	},
+	empty: {
+		noBookmarks: 'No Bookmarked Messages',
+		noBookmarksDescription: 'You have not bookmarked any conversation messages yet',
+		startNewConversation: 'Start a New Conversation',
+		tryTheseQuestions: 'Try these questions:',
+		bookmarkTip1: 'Click the ⭐ button in conversations to bookmark messages',
+		bookmarkTip2: 'Bookmarked messages support keyword search',
+		bookmarkTip3: 'Bookmarked messages are saved in the cloud and will never be lost',
+	},
+	examples: {
+		deviceStatus: 'Help me check device status and alarm information',
+		userPermissions: 'Analyze user permission configuration and role assignment',
+		systemPerformance: 'Check system performance and online user statistics',
+	},
+	prompt: {
+		// Search related
+		search: {
+			keyword: 'Search Keywords',
+			title: 'Prompt Title',
+			dateRange: 'Date Range'
+		},
+		// Button related
+		buttons: {
+			search: 'Search',
+			reset: 'Reset',
+			add: 'Add Prompt',
+			batchDelete: 'Batch Delete',
+			edit: 'Edit',
+			delete: 'Delete'
+		},
+		// Table columns
+		columns: {
+			id: 'ID',
+			title: 'Title',
+			prompt: 'Prompt Content',
+			placeholder: 'Placeholder',
+			createdAt: 'Created Time',
+			updatedAt: 'Updated Time',
+			actions: 'Actions'
+		},
+		// Form related
+		form: {
+			title: 'Prompt Title',
+			prompt: 'Prompt Content',
+			placeholder: 'Placeholder'
+		},
+		// Placeholders
+		placeholders: {
+			keyword: 'Search Keywords',
+			title: 'Prompt Title',
+			startTime: 'Start Time',
+			endTime: 'End Time',
+			inputTitle: 'Please enter prompt title',
+			inputPrompt: 'Please enter prompt content',
+			inputPlaceholder: 'Please enter placeholder'
+		},
+		// Dialog
+		dialog: {
+			addTitle: 'Add Prompt',
+			editTitle: 'Edit Prompt',
+			cancel: 'Cancel',
+			confirm: 'Confirm'
+		},
+		// Messages
+		messages: {
+			selectDeleteItems: 'Please select data to delete',
+			deleteConfirm: 'Are you sure you want to delete the selected data?',
+			deleteConfirmSingle: 'Are you sure you want to delete this data?',
+			deleteSuccess: 'Delete successful',
+			deleteFailed: 'Delete failed',
+			addSuccess: 'Add successful',
+			editSuccess: 'Edit successful',
+			saveFailed: 'Save failed',
+			getListFailed: 'Failed to get prompt list',
+			getDetailFailed: 'Failed to get prompt details',
+			confirmText: 'Confirm',
+			cancelText: 'Cancel',
+			warning: 'Warning',
+			// Legacy management properties for compatibility
+			management: 'Prompt Management',
+			customPrompt: 'Custom Prompt',
+			customPromptWithCount: 'Custom Prompt ({count}) characters',
+			noPrompt: 'No Prompt'
+		},
+		// Validation rules
+		rules: {
+			titleRequired: 'Please enter prompt title',
+			promptRequired: 'Please enter prompt content'
+		}
+	},
+	file: {
+		clickToOpen: 'Click to open: {name}',
+	},
+	model: {
+		// Search related
+		search: {
+			keyword: 'Search Keywords',
+			modelClass: 'Model Class',
+			modelName: 'Model Name',
+			modelType: 'Model Type',
+			status: 'Status',
+			dateRange: 'Date Range'
+		},
+		// Button related
+		buttons: {
+			search: 'Search',
+			reset: 'Reset',
+			add: 'Add Model',
+			batchDelete: 'Batch Delete',
+			edit: 'Edit',
+			delete: 'Delete',
+			enable: 'Enable',
+			disable: 'Disable'
+		},
+		// Table columns
+		columns: {
+			id: 'ID',
+			modelName: 'Model Name',
+			modelClass: 'Model Class',
+			modelType: 'Model Type',
+			status: 'Status',
+			createdAt: 'Created Time',
+			updatedAt: 'Updated Time',
+			actions: 'Actions'
+		},
+		// Form related
+		form: {
+			modelName: 'Model Name',
+			modelClass: 'Model Class',
+			modelType: 'Model Type',
+			apiKey: 'API Key',
+			baseUrl: 'Base URL',
+			maxToken: 'Max Tokens',
+			isCallFun: 'Call Function',
+			status: 'Status'
+		},
+		// Placeholders
+		placeholders: {
+			keyword: 'Search Keywords',
+			modelClass: 'Model Class',
+			modelName: 'Model Name',
+			modelType: 'Please select model type',
+			status: 'Status',
+			startTime: 'Start Time',
+			endTime: 'End Time',
+			inputModelName: 'Please enter model name',
+			inputModelClass: 'Please enter model class',
+			inputModelType: 'Please enter model type',
+			inputApiKey: 'Please enter API key',
+			inputBaseUrl: 'Please enter base URL',
+			inputMaxToken: 'Please enter max tokens'
+		},
+		// Options
+		options: {
+			all: 'All',
+			enabled: 'Enabled',
+			disabled: 'Disabled',
+			embedding: 'Embedding',
+			chat: 'Chat Model',
+			yes: 'Yes',
+			no: 'No'
+		},
+		// Dialog
+		dialog: {
+			addTitle: 'Add Model Configuration',
+			editTitle: 'Edit Model Configuration',
+			cancel: 'Cancel',
+			confirm: 'Confirm'
+		},
+		// Messages
+		messages: {
+			selectDeleteItems: 'Please select data to delete',
+			deleteConfirm: 'Are you sure you want to delete the selected data?',
+			deleteConfirmSingle: 'Are you sure you want to delete this data?',
+			enableConfirm: 'Are you sure you want to enable this model configuration?',
+			disableConfirm: 'Are you sure you want to disable this model configuration?',
+			deleteSuccess: 'Delete successful',
+			deleteFailed: 'Delete failed',
+			enableSuccess: 'Enable successful',
+			disableSuccess: 'Disable successful',
+			enableFailed: 'Enable failed',
+			disableFailed: 'Disable failed',
+			addSuccess: 'Add successful',
+			editSuccess: 'Edit successful',
+			saveFailed: 'Save failed',
+			getListFailed: 'Failed to get model list',
+			getDetailFailed: 'Failed to get model details',
+			confirmText: 'Confirm',
+			cancelText: 'Cancel',
+			warning: 'Warning'
+		},
+		// Validation rules
+		rules: {
+			modelNameRequired: 'Please enter model name',
+			modelClassRequired: 'Please enter model class',
+			modelTypeRequired: 'Please enter model type',
+			apiKeyRequired: 'Please enter API key',
+			baseUrlRequired: 'Please enter base URL',
+			maxTokenNumber: 'Max tokens must be a number'
+		}
+	},
+};

+ 253 - 0
src/i18n/pages/assistant/zh-cn.ts

@@ -0,0 +1,253 @@
+// 定义内容
+export default {
+	sidebar: {
+		conversationHistory: '对话历史',
+		bookmark: '收藏夹',
+		createConversation: '创建对话',
+	},
+	settings: {
+		autoRecordToolCalls: '新对话自动记录工具调用',
+		modelManagement: '模型管理',
+		promptManagement: '提示词管理',
+		conversationManagement: '对话管理',
+		cancelSelection: '取消选择',
+		deleteSelected: '删除选中',
+	},
+	buttons: {
+		more: '更多操作',
+		export: '导出',
+		edit: '编辑',
+		delete: '删除',
+		confirm: '确认修改',
+		cancel: '取消编辑',
+		retry: '重试',
+		search: '搜索',
+		reset: '重置',
+		confirmDialog: '确认',
+		cancelDialog: '取消',
+	},
+	status: {
+		noModelConfigured: '未配置模型',
+		loading: '加载中',
+		noPromptConfigured: '未配置提示词',
+		aiThinking: 'AI正在思考中...',
+		uploading: '请等待上传完成',
+		uploadProgress: '松开鼠标上传文件...',
+		clickToUpload: '点击右侧+上传文件',
+	},
+	messages: {
+		replaceMessageWarning: '替换消息会导致收藏内容被修改,是否继续?',
+		messageSaveFailed: '消息保存失败,切换对话会导致消息记录丢失',
+		deleteSuccess: '删除成功',
+		selectConversationsToDelete: '请选择要删除的会话',
+		deleteConfirm: '确定要删除选中的 {count} 个会话吗?此操作不可恢复!',
+		warning: '警告',
+		prompt: '提示',
+	},
+	placeholders: {
+		inputQuestion: '请输入您的问题...',
+		searchBookmarks: '搜索收藏的消息...',
+		customPrompt: '在此编写你的提示词...(将用于本次会话的系统提示)',
+	},
+	empty: {
+		noBookmarks: '暂无收藏消息',
+		noBookmarksDescription: '您还没有收藏任何对话消息',
+		startNewConversation: '开始新的对话',
+		tryTheseQuestions: '试试这些问题:',
+		bookmarkTip1: '在对话中点击 ⭐ 按钮即可收藏消息',
+		bookmarkTip2: '收藏的消息支持关键词搜索',
+		bookmarkTip3: '收藏消息会保存在云端,永不丢失',
+	},
+	examples: {
+		deviceStatus: '帮我查看设备运行状态和告警信息',
+		userPermissions: '分析用户权限配置和角色分配情况',
+		systemPerformance: '检查系统性能和在线用户统计',
+	},
+	prompt: {
+		// 搜索相关
+		search: {
+			keyword: '搜索关键字',
+			title: '提示词标题',
+			dateRange: '时间范围'
+		},
+		// 按钮相关
+		buttons: {
+			search: '查询',
+			reset: '重置',
+			add: '新增提示词',
+			batchDelete: '批量删除',
+			edit: '编辑',
+			delete: '删除'
+		},
+		// 表格列
+		columns: {
+			id: 'ID',
+			title: '标题',
+			prompt: '提示词内容',
+			placeholder: '占位符',
+			createdAt: '创建时间',
+			updatedAt: '更新时间',
+			actions: '操作'
+		},
+		// 表单相关
+		form: {
+			title: '提示词标题',
+			prompt: '提示词内容',
+			placeholder: '占位符'
+		},
+		// 占位符
+		placeholders: {
+			keyword: '搜索关键字',
+			title: '提示词标题',
+			startTime: '开始时间',
+			endTime: '结束时间',
+			inputTitle: '请输入提示词标题',
+			inputPrompt: '请输入提示词内容',
+			inputPlaceholder: '请输入占位符'
+		},
+		// 对话框
+		dialog: {
+			addTitle: '新增提示词',
+			editTitle: '编辑提示词',
+			cancel: '取消',
+			confirm: '确定'
+		},
+		// 消息
+		messages: {
+			selectDeleteItems: '请选择要删除的数据',
+			deleteConfirm: '您确定要删除所选数据吗?',
+			deleteConfirmSingle: '您确定要删除这条数据吗?',
+			deleteSuccess: '删除成功',
+			deleteFailed: '删除失败',
+			addSuccess: '新增成功',
+			editSuccess: '编辑成功',
+			saveFailed: '保存失败',
+			getListFailed: '获取提示词列表失败',
+			getDetailFailed: '获取提示词详情失败',
+			confirmText: '确认',
+			cancelText: '取消',
+			warning: '提示',
+			// 旧的管理相关属性保留兼容性
+			management: '提示词管理',
+			customPrompt: '自定义提示词',
+			customPromptWithCount: '自定义提示词 ({count}) 字',
+			noPrompt: '不启用提示词'
+		},
+		// 验证规则
+		rules: {
+			titleRequired: '请输入提示词标题',
+			promptRequired: '请输入提示词内容'
+		}
+	},
+	file: {
+		clickToOpen: '点击打开: {name}',
+	},
+	model: {
+		// 搜索相关
+		search: {
+			keyword: '搜索关键字',
+			modelClass: '模型分类',
+			modelName: '模型名称',
+			modelType: '模型类型',
+			status: '状态',
+			dateRange: '时间范围'
+		},
+		// 按钮相关
+		buttons: {
+			search: '查询',
+			reset: '重置',
+			add: '新增模型',
+			batchDelete: '批量删除',
+			edit: '编辑',
+			delete: '删除',
+			enable: '启用',
+			disable: '禁用'
+		},
+		// 表格列
+		columns: {
+			id: 'ID',
+			modelName: '模型名称',
+			modelClass: '模型分类',
+			modelType: '模型类型',
+			status: '状态',
+			createdAt: '创建时间',
+			updatedAt: '更新时间',
+			actions: '操作'
+		},
+		// 表单相关
+		form: {
+			modelName: '模型名称',
+			modelClass: '模型分类',
+			modelType: '模型类型',
+			apiKey: 'API密钥',
+			baseUrl: '基础URL',
+			maxToken: '最大令牌数',
+			isCallFun: '调用函数',
+			status: '状态'
+		},
+		// 占位符
+		placeholders: {
+			keyword: '搜索关键字',
+			modelClass: '模型分类',
+			modelName: '模型名称',
+			modelType: '请选择模型类型',
+			status: '状态',
+			startTime: '开始时间',
+			endTime: '结束时间',
+			inputModelName: '请输入模型名称',
+			inputModelClass: '请输入模型分类',
+			inputModelType: '请输入模型类型',
+			inputApiKey: '请输入API密钥',
+			inputBaseUrl: '请输入基础URL',
+			inputMaxToken: '请输入最大令牌数'
+		},
+		// 选项
+		options: {
+			all: '全部',
+			enabled: '启用',
+			disabled: '禁用',
+			embedding: '词嵌入',
+			chat: '对话模型',
+			yes: '是',
+			no: '否'
+		},
+		// 对话框
+		dialog: {
+			addTitle: '新增模型配置',
+			editTitle: '编辑模型配置',
+			cancel: '取消',
+			confirm: '确定'
+		},
+		// 消息
+		messages: {
+			selectDeleteItems: '请选择要删除的数据',
+			deleteConfirm: '您确定要删除所选数据吗?',
+			deleteConfirmSingle: '您确定要删除这条数据吗?',
+			enableConfirm: '您确定要启用这个模型配置吗?',
+			disableConfirm: '您确定要禁用这个模型配置吗?',
+			deleteSuccess: '删除成功',
+			deleteFailed: '删除失败',
+			enableSuccess: '启用成功',
+			disableSuccess: '禁用成功',
+			enableFailed: '启用失败',
+			disableFailed: '禁用失败',
+			addSuccess: '新增成功',
+			editSuccess: '编辑成功',
+			saveFailed: '保存失败',
+			getListFailed: '获取模型列表失败',
+			getDetailFailed: '获取模型详情失败',
+			confirmText: '确认',
+			cancelText: '取消',
+			warning: '提示'
+		},
+		// 验证规则
+		rules: {
+			modelNameRequired: '请输入模型名称',
+			modelClassRequired: '请输入模型分类',
+			modelTypeRequired: '请输入模型类型',
+			apiKeyRequired: '请输入API密钥',
+			baseUrlRequired: '请输入基础URL',
+			maxTokenNumber: '最大令牌数必须为数字'
+		}
+	},
+};

+ 253 - 0
src/i18n/pages/assistant/zh-tw.ts

@@ -0,0 +1,253 @@
+// 定義內容
+export default {
+	sidebar: {
+		conversationHistory: '對話歷史',
+		bookmark: '收藏夾',
+		createConversation: '創建對話',
+	},
+	settings: {
+		autoRecordToolCalls: '新對話自動記錄工具調用',
+		modelManagement: '模型管理',
+		promptManagement: '提示詞管理',
+		conversationManagement: '對話管理',
+		cancelSelection: '取消選擇',
+		deleteSelected: '刪除選中',
+	},
+	buttons: {
+		more: '更多操作',
+		export: '導出',
+		edit: '編輯',
+		delete: '刪除',
+		confirm: '確認修改',
+		cancel: '取消編輯',
+		retry: '重試',
+		search: '搜索',
+		reset: '重置',
+		confirmDialog: '確認',
+		cancelDialog: '取消',
+	},
+	status: {
+		noModelConfigured: '未配置模型',
+		loading: '加載中',
+		noPromptConfigured: '未配置提示詞',
+		aiThinking: 'AI正在思考中...',
+		uploading: '請等待上傳完成',
+		uploadProgress: '鬆開鼠標上傳文件...',
+		clickToUpload: '點擊右側+上傳文件',
+	},
+	messages: {
+		replaceMessageWarning: '替換消息會導致收藏內容被修改,是否繼續?',
+		messageSaveFailed: '消息保存失敗,切換對話會導致消息記錄丟失',
+		deleteSuccess: '刪除成功',
+		selectConversationsToDelete: '請選擇要刪除的會話',
+		deleteConfirm: '確定要刪除選中的 {count} 個會話嗎?此操作不可恢復!',
+		warning: '警告',
+		prompt: '提示',
+	},
+	placeholders: {
+		inputQuestion: '請輸入您的問題...',
+		searchBookmarks: '搜索收藏的消息...',
+		customPrompt: '在此編寫你的提示詞...(將用於本次會話的系統提示)',
+	},
+	empty: {
+		noBookmarks: '暫無收藏消息',
+		noBookmarksDescription: '您還沒有收藏任何對話消息',
+		startNewConversation: '開始新的對話',
+		tryTheseQuestions: '試試這些問題:',
+		bookmarkTip1: '在對話中點擊 ⭐ 按鈕即可收藏消息',
+		bookmarkTip2: '收藏的消息支持關鍵詞搜索',
+		bookmarkTip3: '收藏消息會保存在雲端,永不丟失',
+	},
+	examples: {
+		deviceStatus: '幫我查看設備運行狀態和告警信息',
+		userPermissions: '分析用戶權限配置和角色分配情況',
+		systemPerformance: '檢查系統性能和在線用戶統計',
+	},
+	prompt: {
+		// 搜索相關
+		search: {
+			keyword: '搜索關鍵字',
+			title: '提示詞標題',
+			dateRange: '時間範圍'
+		},
+		// 按鈕相關
+		buttons: {
+			search: '查詢',
+			reset: '重置',
+			add: '新增提示詞',
+			batchDelete: '批量刪除',
+			edit: '編輯',
+			delete: '刪除'
+		},
+		// 表格列
+		columns: {
+			id: 'ID',
+			title: '標題',
+			prompt: '提示詞內容',
+			placeholder: '占位符',
+			createdAt: '創建時間',
+			updatedAt: '更新時間',
+			actions: '操作'
+		},
+		// 表單相關
+		form: {
+			title: '提示詞標題',
+			prompt: '提示詞內容',
+			placeholder: '占位符'
+		},
+		// 占位符
+		placeholders: {
+			keyword: '搜索關鍵字',
+			title: '提示詞標題',
+			startTime: '開始時間',
+			endTime: '結束時間',
+			inputTitle: '請輸入提示詞標題',
+			inputPrompt: '請輸入提示詞內容',
+			inputPlaceholder: '請輸入占位符'
+		},
+		// 對話框
+		dialog: {
+			addTitle: '新增提示詞',
+			editTitle: '編輯提示詞',
+			cancel: '取消',
+			confirm: '確定'
+		},
+		// 消息
+		messages: {
+			selectDeleteItems: '請選擇要刪除的數據',
+			deleteConfirm: '您確定要刪除所選數據嗎?',
+			deleteConfirmSingle: '您確定要刪除這條數據嗎?',
+			deleteSuccess: '刪除成功',
+			deleteFailed: '刪除失敗',
+			addSuccess: '新增成功',
+			editSuccess: '編輯成功',
+			saveFailed: '保存失敗',
+			getListFailed: '獲取提示詞列表失敗',
+			getDetailFailed: '獲取提示詞詳情失敗',
+			confirmText: '確認',
+			cancelText: '取消',
+			warning: '提示',
+			// 舊的管理相關屬性保留兼容性
+			management: '提示詞管理',
+			customPrompt: '自定義提示詞',
+			customPromptWithCount: '自定義提示詞 ({count}) 字',
+			noPrompt: '不啟用提示詞'
+		},
+		// 驗證規則
+		rules: {
+			titleRequired: '請輸入提示詞標題',
+			promptRequired: '請輸入提示詞內容'
+		}
+	},
+	file: {
+		clickToOpen: '點擊打開: {name}',
+	},
+	model: {
+		// 搜索相關
+		search: {
+			keyword: '搜索關鍵字',
+			modelClass: '模型分類',
+			modelName: '模型名稱',
+			modelType: '模型類型',
+			status: '狀態',
+			dateRange: '時間範圍'
+		},
+		// 按鈕相關
+		buttons: {
+			search: '查詢',
+			reset: '重置',
+			add: '新增模型',
+			batchDelete: '批量刪除',
+			edit: '編輯',
+			delete: '刪除',
+			enable: '啟用',
+			disable: '禁用'
+		},
+		// 表格列
+		columns: {
+			id: 'ID',
+			modelName: '模型名稱',
+			modelClass: '模型分類',
+			modelType: '模型類型',
+			status: '狀態',
+			createdAt: '創建時間',
+			updatedAt: '更新時間',
+			actions: '操作'
+		},
+		// 表單相關
+		form: {
+			modelName: '模型名稱',
+			modelClass: '模型分類',
+			modelType: '模型類型',
+			apiKey: 'API密鑰',
+			baseUrl: '基础URL',
+			maxToken: '最大令牌數',
+			isCallFun: '調用函數',
+			status: '狀態'
+		},
+		// 占位符
+		placeholders: {
+			keyword: '搜索關鍵字',
+			modelClass: '模型分類',
+			modelName: '模型名稱',
+			modelType: '請選擇模型類型',
+			status: '狀態',
+			startTime: '開始時間',
+			endTime: '結束時間',
+			inputModelName: '請輸入模型名稱',
+			inputModelClass: '請輸入模型分類',
+			inputModelType: '請輸入模型類型',
+			inputApiKey: '請輸入API密鑰',
+			inputBaseUrl: '請輸入基础URL',
+			inputMaxToken: '請輸入最大令牌數'
+		},
+		// 選項
+		options: {
+			all: '全部',
+			enabled: '啟用',
+			disabled: '禁用',
+			embedding: '詞嵌入',
+			chat: '對話模型',
+			yes: '是',
+			no: '否'
+		},
+		// 對話框
+		dialog: {
+			addTitle: '新增模型配置',
+			editTitle: '編輯模型配置',
+			cancel: '取消',
+			confirm: '確定'
+		},
+		// 消息
+		messages: {
+			selectDeleteItems: '請選擇要刪除的數據',
+			deleteConfirm: '您確定要刪除所選數據嗎?',
+			deleteConfirmSingle: '您確定要刪除這條數據嗎?',
+			enableConfirm: '您確定要啟用這個模型配置嗎?',
+			disableConfirm: '您確定要禁用這個模型配置嗎?',
+			deleteSuccess: '刪除成功',
+			deleteFailed: '刪除失敗',
+			enableSuccess: '啟用成功',
+			disableSuccess: '禁用成功',
+			enableFailed: '啟用失敗',
+			disableFailed: '禁用失敗',
+			addSuccess: '新增成功',
+			editSuccess: '編輯成功',
+			saveFailed: '保存失敗',
+			getListFailed: '獲取模型列表失敗',
+			getDetailFailed: '獲取模型詳情失敗',
+			confirmText: '確認',
+			cancelText: '取消',
+			warning: '提示'
+		},
+		// 驗證規則
+		rules: {
+			modelNameRequired: '請輸入模型名稱',
+			modelClassRequired: '請輸入模型分類',
+			modelTypeRequired: '請輸入模型類型',
+			apiKeyRequired: '請輸入API密鑰',
+			baseUrlRequired: '請輸入基础URL',
+			maxTokenNumber: '最大令牌數必須為數字'
+		}
+	},
+};

+ 17 - 20
src/utils/loading-util.ts

@@ -1,23 +1,20 @@
-import { Ref, ref } from 'vue'
+import { ref } from 'vue'
 
 // eslint-disable-next-line no-unused-vars
-export function useLoading<T extends (...param: Parameters<T>) => Promise<void>>(
-  // eslint-disable-next-line no-unused-vars
-  inner: T
-): {
-  loading: Ref<boolean>
-  // eslint-disable-next-line no-unused-vars
-  doLoading: (...param: Parameters<T>) => Promise<void>
-} {
-  const loading = ref(false)
+export function useLoading<T extends (...args: any[]) => Promise<any>>(inner: T) {
+	const loading = ref(false)
 
-  return {
-    loading,
-    doLoading: async (...param: Parameters<T>) => {
-      loading.value = true
-      return inner(...param).finally(() => {
-        loading.value = false
-      })
-    },
-  }
-}
+	async function doLoading(...args: Parameters<T>): Promise<Awaited<ReturnType<T>>> {
+		loading.value = true
+		try {
+			return await inner(...args)
+		} finally {
+			loading.value = false
+		}
+	}
+
+	return {
+		loading,
+		doLoading,
+	}
+}

+ 567 - 0
src/views/assistant/dashboard/edit.vue

@@ -0,0 +1,567 @@
+<script setup lang="ts">
+import { useLoading } from '/@/utils/loading-util'
+import { computed, onMounted, ref, watch } from 'vue'
+import DashboardDesigner from '/@/components/assistant/DashboardDesigner.vue'
+import DashboardViewer from '/@/components/assistant/DashboardViewer.vue'
+import ComponentLibrary from '/@/components/assistant/ComponentLibrary.vue'
+import { ElMessage } from 'element-plus'
+import type { MarkdownDashBoard, Position, Size, Content, AddCardData, ComponentLibraryItem } from '/@/components/assistant/types'
+import { LmSession, Message } from '/@/api/assist/type'
+import assist from '/@/api/assist'
+import MarkdownIt from 'markdown-it'
+import { PieChart, View } from '@element-plus/icons-vue'
+import { useRoute } from 'vue-router'
+
+const route = useRoute()
+
+const id = computed(() => route.query.id as unknown as number)
+
+const cards = ref<MarkdownDashBoard[]>([])
+const title = ref<string>('新建仪表板')
+const remark = ref<string>('')
+
+// 预览相关状态
+const showPreviewDialog = ref(false)
+
+const { loading: loadingDashboard, doLoading: doLoadingDashBoard } = useLoading(async () => {
+	if (id.value === undefined) {
+		return
+	}
+	const res = await assist.dashboard.detail(id.value)
+	title.value = res.title
+	remark.value = res.remark || ''
+	cards.value = JSON.parse(res.data)
+})
+
+const renderer = computed<MarkdownDashBoard[]>(() => [...cards.value].sort((a, b) => a.z - b.z))
+
+// 生成唯一ID
+const generateId = () => {
+	return `card-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`
+}
+
+const { loading: loadingDashboardSubmit, doLoading: doLoadingDashboardSubmit } = useLoading(async () => {
+	try {
+		assist.dashboard.edit({
+			id: id.value,
+			title: title.value,
+			data: JSON.stringify(cards.value),
+			remark: remark.value,
+		})
+		ElMessage.success('仪表板保存成功')
+	} catch (error) {
+		ElMessage.error('保存失败')
+		throw error
+	}
+})
+
+// 添加新卡片
+const addCard = (cardData: AddCardData) => {
+	const newCard: MarkdownDashBoard = {
+		id: generateId(),
+		x: cardData.x ?? Math.random() * 50, // 使用传入位置或随机位置
+		y: cardData.y ?? Math.random() * 50,
+		w: 30,
+		h: 25,
+		z: Math.max(...cards.value.map((item) => item.z), 0) + 1,
+		type: cardData.type,
+		title: cardData.title,
+		data: cardData.data,
+	}
+	cards.value.push(newCard)
+}
+
+// 更新卡片位置
+const updateCardPosition = (id: string, position: Position) => {
+	const card = cards.value.find((item) => item.id === id)
+	if (card) {
+		card.x = position.x
+		card.y = position.y
+	}
+}
+
+// 更新卡片大小
+const updateCardSize = (id: string, size: Size) => {
+	const card = cards.value.find((item) => item.id === id)
+	if (card) {
+		card.w = size.w
+		card.h = size.h
+	}
+}
+
+// 更新卡片内容
+const updateCardContent = (id: string, content: Content) => {
+	const card = cards.value.find((item) => item.id === id)
+	if (card) {
+		card.title = content.title
+		card.data = content.data
+	}
+}
+
+// 删除卡片
+const removeCard = (id: string) => {
+	const index = cards.value.findIndex((item) => item.id === id)
+	if (index !== -1) {
+		cards.value.splice(index, 1)
+	}
+}
+
+const chat = ref<LmSession[]>([])
+const currentSelectedChat = ref<number>()
+
+watch(currentSelectedChat, (newVal) => {
+	if (newVal === undefined) {
+		//	/** 组件唯一标识 */
+		// id: string
+		// /** 组件标题 */
+		// title: string
+		// /** 组件图标 */
+		// icon: any
+		// /** 组件描述 */
+		// description: string
+		// /** 组件完整数据 */
+		// data: string
+		// /** 组件预览数据 */
+		// preview: string
+		library.value = defaultLibrary
+		return
+	}
+	if (chat.value[newVal]?.session_id === undefined) {
+		return
+	}
+	doLoadingLibrary(chat.value[newVal].session_id)
+})
+
+const { loading: loadingChat, doLoading: doLoadingChat } = useLoading(async () => {
+	const data: { list: LmSession[]; total: number } = await assist.session
+		.list({
+			pageNum: 1,
+			pageSize: 30,
+		})
+		.catch(() => {
+			return {
+				list: [],
+				total: 0,
+			}
+		})
+
+	chat.value = data.list
+})
+
+const defaultLibrary: ComponentLibraryItem[] = [
+	{
+		id: Math.random().toString(),
+		icon: 'ele-PieChart',
+		description: '测试',
+		type: 'markdown',
+		data: '测试',
+		preview: '测试',
+	},
+]
+
+// 组件库数据
+const library = ref<ComponentLibraryItem[]>(defaultLibrary)
+
+const { loading: loadingLibrary, doLoading: doLoadingLibrary } = useLoading(async (id: number) => {
+	const result: {
+		messages: Message[]
+		total: number
+	} = await assist.session.message.list({ sessionId: id }).catch(() => {
+		return {
+			list: [],
+			total: 0,
+		}
+	})
+
+	const messages = result.messages.filter((it) => it.role === 'assistant')
+
+	// 解析消息内容,提取echarts和表格
+	library.value = parseMessagesForLibrary(messages)
+})
+
+// 解析消息内容,提取echarts代码块和表格
+const parseMessagesForLibrary = (messages: Message[]): ComponentLibraryItem[] => {
+	const libraryItems: ComponentLibraryItem[] = []
+	const md = new MarkdownIt({
+		html: true,
+		linkify: true,
+		typographer: true,
+	})
+
+	messages.forEach((message, messageIndex) => {
+		if (!message.render_content) return
+
+		// 解析markdown内容为tokens
+		const tokens = md.parse(message.render_content, {})
+
+		// 提取echarts代码块
+		extractEchartsFromTokens(tokens, messageIndex, libraryItems)
+
+		// 提取表格
+		extractTablesFromTokens(tokens, messageIndex, libraryItems)
+
+		// 提取结构化数据
+		extractStructDataFromTokens(tokens, messageIndex, libraryItems)
+	})
+
+	return libraryItems
+}
+
+// 验证JSON格式(echarts)
+const isValidEchartsJSON = (str: string): boolean => {
+	if (str.startsWith('[')) {
+		return false
+	}
+	try {
+		const expr = JSON.parse(str)
+		return expr['series'] !== undefined
+	} catch {
+		return false
+	}
+}
+
+// 从tokens中提取echarts代码块
+const extractEchartsFromTokens = (tokens: any[], messageIndex: number, libraryItems: ComponentLibraryItem[]) => {
+	tokens.forEach((token, tokenIndex) => {
+		if (token.type === 'fence') {
+			const info = token.info ? token.info.trim() : ''
+			const content = token.content.trim()
+
+			if ((info === 'echarts' || info === 'json') && isValidEchartsJSON(content)) {
+				try {
+					const config = JSON.parse(content)
+					const title = config.title?.text || `图表 ${libraryItems.length + 1}`
+
+					libraryItems.push({
+						id: `echarts-${messageIndex}-${tokenIndex}`,
+						title: title,
+						icon: PieChart,
+						description: `来自消息的ECharts图表`,
+						type: 'echarts',
+						data: content,
+						preview: content,
+					})
+				} catch (error) {
+					console.warn('解析echarts配置失败:', error)
+				}
+			}
+		}
+	})
+}
+
+// 从tokens中提取echarts代码块
+const extractStructDataFromTokens = (tokens: any[], messageIndex: number, libraryItems: ComponentLibraryItem[]) => {
+	tokens.forEach((token, tokenIndex) => {
+		if (token.type === 'fence') {
+			const info = token.info ? token.info.trim() : ''
+			const content = token.content.trim()
+
+			if (info === 'structdata') {
+				try {
+					libraryItems.push({
+						id: `struct-${messageIndex}-${tokenIndex}`,
+						icon: PieChart,
+						description: `来自消息的动态图表`,
+						type: 'structdata',
+						data: content,
+						preview: content,
+					})
+				} catch (error) {
+					console.warn('解析struct配置失败:', error)
+				}
+			}
+		}
+	})
+}
+
+
+// 解析表格数据的接口
+interface TableData {
+	headers: string[]
+	rows: string[][]
+}
+
+// 从tokens中提取表格
+const extractTablesFromTokens = (tokens: any[], messageIndex: number, libraryItems: ComponentLibraryItem[]) => {
+	tokens.forEach((token, tokenIndex) => {
+		if (token.type === 'table_open') {
+			const tableData = parseTableDataFromTokens(tokens, tokenIndex + 1)
+
+			if (tableData.headers.length > 0 || tableData.rows.length > 0) {
+				const title = `表格 ${libraryItems.length + 1}`
+				const previewData = {
+					headers: tableData.headers.slice(0, 3), // 只显示前3列
+					rows: tableData.rows.slice(0, 3), // 只显示前3行
+				}
+
+				libraryItems.push({
+					id: `table-${messageIndex}-${tokenIndex}`,
+					title: title,
+					icon: 'ele-Table',
+					type: 'markdown',
+					description: `来自消息的数据表格 (${tableData.rows.length}行 x ${tableData.headers.length}列)`,
+					data: generateMarkdownTable(tableData),
+					preview: generateMarkdownTable(previewData),
+				})
+			}
+		}
+	})
+}
+
+// 解析表格tokens获取数据
+const parseTableDataFromTokens = (tokens: any[], startIdx: number): TableData => {
+	const headers: string[] = []
+	const rows: string[][] = []
+	let currentRow: string[] = []
+	let inHeader = false
+	let inBody = false
+	let cellContent = ''
+
+	for (let i = startIdx; i < tokens.length; i++) {
+		const token = tokens[i]
+
+		if (token.type === 'table_close') {
+			break
+		}
+
+		if (token.type === 'thead_open') {
+			inHeader = true
+			continue
+		}
+
+		if (token.type === 'thead_close') {
+			inHeader = false
+			continue
+		}
+
+		if (token.type === 'tbody_open') {
+			inBody = true
+			continue
+		}
+
+		if (token.type === 'tbody_close') {
+			inBody = false
+			continue
+		}
+
+		if (token.type === 'tr_open') {
+			currentRow = []
+			continue
+		}
+
+		if (token.type === 'tr_close') {
+			if (inHeader && currentRow.length > 0) {
+				headers.push(...currentRow)
+			} else if (inBody && currentRow.length > 0) {
+				rows.push([...currentRow])
+			}
+			currentRow = []
+			continue
+		}
+
+		if (token.type === 'th_open' || token.type === 'td_open') {
+			cellContent = ''
+			continue
+		}
+
+		if (token.type === 'th_close' || token.type === 'td_close') {
+			currentRow.push(cellContent.trim())
+			cellContent = ''
+			continue
+		}
+
+		if (token.type === 'inline' && token.children) {
+			// 处理内联内容
+			for (const child of token.children) {
+				if (child.type === 'text') {
+					cellContent += child.content
+				} else if (child.type === 'code_inline') {
+					cellContent += `\`${child.content}\``
+				} else if (child.type === 'strong_open') {
+					cellContent += '**'
+				} else if (child.type === 'strong_close') {
+					cellContent += '**'
+				} else if (child.type === 'em_open') {
+					cellContent += '*'
+				} else if (child.type === 'em_close') {
+					cellContent += '*'
+				}
+			}
+		}
+	}
+
+	return { headers, rows }
+}
+
+// 生成markdown表格
+const generateMarkdownTable = (tableData: TableData): string => {
+	if (tableData.headers.length === 0 && tableData.rows.length === 0) {
+		return ''
+	}
+
+	let markdown = ''
+
+	// 生成表头
+	if (tableData.headers.length > 0) {
+		markdown += '| ' + tableData.headers.join(' | ') + ' |\n'
+		markdown += '| ' + tableData.headers.map(() => '---').join(' | ') + ' |\n'
+	}
+
+	// 生成表格行
+	tableData.rows.forEach((row) => {
+		markdown += '| ' + row.join(' | ') + ' |\n'
+	})
+
+	return markdown
+}
+
+// 打开预览对话框
+const openPreview = () => {
+	showPreviewDialog.value = true
+}
+
+// 关闭预览对话框
+const closePreview = () => {
+	showPreviewDialog.value = false
+}
+
+onMounted( () => {
+	doLoadingChat()
+	doLoadingDashBoard()
+})
+</script>
+
+<template>
+	<div class="dashboard-edit-container">
+		<!-- 顶部工具栏 -->
+		<div class="toolbar">
+			<div class="toolbar-left">
+				<h2>仪表板设计器 | {{ title }}</h2>
+			</div>
+			<div class="toolbar-right">
+				<el-button :icon="View" @click="openPreview" :disabled="loadingDashboard">
+					预览
+				</el-button>
+				<el-button type="primary" @click="doLoadingDashboardSubmit" :loading="loadingDashboardSubmit" :disabled="loadingDashboard">
+					保存仪表板
+				</el-button>
+			</div>
+		</div>
+
+		<!-- 主要内容区域 -->
+		<div class="main-content" v-loading="loadingDashboard">
+			<!-- 左侧设计器面板 -->
+			<div class="designer-panel">
+				<DashboardDesigner
+					:cards="renderer"
+					@update-position="updateCardPosition"
+					@update-size="updateCardSize"
+					@update-content="updateCardContent"
+					@remove-card="removeCard"
+					@add-card="addCard"
+				/>
+			</div>
+
+			<!-- 右侧组件库面板 -->
+			<div class="library-panel">
+				<el-select class="library-panel-header" v-model="currentSelectedChat" v-loading="loadingChat" clearable @clear="currentSelectedChat = undefined">
+					<el-option v-for="(i, index) in chat" :key="i.session_id" :value="index" :label="i.title"></el-option>
+				</el-select>
+				<ComponentLibrary class="library-panel-content" v-loading="loadingLibrary" :library="library" @add-card="addCard" />
+			</div>
+		</div>
+
+		<!-- 预览对话框 -->
+		<el-dialog
+			v-model="showPreviewDialog"
+			title="仪表板预览"
+			width="90%"
+			:before-close="closePreview"
+			append-to-body
+			class="preview-dialog"
+		>
+			<div class="preview-container">
+				<DashboardViewer :cards="renderer" />
+			</div>
+		</el-dialog>
+	</div>
+</template>
+
+<style scoped lang="scss">
+.dashboard-edit-container {
+	height: 100vh;
+	display: flex;
+	flex-direction: column;
+	background: var(--el-bg-color-page);
+}
+
+.toolbar {
+	display: flex;
+	justify-content: space-between;
+	align-items: center;
+	padding: 16px 24px;
+	background: var(--el-bg-color);
+	border-bottom: 1px solid var(--el-border-color-light);
+	box-shadow: 0 1px 4px rgba(0, 0, 0, 0.1);
+
+	.toolbar-left {
+		h2 {
+			margin: 0;
+			color: var(--el-text-color-primary);
+			font-size: 18px;
+			font-weight: 600;
+		}
+	}
+
+	.toolbar-right {
+		display: flex;
+		gap: 12px;
+	}
+}
+
+.main-content {
+	flex: 1;
+	display: flex;
+	overflow: hidden;
+}
+
+.designer-panel {
+	flex: 1;
+	background: var(--el-bg-color);
+	border-right: 1px solid var(--el-border-color-light);
+}
+
+.library-panel {
+	width: 300px;
+	background: var(--el-bg-color);
+	border-left: 1px solid var(--el-border-color-light);
+
+	display: flex;
+	flex-direction: column;
+	align-items: center;
+
+	.library-panel-header {
+		width: 95%;
+		margin: 5%;
+	}
+
+	.library-panel-content {
+		width: 100%;
+	}
+}
+
+/* 预览对话框样式 */
+:deep(.preview-dialog) {
+	.el-dialog__body {
+		padding: 0;
+		height: 70vh;
+	}
+}
+
+.preview-container {
+	height: 100%;
+	border: 1px solid var(--el-border-color-light);
+	border-radius: 6px;
+	overflow: hidden;
+}
+</style>

+ 306 - 0
src/views/assistant/dashboard/index.vue

@@ -0,0 +1,306 @@
+<script setup lang="ts">
+import { onMounted, reactive, ref } from 'vue'
+import { ElMessage, ElMessageBox } from 'element-plus'
+import { Search as EleSearch, Refresh as EleRefresh, Plus as ElePlus, Delete as EleDelete, Edit as EleEdit, Setting as EleSetting } from '@element-plus/icons-vue'
+import { useLoading } from '/@/utils/loading-util'
+import { useRouter } from 'vue-router'
+import api from '/@/api/assist'
+import type { LmDashboard } from '/@/api/assist/type'
+import View from '/@/views/assistant/dashboard/view.vue'
+
+const router = useRouter()
+
+// 数据搜索部分
+const searchParam = reactive({
+	pageNum: 1,
+	pageSize: 10,
+	keyWord: '',
+	dateRange: [],
+})
+
+const total = ref<number>(0)
+const data = ref<Array<LmDashboard>>([])
+const ids = ref<number[]>([])
+
+// 加载列表数据
+const { loading, doLoading: doListLoad } = useLoading(async () => {
+	try {
+		const res: {
+			list: LmDashboard[]
+			total: number
+		} = await api.dashboard.list(searchParam)
+		total.value = res.total
+		data.value = res.list
+	} catch (error) {
+		console.error('获取仪表盘列表失败:', error)
+		data.value = []
+		total.value = 0
+	}
+})
+
+// 重置搜索条件
+const reset = () => {
+	Object.assign(searchParam, {
+		pageNum: 1,
+		pageSize: 10,
+		keyWord: '',
+		dateRange: [],
+	})
+	doListLoad()
+}
+
+// 选择删除项
+const onDeleteItemSelected = (selection: LmDashboard[]) => {
+	ids.value = selection.map((item) => item.id!).filter(Boolean)
+}
+
+// 批量删除
+const del = async () => {
+	if (ids.value.length === 0) {
+		ElMessage.error('请选择要删除的数据')
+		return
+	}
+
+	try {
+		await ElMessageBox.confirm('您确定要删除所选数据吗?', '提示', {
+			confirmButtonText: '确认',
+			cancelButtonText: '取消',
+			type: 'warning',
+		})
+
+		await api.dashboard.del(ids.value)
+		ElMessage.success('删除成功')
+		await doListLoad()
+	} catch (error) {
+		if (error !== 'cancel') {
+			console.error('删除失败:', error)
+			ElMessage.error('删除失败')
+		}
+	}
+}
+
+// 单个删除
+const delSingle = async (id: number) => {
+	try {
+		await ElMessageBox.confirm('您确定要删除这个仪表盘吗?', '提示', {
+			confirmButtonText: '确认',
+			cancelButtonText: '取消',
+			type: 'warning',
+		})
+
+		await api.dashboard.del([id])
+		ElMessage.success('删除成功')
+		await doListLoad()
+	} catch (error) {
+		if (error !== 'cancel') {
+			console.error('删除失败:', error)
+			ElMessage.error('删除失败')
+		}
+	}
+}
+
+// 编辑/新增对话框相关
+const dialogVisible = ref(false)
+const dialogTitle = ref('')
+const isEdit = ref(false)
+const formRef = ref()
+
+const formData = reactive<LmDashboard>({
+	id: 0,
+	title: '',
+	data: '',
+	remark: '',
+})
+
+// 表单验证规则
+const formRules = {
+	title: [{ required: true, message: '请输入仪表盘标题', trigger: 'blur' }],
+}
+
+// 重置表单
+const resetForm = () => {
+	Object.assign(formData, {
+		id: 0,
+		title: '',
+		data: '',
+		remark: '',
+	})
+	formRef.value?.clearValidate()
+}
+
+// 打开新增对话框
+const openAddDialog = () => {
+	resetForm()
+	dialogTitle.value = '新增仪表盘'
+	isEdit.value = false
+	dialogVisible.value = true
+}
+
+// 打开编辑对话框
+const openEditDialog = async (row: LmDashboard) => {
+	resetForm()
+	Object.assign(formData, row)
+	dialogTitle.value = '编辑仪表盘'
+	isEdit.value = true
+	dialogVisible.value = true
+}
+
+// 保存表单
+const { loading: saveLoading, doLoading: doSave } = useLoading(async () => {
+	try {
+		await formRef.value?.validate()
+
+		if (isEdit.value) {
+			await api.dashboard.edit(formData as any)
+			ElMessage.success('编辑成功')
+		} else {
+			await api.dashboard.add({...formData,data: '[]'} as any)
+			ElMessage.success('新增成功')
+		}
+
+		dialogVisible.value = false
+		await doListLoad()
+	} catch (error) {
+		console.error('保存失败:', error)
+		if (error !== false) {
+			// 表单验证失败时不显示错误消息
+			ElMessage.error('保存失败')
+		}
+	}
+})
+
+// 跳转到设计页面
+const goToDesign = (id?: number) => {
+	if (id) {
+		router.push(`/assistant/dashboard/edit?id=${id}`)
+	} else {
+		router.push('/assistant/dashboard/edit')
+	}
+}
+
+const goToView = (id: number) => {
+	router.push(`/assistant/dashboard/view?id=${id}`)
+}
+
+// 组件挂载时加载数据
+onMounted(() => {
+	doListLoad()
+})
+</script>
+
+<template>
+	<div class="system-model-container layout-padding">
+		<el-card shadow="hover" class="layout-padding-auto">
+			<!-- 搜索区域 -->
+			<el-form :model="searchParam" ref="queryRef" :inline="true" label-width="68px">
+				<el-form-item label="关键词" prop="keyWord">
+					<el-input v-model="searchParam.keyWord" placeholder="请输入标题关键词" clearable style="width: 200px" />
+				</el-form-item>
+				<el-form-item label="" prop="dateRange">
+					<el-date-picker
+						v-model="searchParam.dateRange"
+						style="width: 220px"
+						value-format="YYYY-MM-DD"
+						type="daterange"
+						range-separator="-"
+						start-placeholder="开始时间"
+						end-placeholder="结束时间"
+					/>
+				</el-form-item>
+				<el-form-item>
+					<el-button type="primary" @click="doListLoad">
+						<el-icon>
+							<EleSearch />
+						</el-icon>
+						查询
+					</el-button>
+					<el-button @click="reset">
+						<el-icon>
+							<EleRefresh />
+						</el-icon>
+						重置
+					</el-button>
+					<el-button type="primary" @click="openAddDialog">
+						<el-icon>
+							<ElePlus />
+						</el-icon>
+						新增仪表盘
+					</el-button>
+					<el-button type="danger" @click="del" :disabled="ids.length === 0">
+						<el-icon>
+							<EleDelete />
+						</el-icon>
+						批量删除
+					</el-button>
+				</el-form-item>
+			</el-form>
+
+			<!-- 表格区域 -->
+			<el-table :data="data" v-loading="loading" style="width: 100%" @selection-change="onDeleteItemSelected">
+				<el-table-column type="selection" width="55" align="center" />
+				<el-table-column label="ID" prop="id" width="80" align="center" />
+				<el-table-column label="标题" prop="title" min-width="200" align="center" />
+				<el-table-column label="创建时间" prop="createdAt" width="180" align="center" />
+				<el-table-column label="更新时间" prop="updatedAt" width="180" align="center" />
+				<el-table-column label="操作" width="250" align="center" fixed="right">
+					<template #default="scope">
+						<el-button text type="primary" size="small" @click="openEditDialog(scope.row)"> 编辑 </el-button>
+						<el-button text type="primary" size="small" @click="goToView(scope.row.id)">
+							<el-icon><ele-search /></el-icon>
+							预览
+						</el-button>
+						<el-button text type="success" size="small" @click="goToDesign(scope.row.id)">
+							<el-icon><EleSetting /></el-icon>
+							设计
+						</el-button>
+						<el-button text type="danger" size="small" @click="delSingle(scope.row.id)"> 删除 </el-button>
+					</template>
+				</el-table-column>
+			</el-table>
+
+			<!-- 分页 -->
+			<el-pagination
+				v-show="total > 0"
+				:current-page="searchParam.pageNum"
+				:page-size="searchParam.pageSize"
+				:page-sizes="[10, 20, 50, 100]"
+				:total="total"
+				layout="total, sizes, prev, pager, next, jumper"
+				@size-change="(val: number) => { searchParam.pageSize = val; doListLoad() }"
+				@current-change="(val: number) => { searchParam.pageNum = val; doListLoad() }"
+			/>
+		</el-card>
+
+		<!-- 新增/编辑对话框 -->
+		<el-dialog :title="dialogTitle" v-model="dialogVisible" width="600px" destroy-on-close>
+			<el-form ref="formRef" :model="formData" :rules="formRules" label-width="100px">
+				<el-form-item label="仪表盘标题" prop="title">
+					<el-input v-model="formData.title" placeholder="请输入仪表盘标题" clearable />
+				</el-form-item>
+				<el-form-item label="备注" prop="remark">
+					<el-input
+						v-model="formData.remark"
+						type="textarea"
+						:rows="3"
+						placeholder="请输入备注信息(可选)"
+						clearable
+					/>
+				</el-form-item>
+			</el-form>
+			<template #footer>
+				<div class="dialog-footer">
+					<el-button @click="dialogVisible = false">取消</el-button>
+					<el-button type="primary" @click="doSave" :loading="saveLoading">确定</el-button>
+				</div>
+			</template>
+		</el-dialog>
+	</div>
+</template>
+
+<style scoped lang="scss">
+.system-model-container {
+	.layout-padding-auto {
+		padding: 15px;
+	}
+}
+</style>

+ 27 - 0
src/views/assistant/dashboard/view.vue

@@ -0,0 +1,27 @@
+<script setup lang="ts">
+import { useRoute } from 'vue-router'
+import { computed, onMounted, ref } from 'vue'
+import DashboardViewer from '/@/components/assistant/DashboardViewer.vue'
+import { MarkdownDashBoard } from '/@/components/assistant/types'
+import { useLoading } from '/@/utils/loading-util'
+import assist from '/@/api/assist'
+
+const route = useRoute()
+const id = computed(() => parseInt(route.query['id']?.toString() ?? '-1'))
+
+const cards = ref<MarkdownDashBoard[]>([])
+
+const { loading: loadingCards, doLoading: executeCards } = useLoading(async () => {
+	assist.dashboard.detail(id.value).then((res) => {
+		cards.value = JSON.parse(res.data)
+	})
+})
+
+onMounted(executeCards)
+</script>
+
+<template>
+	<dashboard-viewer :cards="cards" style="width: 100%;height: 100%"/>
+</template>
+
+<style scoped lang="scss"></style>

+ 2530 - 0
src/views/assistant/index.vue

@@ -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>

+ 459 - 0
src/views/assistant/model.vue

@@ -0,0 +1,459 @@
+<script setup lang="ts">
+import { onMounted, reactive, ref } from 'vue'
+import { ElMessage, ElMessageBox } from 'element-plus'
+import { Search as EleSearch, Refresh as EleRefresh, Plus as ElePlus, Delete as EleDelete, Edit as EleEdit } from '@element-plus/icons-vue'
+import { useI18n } from 'vue-i18n'
+import { useLoading } from '/@/utils/loading-util'
+import api from '/@/api/assist'
+import type { LmConfigInfo, LmConfigListParams } from '/@/api/assist/type'
+
+const { t } = useI18n()
+
+// 数据搜索部分
+const searchParam = reactive<LmConfigListParams>({
+	pageNum: 1,
+	pageSize: 10,
+	keyWord: '',
+	modelClass: '',
+	modelName: '',
+	modelType: '',
+	status: '',
+	dateRange: [],
+})
+
+const total = ref<number>(0)
+const data = ref<Array<LmConfigInfo>>([])
+const ids = ref<number[]>([])
+
+// 加载列表数据
+const { loading, doLoading: doListLoad } = useLoading(async () => {
+	try {
+		const res: {
+			list: LmConfigInfo[]
+			total: number
+		} = await api.model.getList(searchParam)
+		total.value = res.total
+		data.value = res.list
+	} catch (error) {
+		console.error(t('message.assistant.model.messages.getListFailed'), error)
+		data.value = []
+		total.value = 0
+	}
+})
+
+// 重置搜索条件
+const reset = () => {
+	Object.assign(searchParam, {
+		pageNum: 1,
+		pageSize: 10,
+		keyWord: '',
+		modelClass: '',
+		modelName: '',
+		modelType: '',
+		status: '',
+		dateRange: [],
+	})
+	doListLoad()
+}
+
+// 选择删除项
+const onDeleteItemSelected = (selection: LmConfigInfo[]) => {
+	ids.value = selection.map((item) => item.id!).filter(Boolean)
+}
+
+// 批量删除
+const del = async () => {
+	if (ids.value.length === 0) {
+		ElMessage.error(t('message.assistant.model.messages.selectDeleteItems'))
+		return
+	}
+
+	try {
+		await ElMessageBox.confirm(t('message.assistant.model.messages.deleteConfirm'), t('message.assistant.model.messages.warning'), {
+			confirmButtonText: t('message.assistant.model.messages.confirmText'),
+			cancelButtonText: t('message.assistant.model.messages.cancelText'),
+			type: 'warning',
+		})
+
+		await api.model.del({ ids: ids.value })
+		ElMessage.success(t('message.assistant.model.messages.deleteSuccess'))
+		await doListLoad()
+	} catch (error) {
+		if (error !== 'cancel') {
+			console.error(t('message.assistant.model.messages.deleteFailed'), error)
+			ElMessage.error(t('message.assistant.model.messages.deleteFailed'))
+		}
+	}
+}
+
+// 单个删除
+const delSingle = async (id: number) => {
+	try {
+		await ElMessageBox.confirm(t('message.assistant.model.messages.deleteConfirmSingle'), t('message.assistant.model.messages.warning'), {
+			confirmButtonText: t('message.assistant.model.messages.confirmText'),
+			cancelButtonText: t('message.assistant.model.messages.cancelText'),
+			type: 'warning',
+		})
+
+		await api.model.del({ ids: [id] })
+		ElMessage.success(t('message.assistant.model.messages.deleteSuccess'))
+		await doListLoad()
+	} catch (error) {
+		if (error !== 'cancel') {
+			console.error(t('message.assistant.model.messages.deleteFailed'), error)
+			ElMessage.error(t('message.assistant.model.messages.deleteFailed'))
+		}
+	}
+}
+
+// 切换状态
+const toggleStatus = async (row: LmConfigInfo) => {
+	const newStatus = !row.status
+	const statusText = newStatus ? t('message.assistant.model.buttons.enable') : t('message.assistant.model.buttons.disable')
+	const confirmMessage = newStatus ? t('message.assistant.model.messages.enableConfirm') : t('message.assistant.model.messages.disableConfirm')
+
+	try {
+		await ElMessageBox.confirm(confirmMessage, t('message.assistant.model.messages.warning'), {
+			confirmButtonText: t('message.assistant.model.messages.confirmText'),
+			cancelButtonText: t('message.assistant.model.messages.cancelText'),
+			type: 'warning',
+		})
+
+		await api.model.setStatus({ id: row.id!, status: newStatus.toString() })
+		const successMessage = newStatus ? t('message.assistant.model.messages.enableSuccess') : t('message.assistant.model.messages.disableSuccess')
+		ElMessage.success(successMessage)
+		await doListLoad()
+	} catch (error) {
+		if (error !== 'cancel') {
+			const errorMessage = newStatus ? t('message.assistant.model.messages.enableFailed') : t('message.assistant.model.messages.disableFailed')
+			console.error(errorMessage, error)
+			ElMessage.error(errorMessage)
+		}
+	}
+}
+
+// 编辑/新增对话框相关
+const dialogVisible = ref(false)
+const dialogTitle = ref('')
+const isEdit = ref(false)
+const formRef = ref()
+
+const formData = reactive<LmConfigInfo>({
+	id: undefined,
+	modelClass: '',
+	modelName: '',
+	apiKey: '',
+	baseUrl: '',
+	modelType: '',
+	isCallFun: false,
+	maxToken: 0,
+	status: true,
+})
+
+// 表单验证规则
+const formRules = {
+	modelName: [{ required: true, message: t('message.assistant.model.rules.modelNameRequired'), trigger: 'blur' }],
+	modelClass: [{ required: true, message: t('message.assistant.model.rules.modelClassRequired'), trigger: 'blur' }],
+	modelType: [{ required: true, message: t('message.assistant.model.rules.modelTypeRequired'), trigger: 'blur' }],
+	apiKey: [{ required: true, message: t('message.assistant.model.rules.apiKeyRequired'), trigger: 'blur' }],
+	baseUrl: [{ required: true, message: t('message.assistant.model.rules.baseUrlRequired'), trigger: 'blur' }],
+	maxToken: [{ type: 'number', message: t('message.assistant.model.rules.maxTokenNumber'), trigger: 'blur' },],
+}
+
+// 重置表单
+const resetForm = () => {
+	Object.assign(formData, {
+		id: undefined,
+		modelClass: '',
+		modelName: '',
+		apiKey: '',
+		baseUrl: '',
+		modelType: '',
+		isCallFun: false,
+		maxToken: undefined,
+		status: true,
+	})
+	formRef.value?.clearValidate()
+}
+
+// 打开新增对话框
+const openAddDialog = () => {
+	resetForm()
+	dialogTitle.value = t('message.assistant.model.dialog.addTitle')
+	isEdit.value = false
+	dialogVisible.value = true
+}
+
+const loadingNum = ref<number>(-1)
+// 打开编辑对话框
+const { doLoading: openEditDialog,loading: loadingOpenEditDialog } = useLoading(async (row: LmConfigInfo) => {
+	try {
+		loadingNum.value = row.id ?? -1
+		const res = await api.model.detail({ id: row.id! })
+		Object.assign(formData, res)
+		dialogTitle.value = t('message.assistant.model.dialog.editTitle')
+		isEdit.value = true
+		dialogVisible.value = true
+	} catch (error) {
+		console.error(t('message.assistant.model.messages.getDetailFailed'), error)
+		ElMessage.error(t('message.assistant.model.messages.getDetailFailed'))
+	}
+})
+
+// 保存表单
+const { loading: saveLoading, doLoading: doSave } = useLoading(async () => {
+	try {
+		await formRef.value?.validate()
+
+		if (isEdit.value) {
+			await api.model.edit(formData as any)
+			ElMessage.success(t('message.assistant.model.messages.editSuccess'))
+		} else {
+			await api.model.add(formData as any)
+			ElMessage.success(t('message.assistant.model.messages.addSuccess'))
+		}
+
+		dialogVisible.value = false
+		await doListLoad()
+	} catch (error) {
+		console.error(t('message.assistant.model.messages.saveFailed'), error)
+		if (error !== false) {
+			// 表单验证失败时不显示错误消息
+			ElMessage.error(t('message.assistant.model.messages.saveFailed'))
+		}
+	}
+})
+
+// 组件挂载时加载数据
+onMounted(() => {
+	doListLoad()
+})
+</script>
+
+<template>
+	<el-card shadow="never" class="page">
+		<!-- 搜索表单 -->
+		<el-form :model="searchParam" inline>
+			<el-form-item label="" prop="keyWord">
+				<el-input style="width: 200px" v-model="searchParam.keyWord" :placeholder="t('message.assistant.model.placeholders.keyword')" clearable />
+			</el-form-item>
+			<el-form-item label="" prop="modelClass">
+				<el-input style="width: 150px" v-model="searchParam.modelClass" :placeholder="t('message.assistant.model.placeholders.modelClass')" clearable />
+			</el-form-item>
+			<el-form-item label="" prop="modelName">
+				<el-input style="width: 150px" v-model="searchParam.modelName" :placeholder="t('message.assistant.model.placeholders.modelName')" clearable />
+			</el-form-item>
+			<el-form-item label="" prop="modelType">
+				<el-select v-model="searchParam.modelType" :placeholder="t('message.assistant.model.placeholders.modelType')" clearable>
+					<el-option :label="t('message.assistant.model.options.embedding')" value="embedding" />
+					<el-option :label="t('message.assistant.model.options.chat')" value="chat" />
+				</el-select>
+			</el-form-item>
+			<el-form-item label="" prop="status">
+				<el-select style="width: 125px" v-model="searchParam.status" :placeholder="t('message.assistant.model.placeholders.status')" clearable>
+					<el-option :label="t('message.assistant.model.options.all')" value="" />
+					<el-option :label="t('message.assistant.model.options.enabled')" value="true" />
+					<el-option :label="t('message.assistant.model.options.disabled')" value="false" />
+				</el-select>
+			</el-form-item>
+			<el-form-item label="" prop="dateRange">
+				<el-date-picker
+					v-model="searchParam.dateRange"
+					style="width: 220px"
+					value-format="YYYY-MM-DD"
+					type="daterange"
+					range-separator="-"
+					:start-placeholder="t('message.assistant.model.placeholders.startTime')"
+					:end-placeholder="t('message.assistant.model.placeholders.endTime')"
+				/>
+			</el-form-item>
+			<el-form-item>
+				<el-button type="primary" @click="doListLoad">
+					<el-icon>
+						<EleSearch />
+					</el-icon>
+					{{ t('message.assistant.model.buttons.search') }}
+				</el-button>
+				<el-button @click="reset">
+					<el-icon>
+						<EleRefresh />
+					</el-icon>
+					{{ t('message.assistant.model.buttons.reset') }}
+				</el-button>
+				<el-button type="primary" @click="openAddDialog">
+					<el-icon>
+						<ElePlus />
+					</el-icon>
+					{{ t('message.assistant.model.buttons.add') }}
+				</el-button>
+				<el-button type="danger" @click="del" :disabled="ids.length === 0">
+					<el-icon>
+						<EleDelete />
+					</el-icon>
+					{{ t('message.assistant.model.buttons.batchDelete') }}
+				</el-button>
+			</el-form-item>
+		</el-form>
+
+		<!-- 数据表格 -->
+		<el-table :data="data" style="width: 100%" v-loading="loading" @selection-change="onDeleteItemSelected">
+			<el-table-column type="selection" width="50" align="center" />
+			<el-table-column :label="t('message.assistant.model.columns.id')" prop="id" width="80" align="center" />
+			<el-table-column :label="t('message.assistant.model.columns.modelName')" prop="modelName" align="center" show-overflow-tooltip />
+			<el-table-column :label="t('message.assistant.model.columns.modelClass')" prop="modelClass" align="center" show-overflow-tooltip />
+			<el-table-column :label="t('message.assistant.model.columns.modelType')" prop="modelType" align="center" show-overflow-tooltip />
+			<el-table-column :label="t('message.assistant.model.columns.status')" prop="status" width="100" align="center">
+				<template #default="scope">
+					<el-tag :type="scope.row.status ? 'success' : 'danger'" size="small">
+						{{ scope.row.status ? t('message.assistant.model.options.enabled') : t('message.assistant.model.options.disabled') }}
+					</el-tag>
+				</template>
+			</el-table-column>
+			<el-table-column :label="t('message.assistant.model.columns.createdAt')" prop="createdAt" width="180" align="center" />
+			<el-table-column :label="t('message.assistant.model.columns.updatedAt')" prop="updatedAt" width="180" align="center" />
+			<el-table-column :label="t('message.assistant.model.columns.actions')" width="200" align="center" fixed="right">
+				<template #default="scope">
+					<el-button text type="primary" size="small" @click="openEditDialog(scope.row)" :loading="loadingOpenEditDialog && loadingNum === scope.row.id"> {{ t('message.assistant.model.buttons.edit') }} </el-button>
+					<el-button text :type="scope.row.status ? 'warning' : 'success'" size="small" @click="toggleStatus(scope.row)">
+						{{ scope.row.status ? t('message.assistant.model.buttons.disable') : t('message.assistant.model.buttons.enable') }}
+					</el-button>
+					<el-button text type="danger" size="small" @click="delSingle(scope.row.id)"> {{ t('message.assistant.model.buttons.delete') }} </el-button>
+				</template>
+			</el-table-column>
+		</el-table>
+
+		<!-- 分页 -->
+		<div class="pagination-container">
+			<el-pagination
+				v-show="total > 0"
+				:current-page="searchParam.pageNum"
+				:page-size="searchParam.pageSize"
+				:page-sizes="[10, 20, 50, 100]"
+				:total="total"
+				layout="total, sizes, prev, pager, next, jumper"
+				@size-change="(size: number) => { searchParam.pageSize = size; doListLoad(); }"
+				@current-change="(page: number) => { searchParam.pageNum = page; doListLoad(); }"
+			/>
+		</div>
+
+		<!-- 编辑/新增对话框 -->
+		<el-dialog :title="dialogTitle" v-model="dialogVisible" width="600px" destroy-on-close @close="resetForm">
+			<el-form ref="formRef" :model="formData" :rules="formRules" label-width="100px">
+				<el-form-item :label="t('message.assistant.model.form.modelName')" prop="modelName">
+					<el-input v-model="formData.modelName" :placeholder="t('message.assistant.model.placeholders.inputModelName')" clearable />
+				</el-form-item>
+				<el-form-item :label="t('message.assistant.model.form.modelClass')" prop="modelClass">
+					<el-input v-model="formData.modelClass" :placeholder="t('message.assistant.model.placeholders.inputModelClass')" clearable />
+				</el-form-item>
+				<el-form-item :label="t('message.assistant.model.form.modelType')" prop="modelType">
+					<el-select v-model="formData.modelType" :placeholder="t('message.assistant.model.placeholders.modelType')">
+						<el-option :label="t('message.assistant.model.options.embedding')" value="embedding" />
+						<el-option :label="t('message.assistant.model.options.chat')" value="chat" />
+					</el-select>
+<!--					<el-input v-model="formData.modelType" placeholder="请输入模型类型" clearable />-->
+				</el-form-item>
+				<el-form-item :label="t('message.assistant.model.form.apiKey')" prop="apiKey">
+					<el-input v-model="formData.apiKey" type="password" :placeholder="t('message.assistant.model.placeholders.inputApiKey')" clearable show-password />
+				</el-form-item>
+				<el-form-item :label="t('message.assistant.model.form.baseUrl')" prop="baseUrl">
+					<el-input v-model="formData.baseUrl" :placeholder="t('message.assistant.model.placeholders.inputBaseUrl')" clearable />
+				</el-form-item>
+				<el-form-item :label="t('message.assistant.model.form.maxToken')" prop="maxToken">
+					<el-input-number v-model="formData.maxToken" :min="1" :max="999999" :placeholder="t('message.assistant.model.placeholders.inputMaxToken')" style="width: 100%" />
+				</el-form-item>
+				<el-form-item :label="t('message.assistant.model.form.isCallFun')" prop="isCallFun">
+					<el-radio-group v-model="formData.isCallFun">
+						<el-radio :label="true">{{ t('message.assistant.model.options.yes') }}</el-radio>
+						<el-radio :label="false">{{ t('message.assistant.model.options.no') }}</el-radio>
+					</el-radio-group>
+				</el-form-item>
+				<el-form-item :label="t('message.assistant.model.form.status')" prop="status">
+					<el-radio-group v-model="formData.status">
+						<el-radio :label="true">{{ t('message.assistant.model.options.enabled') }}</el-radio>
+						<el-radio :label="false">{{ t('message.assistant.model.options.disabled') }}</el-radio>
+					</el-radio-group>
+				</el-form-item>
+			</el-form>
+
+			<template #footer>
+				<div class="dialog-footer">
+					<el-button @click="dialogVisible = false">{{ t('message.assistant.model.dialog.cancel') }}</el-button>
+					<el-button type="primary" @click="doSave" :loading="saveLoading"> {{ t('message.assistant.model.dialog.confirm') }} </el-button>
+				</div>
+			</template>
+		</el-dialog>
+	</el-card>
+</template>
+
+<style scoped lang="scss">
+.page {
+	margin: 20px;
+
+	.el-form {
+		margin-bottom: 20px;
+
+		.el-form-item {
+			margin-bottom: 20px;
+		}
+	}
+
+	.pagination-container {
+		margin-top: 20px;
+		display: flex;
+		justify-content: center;
+	}
+
+	.dialog-footer {
+		text-align: right;
+
+		.el-button {
+			margin-left: 10px;
+		}
+	}
+}
+
+// 表格样式优化
+.el-table {
+	.el-table__header {
+		th {
+			background-color: var(--el-bg-color-page);
+			color: var(--el-text-color-primary);
+			font-weight: 600;
+		}
+	}
+
+	.el-table__row {
+		&:hover {
+			background-color: var(--el-bg-color-page);
+		}
+	}
+}
+
+// 按钮组样式
+.el-form-item:last-child {
+	.el-button {
+		margin-right: 10px;
+
+		&:last-child {
+			margin-right: 0;
+		}
+	}
+}
+
+// 状态标签样式
+.el-tag {
+	font-weight: 500;
+}
+
+// 操作按钮样式
+.el-table__fixed-right {
+	.el-button {
+		margin: 0 2px;
+
+		&.el-button--small {
+			padding: 5px 8px;
+			font-size: 12px;
+		}
+	}
+}
+</style>

+ 391 - 0
src/views/assistant/prompt.vue

@@ -0,0 +1,391 @@
+<script setup lang="ts">
+import { onMounted, reactive, ref } from 'vue'
+import { ElMessage, ElMessageBox } from 'element-plus'
+import { Search as EleSearch, Refresh as EleRefresh, Plus as ElePlus, Delete as EleDelete, Edit as EleEdit } from '@element-plus/icons-vue'
+import { useI18n } from 'vue-i18n'
+import { useLoading } from '/@/utils/loading-util'
+import api from '/@/api/assist'
+import type { Prompt, PromptListParams } from '/@/api/assist/type'
+
+const { t } = useI18n()
+
+// 数据搜索部分
+const searchParam = reactive<PromptListParams>({
+	pageNum: 1,
+	pageSize: 10,
+	keyWord: '',
+	title: '',
+	dateRange: [],
+})
+
+const total = ref<number>(0)
+const data = ref<Array<Prompt>>([])
+const ids = ref<number[]>([])
+
+// 加载列表数据
+const { loading, doLoading: doListLoad } = useLoading(async () => {
+	try {
+		const res: {
+			list: Prompt[]
+			total: number
+		} = await api.chat.prompt.list(searchParam)
+		total.value = res.total
+		data.value = res.list
+	} catch (error) {
+		console.error(t('message.assistant.prompt.messages.getListFailed'), error)
+		data.value = []
+		total.value = 0
+	}
+})
+
+// 重置搜索条件
+const reset = () => {
+	Object.assign(searchParam, {
+		pageNum: 1,
+		pageSize: 10,
+		keyWord: '',
+		title: '',
+		dateRange: [],
+	})
+	doListLoad()
+}
+
+// 选择删除项
+const onDeleteItemSelected = (selection: Prompt[]) => {
+	ids.value = selection.map((item) => item.id).filter(Boolean)
+}
+
+// 批量删除
+const del = async () => {
+	if (ids.value.length === 0) {
+		ElMessage.error(t('message.assistant.prompt.messages.selectDeleteItems'))
+		return
+	}
+
+	try {
+		await ElMessageBox.confirm(t('message.assistant.prompt.messages.deleteConfirm'), t('message.assistant.prompt.messages.warning'), {
+			confirmButtonText: t('message.assistant.prompt.messages.confirmText'),
+			cancelButtonText: t('message.assistant.prompt.messages.cancelText'),
+			type: 'warning',
+		})
+
+		await api.chat.prompt.del({ ids: ids.value })
+		ElMessage.success(t('message.assistant.prompt.messages.deleteSuccess'))
+		await doListLoad()
+	} catch (error) {
+		if (error !== 'cancel') {
+			console.error(t('message.assistant.prompt.messages.deleteFailed'), error)
+			ElMessage.error(t('message.assistant.prompt.messages.deleteFailed'))
+		}
+	}
+}
+
+// 单个删除
+const delSingle = async (id: number) => {
+	try {
+		await ElMessageBox.confirm(t('message.assistant.prompt.messages.deleteConfirmSingle'), t('message.assistant.prompt.messages.warning'), {
+			confirmButtonText: t('message.assistant.prompt.messages.confirmText'),
+			cancelButtonText: t('message.assistant.prompt.messages.cancelText'),
+			type: 'warning',
+		})
+
+		await api.chat.prompt.del({ ids: [id] })
+		ElMessage.success(t('message.assistant.prompt.messages.deleteSuccess'))
+		await doListLoad()
+	} catch (error) {
+		if (error !== 'cancel') {
+			console.error(t('message.assistant.prompt.messages.deleteFailed'), error)
+			ElMessage.error(t('message.assistant.prompt.messages.deleteFailed'))
+		}
+	}
+}
+
+// 编辑/新增对话框相关
+const dialogVisible = ref(false)
+const dialogTitle = ref('')
+const isEdit = ref(false)
+const formRef = ref()
+
+const formData = reactive<Prompt>({
+	id: 0,
+	title: '',
+	prompt: '',
+	placeholder: '',
+})
+
+// 表单验证规则
+const formRules = {
+	title: [{ required: true, message: t('message.assistant.prompt.rules.titleRequired'), trigger: 'blur' }],
+	prompt: [{ required: true, message: t('message.assistant.prompt.rules.promptRequired'), trigger: 'blur' }],
+}
+
+// 重置表单
+const resetForm = () => {
+	Object.assign(formData, {
+		id: 0,
+		title: '',
+		prompt: '',
+	})
+	formRef.value?.clearValidate()
+}
+
+// 打开新增对话框
+const openAddDialog = () => {
+	resetForm()
+	dialogTitle.value = t('message.assistant.prompt.dialog.addTitle')
+	isEdit.value = false
+	dialogVisible.value = true
+}
+
+const loadingNum = ref<number>(-1)
+// 打开编辑对话框
+const { doLoading: openEditDialog, loading: loadingOpenEditDialog } = useLoading(async (row: Prompt) => {
+	try {
+		loadingNum.value = row.id ?? -1
+		const res = await api.chat.prompt.detail({ id: row.id })
+		Object.assign(formData, res)
+		dialogTitle.value = t('message.assistant.prompt.dialog.editTitle')
+		isEdit.value = true
+		dialogVisible.value = true
+	} catch (error) {
+		console.error(t('message.assistant.prompt.messages.getDetailFailed'), error)
+		ElMessage.error(t('message.assistant.prompt.messages.getDetailFailed'))
+	}
+})
+
+// 保存表单
+const { loading: saveLoading, doLoading: doSave } = useLoading(async () => {
+	try {
+		await formRef.value?.validate()
+
+		if (isEdit.value) {
+			await api.chat.prompt.edit(formData as any)
+			ElMessage.success(t('message.assistant.prompt.messages.editSuccess'))
+		} else {
+			await api.chat.prompt.add(formData as any)
+			ElMessage.success(t('message.assistant.prompt.messages.addSuccess'))
+		}
+
+		dialogVisible.value = false
+		await doListLoad()
+	} catch (error) {
+		console.error(t('message.assistant.prompt.messages.saveFailed'), error)
+		if (error !== false) {
+			// 表单验证失败时不显示错误消息
+			ElMessage.error(t('message.assistant.prompt.messages.saveFailed'))
+		}
+	}
+})
+
+// 组件挂载时加载数据
+onMounted(() => {
+	doListLoad()
+})
+</script>
+
+<template>
+	<el-card shadow="never" class="page">
+		<!-- 搜索表单 -->
+		<el-form :model="searchParam" inline>
+			<el-form-item label="" prop="keyWord">
+				<el-input style="width: 200px" v-model="searchParam.keyWord" :placeholder="t('message.assistant.prompt.placeholders.keyword')" clearable />
+			</el-form-item>
+			<el-form-item label="" prop="title">
+				<el-input style="width: 150px" v-model="searchParam.title" :placeholder="t('message.assistant.prompt.placeholders.title')" clearable />
+			</el-form-item>
+			<el-form-item label="" prop="dateRange">
+				<el-date-picker
+					v-model="searchParam.dateRange"
+					style="width: 220px"
+					value-format="YYYY-MM-DD"
+					type="daterange"
+					range-separator="-"
+					:start-placeholder="t('message.assistant.prompt.placeholders.startTime')"
+					:end-placeholder="t('message.assistant.prompt.placeholders.endTime')"
+				/>
+			</el-form-item>
+			<el-form-item>
+				<el-button type="primary" @click="doListLoad">
+					<el-icon>
+						<EleSearch />
+					</el-icon>
+					{{ t('message.assistant.prompt.buttons.search') }}
+				</el-button>
+				<el-button @click="reset">
+					<el-icon>
+						<EleRefresh />
+					</el-icon>
+					{{ t('message.assistant.prompt.buttons.reset') }}
+				</el-button>
+				<el-button type="primary" @click="openAddDialog">
+					<el-icon>
+						<ElePlus />
+					</el-icon>
+					{{ t('message.assistant.prompt.buttons.add') }}
+				</el-button>
+				<el-button type="danger" @click="del" :disabled="ids.length === 0">
+					<el-icon>
+						<EleDelete />
+					</el-icon>
+					{{ t('message.assistant.prompt.buttons.batchDelete') }}
+				</el-button>
+			</el-form-item>
+		</el-form>
+
+		<!-- 数据表格 -->
+		<el-table :data="data" style="width: 100%" v-loading="loading" @selection-change="onDeleteItemSelected">
+			<el-table-column type="selection" width="50" align="center" />
+			<el-table-column :label="t('message.assistant.prompt.columns.id')" prop="id" width="80" align="center" />
+			<el-table-column :label="t('message.assistant.prompt.columns.title')" prop="title" align="center" show-overflow-tooltip />
+			<el-table-column :label="t('message.assistant.prompt.columns.prompt')" prop="prompt" align="center" show-overflow-tooltip>
+				<template #default="{row}:{row:Prompt}">
+					<div class="prompt-content">
+						{{ row.prompt.length > 100 ? row.prompt.substring(0, 100) + '...' : row.prompt }}
+					</div>
+				</template>
+			</el-table-column>
+			<el-table-column :label="t('message.assistant.prompt.columns.placeholder')" prop="placeholder" align="center" show-overflow-tooltip>
+				<template #default="{row}:{row:Prompt}">
+					<div class="prompt-placeholder">
+						{{ (row.placeholder?.length ?? 0) > 100 ? row.placeholder!.substring(0, 100) + '...' : row.placeholder}}
+					</div>
+				</template>
+			</el-table-column>
+			<el-table-column :label="t('message.assistant.prompt.columns.createdAt')" prop="createdAt" width="180" align="center" />
+			<el-table-column :label="t('message.assistant.prompt.columns.updatedAt')" prop="updatedAt" width="180" align="center" />
+			<el-table-column :label="t('message.assistant.prompt.columns.actions')" width="150" align="center" fixed="right">
+				<template #default="scope">
+					<el-button text type="primary" size="small" @click="openEditDialog(scope.row)" :loading="loadingOpenEditDialog && loadingNum === scope.row.id"> {{ t('message.assistant.prompt.buttons.edit') }} </el-button>
+					<el-button text type="danger" size="small" @click="delSingle(scope.row.id)"> {{ t('message.assistant.prompt.buttons.delete') }} </el-button>
+				</template>
+			</el-table-column>
+		</el-table>
+
+		<!-- 分页 -->
+		<div class="pagination-container">
+			<el-pagination
+				v-show="total > 0"
+				:current-page="searchParam.pageNum"
+				:page-size="searchParam.pageSize"
+				:page-sizes="[10, 20, 50, 100]"
+				:total="total"
+				layout="total, sizes, prev, pager, next, jumper"
+				@size-change="(size: number) => { searchParam.pageSize = size; doListLoad(); }"
+				@current-change="(page: number) => { searchParam.pageNum = page; doListLoad(); }"
+			/>
+		</div>
+
+		<!-- 编辑/新增对话框 -->
+		<el-dialog :title="dialogTitle" v-model="dialogVisible" width="800px" destroy-on-close @close="resetForm">
+			<el-form ref="formRef" :model="formData" :rules="formRules" label-width="100px">
+				<el-form-item :label="t('message.assistant.prompt.form.title')" prop="title">
+					<el-input v-model="formData.title" :placeholder="t('message.assistant.prompt.placeholders.inputTitle')" clearable />
+				</el-form-item>
+				<el-form-item :label="t('message.assistant.prompt.form.prompt')" prop="prompt">
+					<el-input
+						v-model="formData.prompt"
+						type="textarea"
+						:rows="10"
+						:placeholder="t('message.assistant.prompt.placeholders.inputPrompt')"
+						clearable
+						show-word-limit
+						maxlength="2000"
+					/>
+				</el-form-item>
+				<el-form-item :label="t('message.assistant.prompt.form.placeholder')" prop="placeholder">
+					<el-input
+						v-model="formData.placeholder"
+						type="textarea"
+						:rows="3"
+						:placeholder="t('message.assistant.prompt.placeholders.inputPlaceholder')"
+						clearable
+						show-word-limit
+						maxlength="2000"
+					/>
+				</el-form-item>
+			</el-form>
+
+			<template #footer>
+				<div class="dialog-footer">
+					<el-button @click="dialogVisible = false">{{ t('message.assistant.prompt.dialog.cancel') }}</el-button>
+					<el-button type="primary" @click="doSave" :loading="saveLoading"> {{ t('message.assistant.prompt.dialog.confirm') }} </el-button>
+				</div>
+			</template>
+		</el-dialog>
+	</el-card>
+</template>
+
+<style scoped lang="scss">
+.page {
+	margin: 20px;
+
+	.el-form {
+		margin-bottom: 20px;
+
+		.el-form-item {
+			margin-bottom: 20px;
+		}
+	}
+
+	.pagination-container {
+		margin-top: 20px;
+		display: flex;
+		justify-content: center;
+	}
+
+	.dialog-footer {
+		text-align: right;
+
+		.el-button {
+			margin-left: 10px;
+		}
+	}
+}
+
+// 提示词内容样式
+.prompt-content {
+	max-width: 300px;
+	word-break: break-all;
+	line-height: 1.4;
+}
+
+// 表格样式优化
+.el-table {
+	.el-table__header {
+		th {
+			background-color: var(--el-bg-color-page);
+			color: var(--el-text-color-primary);
+			font-weight: 600;
+		}
+	}
+
+	.el-table__row {
+		&:hover {
+			background-color: var(--el-bg-color-page);
+		}
+	}
+}
+
+// 按钮组样式
+.el-form-item:last-child {
+	.el-button {
+		margin-right: 10px;
+
+		&:last-child {
+			margin-right: 0;
+		}
+	}
+}
+
+// 操作按钮样式
+.el-table__fixed-right {
+	.el-button {
+		margin: 0 2px;
+
+		&.el-button--small {
+			padding: 5px 8px;
+			font-size: 12px;
+		}
+	}
+}
+</style>

Filskillnaden har hållts tillbaka eftersom den är för stor
+ 100 - 510
yarn.lock


Vissa filer visades inte eftersom för många filer har ändrats