jiaxing.liao недель назад: 3
Родитель
Сommit
b7fd26994e

+ 2 - 0
apps/web/components.d.ts

@@ -17,6 +17,7 @@ declare module 'vue' {
     ElAside: typeof import('element-plus/es')['ElAside']
     ElAutocomplete: typeof import('element-plus/es')['ElAutocomplete']
     ElAvatar: typeof import('element-plus/es')['ElAvatar']
+    ElBadge: typeof import('element-plus/es')['ElBadge']
     ElBreadcrumb: typeof import('element-plus/es')['ElBreadcrumb']
     ElBreadcrumbItem: typeof import('element-plus/es')['ElBreadcrumbItem']
     ElButton: typeof import('element-plus/es')['ElButton']
@@ -95,6 +96,7 @@ declare global {
   const ElAside: typeof import('element-plus/es')['ElAside']
   const ElAutocomplete: typeof import('element-plus/es')['ElAutocomplete']
   const ElAvatar: typeof import('element-plus/es')['ElAvatar']
+  const ElBadge: typeof import('element-plus/es')['ElBadge']
   const ElBreadcrumb: typeof import('element-plus/es')['ElBreadcrumb']
   const ElBreadcrumbItem: typeof import('element-plus/es')['ElBreadcrumbItem']
   const ElButton: typeof import('element-plus/es')['ElButton']

+ 161 - 15
apps/web/src/views/agent/components/EditModal.vue

@@ -40,14 +40,32 @@
 								<div class="field-tip">简要描述智能体的用途和特点。</div>
 							</el-form-item>
 							<el-form-item label="系统提示词" prop="config.basic_config.system_prompt">
-								<RichEditor v-model="form.config.basic_config.system_prompt" mode="prompt" :rows="8"
-									placeholder="请输入系统提示词,可直接编写角色、目标、约束和回答风格" />
+								<div class="prompt-field">
+									<div class="prompt-variable-row">
+										<span class="prompt-variable-label">支持变量</span>
+										<el-tag v-for="variable in systemPromptVariables" :key="variable" class="prompt-variable-tag"
+											type="info" effect="plain" @click="insertPromptVariable('systemPrompt', variable)">
+											{{ formatPromptVariable(variable) }}
+										</el-tag>
+									</div>
+									<el-input ref="systemPromptInputRef" v-model="form.config.basic_config.system_prompt" mode="prompt"
+										type="textarea" :rows="8" placeholder="请输入系统提示词,可直接编写角色、目标、约束和回答风格" />
+								</div>
 								<div class="field-tip">自定义系统提示词,定义智能体的行为和角色。</div>
 							</el-form-item>
 							<el-form-item v-if="form.mode === 'quick-answer'" label="上下文模板"
 								prop="config.basic_config.context_template">
-								<RichEditor v-model="form.config.basic_config.context_template" mode="prompt" :rows="5"
-									placeholder="请输入上下文模板,用于约束问答模式下的上下文组织方式" />
+								<div class="prompt-field">
+									<div class="prompt-variable-row">
+										<span class="prompt-variable-label">支持变量</span>
+										<el-tag v-for="variable in quickAnswerVariables" :key="variable" class="prompt-variable-tag"
+											type="info" effect="plain" @click="insertPromptVariable('contextTemplate', variable)">
+											{{ formatPromptVariable(variable) }}
+										</el-tag>
+									</div>
+									<el-input ref="contextTemplateInputRef" v-model="form.config.basic_config.context_template"
+										mode="prompt" type="textarea" :rows="5" placeholder="请输入上下文模板,用于约束问答模式下的上下文组织方式" />
+								</div>
 								<div class="field-tip">定义如何将检索到的内容格式化后传递给模型。</div>
 							</el-form-item>
 						</div>
@@ -405,14 +423,33 @@
 								</el-form-item>
 								<el-form-item v-if="form.config.advanced_config.enable_rewrite" label="改写系统提示词"
 									prop="config.advanced_config.rewrite_prompt_system">
-									<el-input v-model="form.config.advanced_config.rewrite_prompt_system" type="textarea" :rows="3"
-										placeholder="请输入问题改写时的系统提示词" />
+									<div class="prompt-field">
+										<div class="prompt-variable-row">
+											<span class="prompt-variable-label">可用变量</span>
+											<el-tag v-for="variable in rewriteVariables" :key="variable" class="prompt-variable-tag"
+												type="info" effect="plain" @click="insertPromptVariable('rewritePromptSystem', variable)">
+												{{ formatPromptVariable(variable) }}
+											</el-tag>
+										</div>
+										<el-input ref="rewritePromptSystemInputRef"
+											v-model="form.config.advanced_config.rewrite_prompt_system" type="textarea" :rows="3"
+											placeholder="请输入问题改写时的系统提示词" />
+									</div>
 									<div class="field-tip">用于问题改写的系统提示词。</div>
 								</el-form-item>
 								<el-form-item v-if="form.config.advanced_config.enable_rewrite" label="改写用户提示词"
 									prop="config.advanced_config.rewrite_prompt_user">
-									<el-input v-model="form.config.advanced_config.rewrite_prompt_user" type="textarea" :rows="3"
-										placeholder="请输入问题改写时的用户提示词" />
+									<div class="prompt-field">
+										<div class="prompt-variable-row">
+											<span class="prompt-variable-label">可用变量</span>
+											<el-tag v-for="variable in rewriteVariables" :key="variable" class="prompt-variable-tag"
+												type="info" effect="plain" @click="insertPromptVariable('rewritePromptUser', variable)">
+												{{ formatPromptVariable(variable) }}
+											</el-tag>
+										</div>
+										<el-input ref="rewritePromptUserInputRef" v-model="form.config.advanced_config.rewrite_prompt_user"
+											type="textarea" :rows="3" placeholder="请输入问题改写时的用户提示词" />
+									</div>
 									<div class="field-tip">用于问题改写的用户提示词模板。</div>
 								</el-form-item>
 							</div>
@@ -442,7 +479,6 @@
 import { computed, nextTick, reactive, ref, watch } from 'vue'
 import { ElMessage } from 'element-plus'
 import { agentApplication, aiModel, knowledge, resource } from '@repo/api-service'
-import RichEditor from '@/components/RichEditor/index.vue'
 import EmojiPicker from 'vue3-emoji-picker'
 import 'vue3-emoji-picker/css'
 import type {
@@ -493,6 +529,10 @@ const skillOptions = ref<AgentSelectOption[]>([])
 const skillLoading = ref(false)
 const allSkillOptions = ref<AgentSelectOption[]>([])
 const emojiDialogVisible = ref(false)
+const systemPromptInputRef = ref()
+const contextTemplateInputRef = ref()
+const rewritePromptSystemInputRef = ref()
+const rewritePromptUserInputRef = ref()
 const supportedFileTypes = ['pdf', 'docx', 'txt', 'md', 'csv', 'xlsx', 'jpg']
 const selectionModeOptions = [
 	{ label: '全部知识库', value: 'all' },
@@ -508,6 +548,14 @@ const fallbackStrategyOptions = [
 	{ label: '模型生成', value: 'model' },
 	{ label: '固定回复', value: 'fixed' }
 ]
+const smartReasoningSystemPromptVariables = [
+	'knowledge_bases',
+	'web_search_status',
+	'current_time',
+	'language'
+]
+const quickAnswerVariables = ['query', 'contexts', 'current_time', 'current_week', 'language']
+const rewriteVariables = ['query', 'conversation', 'current_time', 'yesterday', 'language']
 
 const form = reactive<AgentFormData>({
 	name: '',
@@ -637,6 +685,9 @@ const skillCheckboxOptions = computed(() =>
 		form.config.setting_config.selected_skills
 	)
 )
+const systemPromptVariables = computed(() =>
+	form.mode === 'quick-answer' ? quickAnswerVariables : smartReasoningSystemPromptVariables
+)
 
 const fieldTabMap: Array<{ match: string | RegExp; tab: string }> = [
 	{ match: 'mode', tab: 'basic' },
@@ -701,6 +752,70 @@ function clearEmoji() {
 	form.avatar = ''
 }
 
+function formatPromptVariable(variable: string) {
+	return `{{${variable}}}`
+}
+
+async function insertPromptVariable(
+	field: 'systemPrompt' | 'contextTemplate' | 'rewritePromptSystem' | 'rewritePromptUser',
+	variable: string
+) {
+	const variableText = formatPromptVariable(variable)
+	const fieldMap = {
+		systemPrompt: {
+			get value() {
+				return form.config.basic_config.system_prompt
+			},
+			set value(value: string) {
+				form.config.basic_config.system_prompt = value
+			},
+			ref: systemPromptInputRef
+		},
+		contextTemplate: {
+			get value() {
+				return form.config.basic_config.context_template
+			},
+			set value(value: string) {
+				form.config.basic_config.context_template = value
+			},
+			ref: contextTemplateInputRef
+		},
+		rewritePromptSystem: {
+			get value() {
+				return form.config.advanced_config.rewrite_prompt_system
+			},
+			set value(value: string) {
+				form.config.advanced_config.rewrite_prompt_system = value
+			},
+			ref: rewritePromptSystemInputRef
+		},
+		rewritePromptUser: {
+			get value() {
+				return form.config.advanced_config.rewrite_prompt_user
+			},
+			set value(value: string) {
+				form.config.advanced_config.rewrite_prompt_user = value
+			},
+			ref: rewritePromptUserInputRef
+		}
+	}[field]
+	const textarea = fieldMap.ref.value?.textarea as HTMLTextAreaElement | undefined
+	if (!textarea || typeof textarea.selectionStart !== 'number') {
+		fieldMap.value = `${fieldMap.value || ''}${variableText}`
+		await nextTick()
+		fieldMap.ref.value?.focus?.()
+		return
+	}
+	const currentValue = fieldMap.value || ''
+	const start = textarea.selectionStart
+	const end = textarea.selectionEnd
+	fieldMap.value = `${currentValue.slice(0, start)}${variableText}${currentValue.slice(end)}`
+	await nextTick()
+	fieldMap.ref.value?.focus?.()
+	const cursorPosition = start + variableText.length
+	textarea.setSelectionRange(cursorPosition, cursorPosition)
+}
+
 function mergeOptions(
 	current: AgentSelectOption[],
 	items: AgentSelectOption[],
@@ -1006,6 +1121,8 @@ function applyDetail(item: AgentItem) {
 	form.config.advanced_config.fallback_response =
 		item.config?.advanced_config?.fallback_response || ''
 	form.config.advanced_config.fallback_prompt = item.config?.advanced_config?.fallback_prompt || ''
+
+	console.log('applyDetail', item, form)
 }
 
 async function loadKnowledgeBases() {
@@ -1056,9 +1173,9 @@ async function searchWebSearchProviders(keyword: string) {
 		if (res?.isSuccess) {
 			webSearchProviderOptions.value = mergeOptions(
 				webSearchProviderOptions.value,
-				(res.result?.model || []).map((item) => ({
-					label: item.name,
-					value: item.provider
+				(res.result?.model || []).map((provider) => ({
+					label: provider,
+					value: provider
 				})),
 				form.config.web_search_config.web_search_provider_id
 					? [form.config.web_search_config.web_search_provider_id]
@@ -1087,10 +1204,10 @@ async function searchMcpServices(keyword: string) {
 			mcpServiceOptions.value = mergeOptions(
 				mcpServiceOptions.value,
 				((res.result?.model || []) as McpItem[])
-					.filter((item) => item.id)
+					.filter((item): item is McpItem & { id: string } => !!item.id)
 					.map((item) => ({
-						label: item.name,
-						value: item.id!
+						label: item.name || item.id,
+						value: item.id
 					})),
 				form.config.setting_config.mcp_services
 			)
@@ -1579,6 +1696,35 @@ watch(
 		color: var(--agent-text-soft);
 	}
 
+	.prompt-field {
+		display: flex;
+		flex-direction: column;
+		gap: 10px;
+		width: 100%;
+	}
+
+	.prompt-variable-row {
+		display: flex;
+		flex-wrap: wrap;
+		align-items: center;
+		gap: 8px;
+		padding: 12px 14px;
+		border: 1px solid var(--agent-border);
+		border-radius: 12px;
+		background: rgba(247, 248, 252, 0.8);
+	}
+
+	.prompt-variable-label {
+		font-size: 12px;
+		font-weight: 600;
+		color: #64748b;
+	}
+
+	.prompt-variable-tag {
+		cursor: pointer;
+		user-select: none;
+	}
+
 	.collapse-body {
 		padding: 20px;
 	}

+ 111 - 1
apps/web/src/views/chat/api/chat.api.ts

@@ -1,5 +1,6 @@
 // src/views/chat/api/chat.api.ts
-import { aiChat } from '@repo/api-service' // 假设这是正确的导入路径
+import { agentApplication, aiChat, aiModel, knowledge } from '@repo/api-service'
+import type { ChatTargetConfig, ChatTargetType } from '../types'
 
 export interface ChatRequestParams {
 	sessionId: string
@@ -16,6 +17,58 @@ export interface ChatRequestParams {
 	webSearchEnabled?: boolean
 }
 
+export interface ChatOptionItem {
+	label: string
+	value: string
+	description?: string
+	type?: string
+	raw?: any
+}
+
+export function getChatUrl(type: ChatTargetType) {
+	if (type === 'knowledge') return '/api/ai/chat/knowledge-chat'
+	if (type === 'model') return '/api/ai/chat/model-chat'
+	return '/api/ai/chat/agent-chat'
+}
+
+export function buildChatRequestBody(
+	type: ChatTargetType,
+	config: ChatTargetConfig,
+	sessionId: string,
+	query: string
+) {
+	const base = {
+		session_id: sessionId,
+		query,
+		summary_model_id: config.summaryModelId || '',
+		disable_title: config.disableTitle,
+		enable_memory: config.enableMemory,
+		channel: 'web'
+	}
+
+	if (type === 'knowledge') {
+		return {
+			...base,
+			knowledge_base_ids: config.knowledgeBaseIds,
+			knowledge_ids: config.knowledgeIds
+		}
+	}
+
+	if (type === 'model') {
+		return base
+	}
+
+	return {
+		...base,
+		knowledge_base_ids: config.knowledgeBaseIds,
+		knowledge_ids: config.knowledgeIds,
+		agent_id: config.agentId,
+		images: config.images,
+		agent_enabled: config.agentEnabled,
+		web_search_enabled: config.webSearchEnabled
+	}
+}
+
 /**
  * 发送聊天消息
  */
@@ -59,3 +112,60 @@ export async function getSessionMessages(
 ) {
 	return aiChat.postSessionSessionMessages({ sessionId, pageIndex, pageSize })
 }
+
+export async function getAgentOptions(keyword = ''): Promise<ChatOptionItem[]> {
+	const res = await agentApplication.postAiAgentPageList({
+		keyword,
+		mode: '',
+		type: '',
+		pageIndex: 1,
+		pageSize: 100
+	})
+
+	return (res.result?.model || []).map((item) => ({
+		label: item.name || item.id || '',
+		value: item.id || '',
+		description: item.description,
+		type: item.mode,
+		raw: item
+	}))
+}
+
+export async function getAgentInfo(id: string) {
+	return agentApplication.postAiAgentInfo({ id })
+}
+
+export async function getKnowledgeBaseOptions(keyword = ''): Promise<ChatOptionItem[]> {
+	const res = await knowledge.postKnowledgeBasePageList({
+		keyword,
+		type: '',
+		pageIndex: 1,
+		pageSize: 100
+	})
+
+	return (res.result?.model || []).map((item) => ({
+		label: item.name || item.id || '',
+		value: item.id || '',
+		description: item.description,
+		type: item.type,
+		raw: item
+	}))
+}
+
+export async function getModelOptions(keyword = ''): Promise<ChatOptionItem[]> {
+	const res = await aiModel.postModelPageList({
+		keyword,
+		type: '',
+		source: '',
+		pageIndex: 1,
+		pageSize: 100
+	})
+
+	return (res.result?.model || []).map((item) => ({
+		label: item.title ? `${item.title} (${item.name})` : item.name || item.id || '',
+		value: item.id || '',
+		description: item.description,
+		type: item.type,
+		raw: item
+	}))
+}

+ 28 - 2
apps/web/src/views/chat/components/ChatHeader.vue

@@ -1,6 +1,11 @@
 <template>
 	<div class="chat-header">
-		<div class="chat-title">{{ title || t('pages.chat.newConversation') }}</div>
+		<div class="chat-header__left">
+			<div class="chat-title">{{ title || t('pages.chat.newConversation') }}</div>
+		</div>
+		<div class="chat-header__right">
+			<slot name="actions" />
+		</div>
 	</div>
 </template>
 
@@ -15,15 +20,36 @@ defineProps<{
 
 <style lang="less" scoped>
 .chat-header {
+	display: flex;
+	align-items: center;
+	justify-content: space-between;
+	gap: 12px;
 	padding: 0px 24px;
 	height: 64px;
 	border-bottom: 1px solid var(--border-light);
 	background: var(--bg-base);
+	box-sizing: border-box;
+
+	.chat-header__left {
+		min-width: 0;
+		flex: 1;
+	}
+
+	.chat-header__right {
+		display: flex;
+		align-items: center;
+		gap: 8px;
+		flex-shrink: 0;
+	}
+
 	.chat-title {
 		font-size: 18px;
 		font-weight: 600;
 		color: var(--text-primary);
-		line-height: 64px;
+		line-height: 1.2;
+		white-space: nowrap;
+		overflow: hidden;
+		text-overflow: ellipsis;
 	}
 }
 </style>

+ 2 - 4
apps/web/src/views/chat/components/ChatInput.vue

@@ -1,6 +1,6 @@
 <template>
 	<div class="sender-wrapper">
-		<Sender
+	<Sender
 			v-model="modelValue"
 			variant="updown"
 			:auto-size="{ minRows: 2, maxRows: 5 }"
@@ -13,9 +13,7 @@
 		>
 			<template #prefix>
 				<div style="display: flex; align-items: center; gap: 8px; flex-wrap: wrap">
-					<el-button round plain color="#626aef">
-						<el-icon><Paperclip /></el-icon>
-					</el-button>
+					<slot name="prefix-extra" />
 
 					<div
 						:class="{ isSelect: isDeep }"

+ 105 - 35
apps/web/src/views/chat/components/MessageList.vue

@@ -1,13 +1,7 @@
 <template>
 	<div class="chat-content">
-		<BubbleList
-			v-if="messages.length > 0"
-			:loading="loading"
-			ref="bubbleListRef"
-			:show-avatar="false"
-			:list="messages"
-			class="item-list"
-		>
+		<BubbleList v-if="messages.length > 0" :loading="loading" ref="bubbleListRef" :show-avatar="false" :list="messages"
+			class="item-list">
 			<!-- 自定义头像 -->
 			<template #avatar="{ item }">
 				<div class="avatar-wrapper">
@@ -25,34 +19,24 @@
 			</template>
 
 			<!-- 自定义气泡内容 -->
-			<!-- <template #content="{ item }">
-				<div class="content-wrapper">
-					<div class="content-text">
-					</div>
+			<template #content="{ item }">
+				<div class="msg-content-wrapper">
+					<div class="msg-content-text" v-html="item.content"></div>
 				</div>
-			</template> -->
+			</template>
 
 			<!-- 自定义底部 -->
 			<template #footer="{ item }">
 				<div class="file-wrap">
-					<el-image
-						v-if="item.message_files"
-						:src="item.message_files ? `/File/GetImage?fileId=${item.message_files}` : undefined"
-						class="file-image"
-					/>
+					<el-image v-if="item.message_files"
+						:src="item.message_files ? `/File/GetImage?fileId=${item.message_files}` : undefined" class="file-image" />
 				</div>
 				<div class="footer-wrapper">
 					<div class="footer-container">
 						<el-button type="info" :icon="Refresh" size="small" circle @click="handleRetry(item)" />
 						<!-- <el-button type="success" :icon="Search" size="small" circle />
 						<el-button type="warning" :icon="Star" size="small" circle /> -->
-						<el-button
-							color="#626aef"
-							:icon="DocumentCopy"
-							size="small"
-							circle
-							@click="handleCopy(item)"
-						/>
+						<el-button color="#626aef" :icon="DocumentCopy" size="small" circle @click="handleCopy(item)" />
 					</div>
 					<div class="footer-time">
 						{{ item.updateTime }}
@@ -94,7 +78,7 @@ import { ref } from 'vue'
 import { BubbleList } from 'vue-element-plus-x'
 import { useI18n } from '@/composables/useI18n'
 import { ElMessage } from 'element-plus'
-import { DocumentCopy, Refresh, Search, Star } from '@element-plus/icons-vue'
+import { DocumentCopy, Refresh } from '@element-plus/icons-vue'
 import type { BubbleMessage } from '../types' // 提取类型
 
 const { t } = useI18n()
@@ -116,10 +100,11 @@ defineExpose({
 	}
 })
 
-const handleRetry = (message: BubbleMessage) => {}
+const handleRetry = (message: BubbleMessage) => { }
 
 const handleCopy = (message: BubbleMessage) => {
-	window.navigator.clipboard.writeText(message.content)
+	const text = `${message.answerText || message.content || ''}`.replace(/<[^>]+>/g, '')
+	window.navigator.clipboard.writeText(text)
 	ElMessage.success('复制成功!')
 }
 </script>
@@ -136,6 +121,7 @@ const handleCopy = (message: BubbleMessage) => {
 		.think {
 			padding-left: 20px;
 			border-left: solid 4px var(--border-light);
+
 			&-title {
 				font-size: 14px;
 				color: var(--text-primary);
@@ -158,6 +144,7 @@ const handleCopy = (message: BubbleMessage) => {
 .avatar-wrapper {
 	width: 40px;
 	height: 40px;
+
 	img {
 		width: 100%;
 		height: 100%;
@@ -172,14 +159,91 @@ const handleCopy = (message: BubbleMessage) => {
 	}
 }
 
-.content-wrapper {
-	.content-text {
+.msg-content-wrapper {
+	.msg-content-text {
 		font-size: 14px;
-		color: #333;
-		padding: 12px;
-		background: linear-gradient(to right, #fdfcfb 0%, #ffd1ab 100%);
-		border-radius: 15px;
-		box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
+		line-height: 1.7;
+		color: var(--text-primary);
+		word-break: break-word;
+
+		:deep(.chat-answer) {
+			white-space: normal;
+		}
+
+		:deep(.chat-section) {
+			display: flex;
+			flex-direction: column;
+			gap: 8px;
+			margin-bottom: 10px;
+		}
+
+		:deep(.chat-section__title) {
+			margin-bottom: 6px;
+			font-size: 12px;
+			font-weight: 700;
+			color: var(--text-secondary);
+		}
+
+		:deep(.chat-block) {
+			padding: 10px 12px;
+			border-radius: 8px;
+			border: 1px solid var(--border-light);
+			background: var(--bg-base);
+		}
+
+		:deep(.chat-block--think) {
+			border-left: 4px solid #626aef;
+			background: rgba(98, 106, 239, 0.06);
+			margin-bottom: 10px;
+		}
+
+		:deep(.chat-block__title) {
+			display: flex;
+			align-items: center;
+			gap: 6px;
+			margin-bottom: 6px;
+			font-size: 13px;
+			font-weight: 700;
+			color: var(--text-primary);
+		}
+
+		:deep(.chat-block__body),
+		:deep(.chat-block__meta) {
+			font-size: 13px;
+			color: var(--text-secondary);
+		}
+
+		:deep(.chat-block__code) {
+			margin: 6px 0 0;
+			padding: 8px;
+			border-radius: 6px;
+			background: var(--bg-container);
+			color: var(--text-primary);
+			white-space: pre-wrap;
+		}
+
+		:deep(.chat-block__error) {
+			color: var(--el-color-danger);
+		}
+
+		:deep(.chat-reference) {
+			padding: 8px 10px;
+			border-radius: 8px;
+			border: 1px solid var(--border-light);
+			background: var(--bg-container);
+		}
+
+		:deep(.chat-reference__title) {
+			font-size: 13px;
+			font-weight: 700;
+		}
+
+		:deep(.chat-reference__desc),
+		:deep(.chat-reference__content) {
+			margin-top: 4px;
+			font-size: 12px;
+			color: var(--text-secondary);
+		}
 	}
 }
 
@@ -187,6 +251,7 @@ const handleCopy = (message: BubbleMessage) => {
 	display: flex;
 	align-items: center;
 	gap: 10px;
+
 	.footer-time {
 		font-size: 12px;
 		margin-top: 3px;
@@ -221,10 +286,12 @@ const handleCopy = (message: BubbleMessage) => {
 }
 
 @keyframes bounce {
+
 	0%,
 	100% {
 		transform: translateY(5px);
 	}
+
 	50% {
 		transform: translateY(-5px);
 	}
@@ -233,14 +300,17 @@ const handleCopy = (message: BubbleMessage) => {
 .loading-container span:nth-child(4n) {
 	animation: bounce 1.2s ease infinite;
 }
+
 .loading-container span:nth-child(4n + 1) {
 	animation: bounce 1.2s ease infinite;
 	animation-delay: 0.3s;
 }
+
 .loading-container span:nth-child(4n + 2) {
 	animation: bounce 1.2s ease infinite;
 	animation-delay: 0.6s;
 }
+
 .loading-container span:nth-child(4n + 3) {
 	animation: bounce 1.2s ease infinite;
 	animation-delay: 0.9s;

Разница между файлами не показана из-за своего большого размера
+ 37 - 39
apps/web/src/views/chat/composables/useChatStream.ts


Разница между файлами не показана из-за своего большого размера
+ 674 - 229
apps/web/src/views/chat/index.vue


+ 70 - 0
apps/web/src/views/chat/types.ts

@@ -14,9 +14,77 @@ export interface BubbleMessage {
 	isFog?: boolean
 	updateTime?: string
 	message_files?: string
+	thinking?: string
+	toolCalls?: ChatToolCall[]
+	toolResults?: ChatToolResult[]
+	references?: ChatReference[]
+	assistantMessageId?: string
 	[key: string]: any
 }
 
+export type ChatTargetType = 'knowledge' | 'agent' | 'model'
+
+export interface ChatTargetConfig {
+	type: ChatTargetType
+	knowledgeBaseIds: string[]
+	knowledgeIds: string[]
+	summaryModelId: string
+	disableTitle: boolean
+	enableMemory: boolean
+	agentId: string
+	agentEnabled: boolean
+	webSearchEnabled: boolean
+	images: string[]
+}
+
+export interface ChatToolCall {
+	id?: string
+	toolCallId?: string
+	toolName?: string
+	arguments?: Record<string, any>
+	content?: string
+}
+
+export interface ChatToolResult {
+	id?: string
+	toolCallId?: string
+	toolName?: string
+	success?: boolean
+	error?: string
+	output?: string
+	thought?: string
+	durationMs?: number
+	displayType?: string
+	contentItems?: any[]
+}
+
+export interface ChatReference {
+	id?: string
+	knowledgeBaseId?: string
+	knowledgeId?: string
+	knowledgeTitle?: string
+	knowledgeFilename?: string
+	knowledgeDescription?: string
+	content?: string
+	matchedContent?: string
+	score?: number
+	chunkType?: string
+}
+
+export interface ChatSseMessage {
+	id?: string
+	response_type?: string
+	content?: string
+	done?: boolean
+	data?: Record<string, any>
+}
+
+export interface ChatSessionRuntimeState {
+	targetType: ChatTargetType
+	config: ChatTargetConfig
+	allowImageUpload: boolean
+}
+
 /**
  * 会话数据
  */
@@ -25,4 +93,6 @@ export interface Conversation {
 	title: string
 	updatedAt: number
 	sessionId: string
+	targetType?: ChatTargetType
+	targetConfig?: Partial<ChatTargetConfig>
 }

+ 10 - 12
apps/web/src/views/knowledge/WikiManage.vue

@@ -74,7 +74,8 @@
 								<span>{{ item.version ? `v${item.version}` : '-' }}</span>
 							</div>
 						</div>
-						<el-empty v-if="!pageListLoading && !pageList.length" description="暂无 Wiki 页面" class="page-empty page-empty--compact" />
+						<el-empty v-if="!pageListLoading && !pageList.length" description="暂无 Wiki 页面"
+							class="page-empty page-empty--compact" />
 					</div>
 
 					<div class="wiki-sidebar__footer">
@@ -90,7 +91,7 @@
 					<div class="graph-card__header">
 						<div>
 							<div class="graph-card__title">知识图谱</div>
-							<div class="graph-card__desc">基于 Wiki 页面与关系边构建的图谱视图,可拖拽、缩放并联动当前页面。</div>
+							<div class="graph-card__desc">基于 Wiki 页面与关系边构建的图谱视图,可拖拽、缩放。</div>
 							<div class="graph-card__summary">
 								<div v-for="item in graphSummary" :key="item.label" class="graph-stat">
 									<span class="graph-stat__label">{{ item.label }}</span>
@@ -761,17 +762,14 @@ function renderGraph() {
 
 async function loadAll() {
 	if (!props.currentBase.id) return
-	try {
-		const tasks = [loadStats()]
-		if (isGraphMode.value) {
-			tasks.push(loadGraph())
-		} else {
-			tasks.push(loadPages(), loadLogPage())
-		}
-		await Promise.all(tasks)
-	} catch {
-		ElMessage.error('Wiki 数据加载失败')
+
+	const tasks = [loadStats()]
+	if (isGraphMode.value) {
+		tasks.push(loadGraph())
+	} else {
+		tasks.push(loadPages(), loadLogPage())
 	}
+	await Promise.all(tasks)
 }
 
 watch(

+ 4 - 4
apps/web/src/views/knowledge/index.vue

@@ -11,16 +11,16 @@
 			<div v-if="currentBase" class="content-container">
 				<el-tabs v-model="currentModule" type="border-card">
 					<el-tab-pane label="知识" name="document" v-if="currentBase.type === 'document'">
-						<DocumentManage :current-base="currentBase" />
+						<DocumentManage v-if="currentModule === 'document'" :current-base="currentBase" />
 					</el-tab-pane>
 					<el-tab-pane label="Wiki" name="wiki" v-if="currentBase.type === 'document'">
-						<WikiManage :current-base="currentBase" mode="wiki" />
+						<WikiManage v-if="currentModule === 'wiki'" :current-base="currentBase" mode="wiki" />
 					</el-tab-pane>
 					<el-tab-pane label="知识图谱" name="graph" v-if="currentBase.type === 'document'">
-						<WikiManage v-if="currentBase.type === 'document'" :current-base="currentBase" mode="graph" />
+						<WikiManage v-if="currentModule === 'graph'" :current-base="currentBase" mode="graph" />
 					</el-tab-pane>
 					<el-tab-pane label="问答" name="qa" v-if="currentBase.type === 'faq'">
-						<QaManage :current-base-id="currentBase?.id!" />
+						<QaManage v-if="currentModule === 'qa'" :current-base-id="currentBase?.id!" />
 					</el-tab-pane>
 				</el-tabs>
 			</div>

+ 15 - 37
apps/web/src/views/model/ModelManage.vue

@@ -91,40 +91,6 @@
 			</div>
 		</el-card>
 
-		<!-- <el-card class="list-card">
-			<template #header>
-				<div class="card-header">
-					<span>更多模型</span>
-				</div>
-			</template>
-			<div v-if="providers.length" class="provider-grid">
-				<div
-					v-for="(p, idx) in providers"
-					:key="p.value"
-					class="provider-card"
-					:class="`provider-card--tone-${(idx % 5) + 1}`"
-				>
-					<div class="provider-card__top">
-						<div class="provider-card__label">{{ p.label }}</div>
-						<el-button
-							class="provider-card__action"
-							type="primary"
-							@click="openCreateModelByProvider(p)"
-						>
-							添加模型
-						</el-button>
-					</div>
-					<div class="provider-card__desc">{{ p.description || '-' }}</div>
-					<div class="provider-card__tags">
-						<el-tag v-for="t in getProviderModelTypes(p)" :key="`${p.value}-${t}`" round>
-							{{ getModelTypeLabel(t) }}
-						</el-tag>
-					</div>
-				</div>
-			</div>
-			<el-empty v-else description="暂无服务商数据" />
-		</el-card> -->
-
 		<el-dialog v-model="showDetailDialog" title="模型详情" width="700px">
 			<el-descriptions :column="1" border v-if="currentDetailModel">
 				<el-descriptions-item label="模型标识">{{ currentDetailModel.name }}</el-descriptions-item>
@@ -281,7 +247,7 @@
 </template>
 
 <script setup lang="ts">
-import { computed, ref, reactive, onMounted } from 'vue'
+import { computed, ref, reactive, onMounted, watch } from 'vue'
 import { ElMessage, ElMessageBox } from 'element-plus'
 import { Plus, Refresh, MoreFilled } from '@element-plus/icons-vue'
 import { aiModel, ollama } from '@repo/api-service'
@@ -479,8 +445,9 @@ async function openDetailModel(id: string) {
 	}
 }
 
+/**根据模型类型获取模型提供者 */
 async function getProviders(type?: modelType) {
-	const res = await aiModel.postModelProviders({ model_type: type } as any)
+	const res = await aiModel.postModelProviders({ model_type: type! })
 	if (res.isSuccess) providers.value = (res.result || []) as ModelProvider[]
 }
 
@@ -644,7 +611,7 @@ async function handleTypeChange() {
 	modelForm.supports_vision = false
 	modelForm.dimension = undefined
 	modelForm.truncate_prompt_tokens = undefined
-	getProviders(modelForm.type)
+	// getProviders(modelForm.type)
 }
 
 function handleProviderChange() {
@@ -808,6 +775,16 @@ async function deleteModelConfirm(id: string) {
 	})
 }
 
+watch(
+	() => modelForm.type,
+	() => {
+		if (modelForm.type) {
+			getProviders(modelForm.type)
+		}
+	},
+	{ immediate: true }
+)
+
 onMounted(() => {
 	// getProviders()
 	loadLocalModels()
@@ -1057,6 +1034,7 @@ onMounted(() => {
 	display: flex;
 	align-items: center;
 	justify-content: center;
+	grid-column: span 4;
 }
 
 .card-header {

+ 28 - 6
apps/web/src/views/model/OllamaManage.vue

@@ -21,7 +21,9 @@
 				<el-card class="stat-card" shadow="hover">
 					<div class="stat-content">
 						<div class="stat-icon default">
-							<el-icon :size="24"><Document /></el-icon>
+							<el-icon :size="24">
+								<Document />
+							</el-icon>
 						</div>
 						<div class="stat-info">
 							<div class="stat-label">已下载模型</div>
@@ -34,7 +36,9 @@
 				<el-card class="stat-card" shadow="hover">
 					<div class="stat-content">
 						<div class="stat-icon default">
-							<el-icon :size="24"><List /></el-icon>
+							<el-icon :size="24">
+								<List />
+							</el-icon>
 						</div>
 						<div class="stat-info">
 							<div class="stat-label">进行中任务</div>
@@ -47,10 +51,14 @@
 
 		<div class="action-bar">
 			<el-button type="primary" @click="showDownloadDialog = true">
-				<el-icon><Plus /></el-icon> 下载新模型
+				<el-icon>
+					<Plus />
+				</el-icon> 下载新模型
 			</el-button>
 			<el-button @click="handleRefresh()">
-				<el-icon><Refresh /></el-icon> 刷新
+				<el-icon>
+					<Refresh />
+				</el-icon> 刷新
 			</el-button>
 		</div>
 
@@ -59,7 +67,7 @@
 				<el-table-column prop="name" label="模型名称" />
 				<el-table-column prop="size" label="大小" :formatter="formatSize" />
 				<el-table-column prop="digest" label="Digest" />
-				<el-table-column prop="modified_at" label="更新时间" :formatter="formatTime" />
+				<el-table-column prop="modified_at" label="更新时间" />
 				<el-table-column label="操作" width="180" fixed="right">
 					<template #default="{ row }">
 						<el-button type="primary" plain link @click="emitOpenImport(row.name)">导入到模型列表</el-button>
@@ -81,7 +89,8 @@
 				</el-table-column>
 				<el-table-column prop="progress" label="进度" width="200">
 					<template #default="{ row }">
-						<el-progress :percentage="getPercentage(row.progress)" :stroke-width="8" :show-text="row.status === 'downloading'" />
+						<el-progress :percentage="getPercentage(row.progress)" :stroke-width="8"
+							:show-text="row.status === 'downloading'" />
 					</template>
 				</el-table-column>
 				<el-table-column prop="startTime" label="开始时间" :formatter="formatTime" />
@@ -277,14 +286,17 @@ onUnmounted(() => {
 .ollama-manage {
 	width: 100%;
 }
+
 .stats-row {
 	margin-bottom: 16px;
 }
+
 .stat-card {
 	.stat-content {
 		display: flex;
 		align-items: center;
 		gap: 12px;
+
 		.stat-icon {
 			width: 48px;
 			height: 48px;
@@ -292,26 +304,32 @@ onUnmounted(() => {
 			display: flex;
 			align-items: center;
 			justify-content: center;
+
 			&.online {
 				background: #f0f9eb;
 				color: #67c23a;
 			}
+
 			&.offline {
 				background: #fef0f0;
 				color: #f56c6c;
 			}
+
 			&.default {
 				background: #ecf5ff;
 				color: #409eff;
 			}
 		}
+
 		.stat-info {
 			flex: 1;
+
 			.stat-label {
 				font-size: 14px;
 				color: #909399;
 				margin-bottom: 4px;
 			}
+
 			.stat-value {
 				font-size: 18px;
 				font-weight: 600;
@@ -320,20 +338,24 @@ onUnmounted(() => {
 		}
 	}
 }
+
 .action-bar {
 	margin-bottom: 16px;
 	display: flex;
 	gap: 12px;
 }
+
 .list-card {
 	border-radius: 8px;
 }
+
 .task-title {
 	margin: 16px 0 8px;
 	font-size: 16px;
 	font-weight: 600;
 	color: #303133;
 }
+
 .mt-4 {
 	margin-top: 16px;
 }

+ 5 - 1
apps/web/src/views/resource/components/McpPanel.vue

@@ -50,7 +50,7 @@
 		</div> -->
 
 		<div v-loading="loading" class="grid">
-			<el-empty v-if="!list.length && !loading" description="暂无 MCP 服务" />
+			<el-empty class="empty" v-if="!list.length && !loading" description="暂无 MCP 服务" />
 			<div v-for="row in list" :key="row.id" class="card">
 				<div class="card-head">
 					<div class="card-head__top">
@@ -628,6 +628,10 @@ onMounted(() => {
 	grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
 	gap: 16px;
 	min-height: 240px;
+
+	.empty {
+		grid-column: span 4;
+	}
 }
 
 .card {

+ 37 - 37
apps/web/src/views/resource/components/PromptTemplatePanel.vue

@@ -2,31 +2,24 @@
 	<div class="panel">
 		<div class="toolbar">
 			<div class="toolbar-left">
-				<el-input
-					v-model="keyword"
-					clearable
-					placeholder="搜索提示词名称 / 描述"
-					class="search-input"
-					@keyup.enter="loadList(1)"
-				>
+				<el-input v-model="keyword" clearable placeholder="搜索提示词名称 / 描述" class="search-input"
+					@keyup.enter="loadList(1)">
 					<template #prefix>
-						<el-icon><Search /></el-icon>
+						<el-icon>
+							<Search />
+						</el-icon>
 					</template>
 				</el-input>
-				<el-select
-					v-model="type"
-					clearable
-					placeholder="类型"
-					style="width: 160px"
-					:options="typeList"
-					@change="loadList(1)"
-				></el-select>
+				<el-select v-model="type" clearable placeholder="类型" style="width: 160px" :options="typeList"
+					@change="loadList(1)"></el-select>
 				<el-button @click="loadList(1)">查询</el-button>
 				<el-button @click="handleReset">重置</el-button>
 			</div>
 			<div class="toolbar-right">
 				<el-button type="primary" @click="openCreate">
-					<el-icon><Plus /></el-icon>
+					<el-icon>
+						<Plus />
+					</el-icon>
 					新建提示词
 				</el-button>
 			</div>
@@ -38,15 +31,16 @@
 				<div class="resource-card__top">
 					<div>
 						<div class="resource-card__title flex items-center gap-4px">
-							<el-tag v-if="item.is_builtin" type="success" effect="light">内置</el-tag
-							>{{ item.name }}
+							<el-tag v-if="item.is_builtin" type="success" effect="light">内置</el-tag>{{ item.name }}
 						</div>
 
 						<div class="resource-card__desc">{{ item.description || '暂无描述' }}</div>
 					</div>
 					<el-dropdown :hide-on-click="false">
 						<span class="cursor-pointer">
-							<el-icon><MoreFilled /></el-icon>
+							<el-icon>
+								<MoreFilled />
+							</el-icon>
 						</span>
 						<template #dropdown>
 							<el-dropdown-menu>
@@ -71,24 +65,12 @@
 		</div>
 
 		<div class="pagination">
-			<el-pagination
-				v-model:current-page="pagination.pageIndex"
-				v-model:page-size="pagination.pageSize"
-				background
-				layout="total, sizes, prev, pager, next, jumper"
-				:page-sizes="[10, 20, 50, 100]"
-				:total="pagination.totalCount"
-				@current-change="handlePageChange"
-				@size-change="handleSizeChange"
-			/>
+			<el-pagination v-model:current-page="pagination.pageIndex" v-model:page-size="pagination.pageSize" background
+				layout="total, sizes, prev, pager, next, jumper" :page-sizes="[10, 20, 50, 100]" :total="pagination.totalCount"
+				@current-change="handlePageChange" @size-change="handleSizeChange" />
 		</div>
 
-		<el-drawer
-			v-model="drawerVisible"
-			:title="currentId ? '编辑提示词' : '新建提示词'"
-			direction="rtl"
-			size="760px"
-		>
+		<el-drawer v-model="drawerVisible" :title="currentId ? '编辑提示词' : '新建提示词'" direction="rtl" size="760px">
 			<el-form ref="formRef" :model="form" :rules="rules" label-position="top">
 				<el-form-item label="名称" prop="name">
 					<el-input v-model="form.name" />
@@ -347,6 +329,7 @@ onMounted(() => {
 	flex-direction: column;
 	gap: 16px;
 }
+
 .toolbar {
 	display: flex;
 	align-items: center;
@@ -354,6 +337,7 @@ onMounted(() => {
 	gap: 12px;
 	flex-wrap: wrap;
 }
+
 .toolbar-left,
 .toolbar-right {
 	display: flex;
@@ -361,15 +345,18 @@ onMounted(() => {
 	gap: 10px;
 	flex-wrap: wrap;
 }
+
 .search-input {
 	width: 280px;
 }
+
 .card-grid {
 	display: grid;
 	grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
 	gap: 16px;
 	min-height: 200px;
 }
+
 .resource-card {
 	padding: 16px;
 	border-radius: 18px;
@@ -377,22 +364,26 @@ onMounted(() => {
 	background: #fff;
 	box-shadow: 0 12px 24px rgba(15, 23, 42, 0.05);
 }
+
 .resource-card__top {
 	display: flex;
 	align-items: flex-start;
 	justify-content: space-between;
 	gap: 10px;
 }
+
 .resource-card__title {
 	font-size: 18px;
 	font-weight: 700;
 	color: #111827;
 }
+
 .resource-card__desc {
 	margin-top: 6px;
 	color: #6b7280;
 	font-size: 13px;
 }
+
 .resource-card__meta {
 	display: flex;
 	flex-wrap: wrap;
@@ -401,6 +392,7 @@ onMounted(() => {
 	font-size: 12px;
 	color: #6b7280;
 }
+
 .resource-card__content {
 	margin-top: 12px;
 	padding: 12px;
@@ -411,35 +403,43 @@ onMounted(() => {
 	line-height: 1.6;
 	white-space: pre-wrap;
 }
+
 .resource-card__actions {
 	display: flex;
 	justify-content: flex-end;
 	gap: 8px;
 	margin-top: 12px;
 }
+
 .pagination {
 	display: flex;
 	justify-content: flex-end;
 }
+
 .grid-2 {
 	display: grid;
 	grid-template-columns: repeat(2, minmax(0, 1fr));
 	gap: 12px;
 }
+
 .drawer-footer {
 	display: flex;
 	justify-content: flex-end;
 	gap: 10px;
 }
+
 .empty {
-	grid-column: 1 / -1;
 	padding: 24px 0;
+	grid-column: span 4;
 }
+
 @media (max-width: 768px) {
+
 	.toolbar,
 	.grid-2 {
 		grid-template-columns: 1fr;
 	}
+
 	.search-input {
 		width: 100%;
 	}

+ 12 - 7
apps/web/src/views/resource/components/StorageManager.vue

@@ -7,7 +7,7 @@
 			<el-button :loading="engineLoading" @click="loadEngines">刷新列表</el-button>
 		</div>
 
-		<div v-if="engines.length" class="grid">
+		<div class="grid">
 			<div v-for="item in engines" :key="item.name" class="card">
 				<div class="card-head">
 					<div class="card-head__top">
@@ -18,14 +18,14 @@
 						<div class="actions">
 							<el-dropdown>
 								<span class="actions-trigger">
-									<el-icon><MoreFilled /></el-icon>
+									<el-icon>
+										<MoreFilled />
+									</el-icon>
 								</span>
 								<template #dropdown>
 									<el-dropdown-menu>
-										<el-dropdown-item
-											:disabled="currentDefaultProvider === item.name"
-											@click="updateDefaultProvider(item.name)"
-										>
+										<el-dropdown-item :disabled="currentDefaultProvider === item.name"
+											@click="updateDefaultProvider(item.name)">
 											{{ currentDefaultProvider === item.name ? '当前默认' : '设为默认' }}
 										</el-dropdown-item>
 										<el-dropdown-item @click="openEditProvider(item.name)">编辑</el-dropdown-item>
@@ -48,8 +48,9 @@
 					</el-tag>
 				</div>
 			</div>
+			<el-empty v-if="!engines.length" class="empty" description="暂无引擎" />
 		</div>
-		<el-empty v-else description="暂无引擎" />
+
 
 		<el-drawer v-model="drawerVisible"
 			:title="selectedProvider ? `${formatProviderLabel(selectedProvider)} 配置` : '编辑存储引擎'" direction="rtl" size="760px">
@@ -652,6 +653,10 @@ onMounted(async () => {
 	grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
 	gap: 16px;
 	min-height: 240px;
+
+	.empty {
+		grid-column: span 4;
+	}
 }
 
 .card {

+ 5 - 1
apps/web/src/views/resource/components/WebSearchPanel.vue

@@ -26,7 +26,7 @@
 		</div>
 
 		<div v-loading="loading" class="grid">
-			<el-empty v-if="!list.length && !loading" description="暂无网络搜索配置" />
+			<el-empty v-if="!list.length && !loading" class="empty" description="暂无网络搜索配置" />
 			<div v-for="row in list" :key="row.id" class="card">
 				<div class="card-head">
 					<div class="flex items-center justify-between">
@@ -393,6 +393,10 @@ onMounted(async () => {
 	grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
 	gap: 16px;
 	min-height: 200px;
+
+	.empty {
+		grid-column: span 4;
+	}
 }
 
 .actions {

+ 23 - 0
packages/api-service/schema/model.openapi.json

@@ -40,6 +40,29 @@
 						}
 					}
 				],
+				"requestBody": {
+					"content": {
+						"application/json": {
+							"schema": {
+								"type": "object",
+								"properties": {
+									"model_type": {
+										"type": "string"
+									}
+								},
+								"required": ["model_type"]
+							},
+							"example": {
+								"keyword": "",
+								"type": "",
+								"source": "",
+								"pageIndex": 1,
+								"pageSize": 20
+							}
+						}
+					},
+					"required": true
+				},
 				"responses": {
 					"200": {
 						"description": "",

+ 10 - 1
packages/api-service/servers/model/api/aiModel.ts

@@ -173,7 +173,12 @@ export async function postModelPageList(
 }
 
 /** 根据模型类型获取支持的服务商列表及配置信息 POST /api/ai/model/providers */
-export async function postModelProviders(options?: { [key: string]: any }) {
+export async function postModelProviders(
+  body: {
+    model_type: string
+  },
+  options?: { [key: string]: any }
+) {
   return request<{
     isSuccess: boolean
     code: number
@@ -187,6 +192,10 @@ export async function postModelProviders(options?: { [key: string]: any }) {
     isAuthorized: boolean
   }>('/api/ai/model/providers', {
     method: 'POST',
+    headers: {
+      'Content-Type': 'application/json'
+    },
+    data: body,
     ...(options || {})
   })
 }