From 1313f4dfbb174f4e522436599b345fdd04eb6bf4 Mon Sep 17 00:00:00 2001 From: wizardchen Date: Sun, 9 Nov 2025 23:17:39 +0800 Subject: [PATCH] feat: Add system prompt template support and placeholder functionality - Introduced a new PlaceholderDefinition interface to manage available placeholders for system prompts. - Enhanced AgentSettings.vue to allow users to configure system prompts with dynamic placeholders, improving user experience. - Updated the AgentConfig structure to include a system prompt field, enabling custom prompt templates. - Modified backend components to support the new system prompt template, ensuring compatibility with existing configurations. - Implemented logic for rendering placeholders in the system prompt, allowing for dynamic content generation based on user input. --- frontend/src/api/system/index.ts | 8 + frontend/src/views/settings/AgentSettings.vue | 597 +++++++++++++++++- internal/agent/engine.go | 23 +- internal/agent/prompts.go | 139 ++-- internal/application/service/agent_service.go | 4 + internal/handler/tenant.go | 48 +- internal/types/agent.go | 17 +- 7 files changed, 736 insertions(+), 100 deletions(-) diff --git a/frontend/src/api/system/index.ts b/frontend/src/api/system/index.ts index 72d58b1aa..1a2611867 100644 --- a/frontend/src/api/system/index.ts +++ b/frontend/src/api/system/index.ts @@ -13,6 +13,12 @@ export interface ToolDefinition { description: string } +export interface PlaceholderDefinition { + name: string + label: string + description: string +} + export interface AgentConfig { enabled: boolean max_iterations: number @@ -22,7 +28,9 @@ export interface AgentConfig { thinking_model_id: string rerank_model_id: string knowledge_bases?: string[] + system_prompt?: string // System prompt template with placeholders (optional) available_tools?: ToolDefinition[] // GET 响应中包含,POST/PUT 不需要 + available_placeholders?: PlaceholderDefinition[] // GET 响应中包含,POST/PUT 不需要 } export function getSystemInfo(): Promise<{ data: SystemInfo }> { diff --git a/frontend/src/views/settings/AgentSettings.vue b/frontend/src/views/settings/AgentSettings.vue index dc3d34e11..3f58f144c 100644 --- a/frontend/src/views/settings/AgentSettings.vue +++ b/frontend/src/views/settings/AgentSettings.vue @@ -190,6 +190,68 @@ + + +
+
+ +

配置 Agent 的系统提示词,支持占位符模板。占位符会在运行时自动替换为实际内容。

+
+

可用占位符:

+
    +
  • + - {{ placeholder.label }}({{ placeholder.description }}) +
  • +
+

提示:输入 {{ 时会自动显示可用占位符

+
+
+
+
+ + 恢复默认 + +
+
+ +
+ + +
+
+
+
+ +
+
{{ placeholder.description }}
+
+
+
+
+
+
@@ -198,9 +260,9 @@ import { ref, onMounted, watch, computed, nextTick } from 'vue' import { useRouter } from 'vue-router' import { useSettingsStore } from '@/stores/settings' -import { MessagePlugin } from 'tdesign-vue-next' +import { MessagePlugin, DialogPlugin } from 'tdesign-vue-next' import { listModels, type ModelConfig } from '@/api/model' -import { getAgentConfig, updateAgentConfig, type AgentConfig, type ToolDefinition } from '@/api/system' +import { getAgentConfig, updateAgentConfig, type AgentConfig, type ToolDefinition, type PlaceholderDefinition } from '@/api/system' const settingsStore = useSettingsStore() const router = useRouter() @@ -211,6 +273,7 @@ const localTemperature = ref(0.7) const localThinkingModelId = ref('') const localRerankModelId = ref('') const localAllowedTools = ref([]) +const localSystemPrompt = ref('') // 计算 Agent 是否就绪 const isAgentReady = computed(() => { @@ -248,12 +311,122 @@ const loadingModels = ref(false) // 可用工具列表 const availableTools = ref([]) +// 可用占位符列表 +const availablePlaceholders = ref([]) // 配置加载状态 const loadingConfig = ref(false) const configLoaded = ref(false) // 防止重复加载 const isInitializing = ref(true) // 标记是否正在初始化,防止初始化时触发保存 +// 保存的 Prompt 值,用于比较是否变化 +let savedSystemPrompt = '' + +// 恢复默认 Prompt 的加载状态 +const isResettingPrompt = ref(false) + +// 占位符提示相关状态 +const promptTextareaRef = ref(null) +const showPlaceholderPopup = ref(false) +const selectedPlaceholderIndex = ref(0) +let placeholderPopupTimer: any = null +const placeholderPrefix = ref('') // 当前输入的前缀,用于过滤 +const popupStyle = ref({ top: '0px', left: '0px' }) // 提示框位置 + +// 设置 textarea 原生事件监听器 +const setupTextareaEventListeners = () => { + nextTick(() => { + const textarea = getTextareaElement() + if (textarea) { + // 添加原生 keydown 事件监听(使用 capture 阶段,确保优先处理) + textarea.addEventListener('keydown', (e: KeyboardEvent) => { + // 如果正在显示占位符提示,优先处理占位符相关的按键 + if (showPlaceholderPopup.value && filteredPlaceholders.value.length > 0) { + if (e.key === 'ArrowDown') { + // 下箭头选择下一个 + e.preventDefault() + e.stopPropagation() + e.stopImmediatePropagation() + if (selectedPlaceholderIndex.value < filteredPlaceholders.value.length - 1) { + selectedPlaceholderIndex.value++ + } else { + selectedPlaceholderIndex.value = 0 // 循环到第一个 + } + return + } else if (e.key === 'ArrowUp') { + // 上箭头选择上一个 + e.preventDefault() + e.stopPropagation() + e.stopImmediatePropagation() + if (selectedPlaceholderIndex.value > 0) { + selectedPlaceholderIndex.value-- + } else { + selectedPlaceholderIndex.value = filteredPlaceholders.value.length - 1 // 循环到最后一个 + } + return + } else if (e.key === 'Enter') { + // Enter 键插入选中的占位符 + e.preventDefault() + e.stopPropagation() + e.stopImmediatePropagation() + const selected = filteredPlaceholders.value[selectedPlaceholderIndex.value] + if (selected) { + insertPlaceholder(selected.name) + } + return + } else if (e.key === 'Escape') { + // ESC 键关闭提示 + e.preventDefault() + e.stopPropagation() + e.stopImmediatePropagation() + showPlaceholderPopup.value = false + placeholderPrefix.value = '' + return + } + } + + // 如果按下的是 { 键 + if (e.key === '{') { + // 清除之前的定时器 + if (placeholderPopupTimer) { + clearTimeout(placeholderPopupTimer) + } + + // 延迟检查,等待输入完成(连续输入两个 {) + placeholderPopupTimer = setTimeout(() => { + checkAndShowPlaceholderPopup() + }, 150) + } + }, true) // 使用 capture 阶段 + + // 添加原生 input 事件监听(作为备用) + textarea.addEventListener('input', () => { + if (placeholderPopupTimer) { + clearTimeout(placeholderPopupTimer) + } + placeholderPopupTimer = setTimeout(() => { + checkAndShowPlaceholderPopup() + }, 50) + }) + } + }) +} + +// 获取 textarea 元素的辅助函数 +const getTextareaElement = (): HTMLTextAreaElement | null => { + if (promptTextareaRef.value) { + if (promptTextareaRef.value.$el) { + return promptTextareaRef.value.$el.querySelector('textarea') + } else if (promptTextareaRef.value instanceof HTMLTextAreaElement) { + return promptTextareaRef.value + } + } + + // 如果还是找不到,尝试通过 DOM 查找 + const wrapper = document.querySelector('.setting-control.full-width') + return wrapper?.querySelector('textarea') || null +} + // 初始化加载 onMounted(async () => { // 防止重复加载 @@ -275,7 +448,13 @@ onMounted(async () => { localThinkingModelId.value = config.thinking_model_id localRerankModelId.value = config.rerank_model_id localAllowedTools.value = config.allowed_tools || [] + localSystemPrompt.value = config.system_prompt || '' + savedSystemPrompt = config.system_prompt || '' // 记录已保存的值 availableTools.value = config.available_tools || [] + availablePlaceholders.value = config.available_placeholders || [] + + // 调试信息 + console.log('加载的占位符列表:', availablePlaceholders.value) // 统一加载所有模型(只调用一次API) if (config.thinking_model_id || config.rerank_model_id) { @@ -298,7 +477,10 @@ onMounted(async () => { // 再等待一帧,确保所有事件监听器都已设置好 requestAnimationFrame(() => { // 初始化完成,现在可以允许保存操作 - isInitializing.value = false + isInitializing.value = false + + // 设置原生事件监听器(作为备用方案) + setupTextareaEventListeners() }) } catch (error) { console.error('加载Agent配置失败:', error) @@ -380,7 +562,8 @@ const handleMaxIterationsChangeDebounced = (value: number) => { allowed_tools: localAllowedTools.value, temperature: localTemperature.value, thinking_model_id: localThinkingModelId.value, - rerank_model_id: localRerankModelId.value + rerank_model_id: localRerankModelId.value, + system_prompt: localSystemPrompt.value } await updateAgentConfig(config) @@ -443,7 +626,8 @@ const handleThinkingModelChange = async (value: string) => { allowed_tools: localAllowedTools.value, temperature: localTemperature.value, thinking_model_id: value, - rerank_model_id: localRerankModelId.value + rerank_model_id: localRerankModelId.value, + system_prompt: localSystemPrompt.value } await updateAgentConfig(config) @@ -476,7 +660,8 @@ const handleRerankModelChange = async (value: string) => { allowed_tools: localAllowedTools.value, temperature: localTemperature.value, thinking_model_id: localThinkingModelId.value, - rerank_model_id: value + rerank_model_id: value, + system_prompt: localSystemPrompt.value } await updateAgentConfig(config) @@ -556,7 +741,8 @@ const handleTemperatureChange = async (value: number) => { allowed_tools: localAllowedTools.value, temperature: value, thinking_model_id: localThinkingModelId.value, - rerank_model_id: localRerankModelId.value + rerank_model_id: localRerankModelId.value, + system_prompt: localSystemPrompt.value } await updateAgentConfig(config) @@ -581,7 +767,8 @@ const handleAllowedToolsChange = async (value: string[]) => { allowed_tools: value, temperature: localTemperature.value, thinking_model_id: localThinkingModelId.value, - rerank_model_id: localRerankModelId.value + rerank_model_id: localRerankModelId.value, + system_prompt: localSystemPrompt.value } await updateAgentConfig(config) @@ -595,6 +782,293 @@ const handleAllowedToolsChange = async (value: string[]) => { } } +// 处理系统 Prompt 键盘事件(作为备用,主要逻辑在原生事件监听器中) +const handlePromptKeydown = (e: KeyboardEvent) => { + // 如果正在显示占位符提示,且输入的是字母、数字或下划线,实时更新过滤 + if (showPlaceholderPopup.value && /^[a-zA-Z0-9_]$/.test(e.key)) { + // 延迟检查,等待字符输入完成 + if (placeholderPopupTimer) { + clearTimeout(placeholderPopupTimer) + } + placeholderPopupTimer = setTimeout(() => { + checkAndShowPlaceholderPopup() + }, 50) + } +} + +// 过滤后的占位符列表(根据前缀匹配) +const filteredPlaceholders = computed(() => { + if (!placeholderPrefix.value) { + return availablePlaceholders.value + } + + const prefix = placeholderPrefix.value.toLowerCase() + return availablePlaceholders.value.filter(p => + p.name.toLowerCase().startsWith(prefix) + ) +}) + +// 计算光标在 textarea 中的像素位置 +const calculateCursorPosition = (textarea: HTMLTextAreaElement) => { + const cursorPos = textarea.selectionStart + const textBeforeCursor = localSystemPrompt.value.substring(0, cursorPos) + + // 获取 textarea 的样式和位置 + const style = window.getComputedStyle(textarea) + const textareaRect = textarea.getBoundingClientRect() + + // 计算行数和当前行的文本 + const lines = textBeforeCursor.split('\n') + const currentLine = lines.length - 1 + const lineText = lines[currentLine] || '' + + // 获取行高 + const lineHeight = parseFloat(style.lineHeight) || parseFloat(style.fontSize) * 1.2 + + // 获取 padding + const paddingTop = parseFloat(style.paddingTop) || 0 + const paddingLeft = parseFloat(style.paddingLeft) || 0 + + // 使用 canvas 测量当前行的文本宽度(更准确) + const canvas = document.createElement('canvas') + const context = canvas.getContext('2d') + let textWidth = 0 + + if (context) { + context.font = `${style.fontSize} ${style.fontFamily}` + textWidth = context.measureText(lineText).width + } else { + // 回退方案:使用等宽字体估算(Monaco/Menlo 是等宽字体) + const charWidth = parseFloat(style.fontSize) * 0.6 // 等宽字体字符宽度约为字体大小的 0.6 倍 + textWidth = lineText.length * charWidth + } + + // 计算光标位置的 top(考虑滚动) + const scrollTop = textarea.scrollTop + const top = textareaRect.top + paddingTop + (currentLine * lineHeight) - scrollTop + lineHeight + 4 + + // 计算光标位置的 left(考虑滚动) + const scrollLeft = textarea.scrollLeft + const left = textareaRect.left + paddingLeft + textWidth - scrollLeft + + return { top, left } +} + +// 检查并显示占位符提示 +const checkAndShowPlaceholderPopup = () => { + const textarea = getTextareaElement() + + if (!textarea) { + return + } + + const cursorPos = textarea.selectionStart + const textBeforeCursor = localSystemPrompt.value.substring(0, cursorPos) + + // 检查是否输入了 {{(从光标位置向前查找最近的 {{) + // 需要找到光标前最近的 {{,且中间没有 }} + let lastOpenPos = -1 + for (let i = cursorPos - 1; i >= 0; i--) { + if (i > 0 && textBeforeCursor[i - 1] === '{' && textBeforeCursor[i] === '{') { + // 找到了 {{ + const textAfterOpen = textBeforeCursor.substring(i + 1) + // 检查是否已经包含 }}(说明占位符已完成) + if (!textAfterOpen.includes('}}')) { + lastOpenPos = i - 1 + break + } + } + } + + if (lastOpenPos === -1) { + // 没有找到有效的 {{,隐藏提示 + showPlaceholderPopup.value = false + placeholderPrefix.value = '' + return + } + + // 获取 {{ 之后到光标位置的内容作为前缀 + const textAfterOpen = textBeforeCursor.substring(lastOpenPos + 2) + + // 更新前缀 + placeholderPrefix.value = textAfterOpen + + // 根据前缀过滤占位符 + const filtered = filteredPlaceholders.value + + if (filtered.length > 0) { + // 有匹配的占位符,显示提示 + // 计算光标位置 + nextTick(() => { + const position = calculateCursorPosition(textarea) + popupStyle.value = { + top: `${position.top}px`, + left: `${position.left}px` + } + showPlaceholderPopup.value = true + // 重置选中索引为第一个(默认选择第一个) + selectedPlaceholderIndex.value = 0 + }) + } else { + // 没有匹配的占位符,隐藏提示 + showPlaceholderPopup.value = false + } +} + +// 处理系统 Prompt 输入 +const handlePromptInput = () => { + // 清除之前的定时器 + if (placeholderPopupTimer) { + clearTimeout(placeholderPopupTimer) + } + + // 延迟检查,避免频繁触发 + placeholderPopupTimer = setTimeout(() => { + checkAndShowPlaceholderPopup() + }, 50) +} + +// 插入占位符 +const insertPlaceholder = (placeholderName: string) => { + const textarea = getTextareaElement() + if (!textarea) { + return + } + + // 先关闭提示,避免触发 blur 事件 + showPlaceholderPopup.value = false + placeholderPrefix.value = '' + selectedPlaceholderIndex.value = 0 + + // 延迟执行,确保提示框已关闭 + nextTick(() => { + const cursorPos = textarea.selectionStart + const textBeforeCursor = localSystemPrompt.value.substring(0, cursorPos) + const textAfterCursor = localSystemPrompt.value.substring(cursorPos) + + // 找到最后一个 {{ 的位置 + const lastOpenPos = textBeforeCursor.lastIndexOf('{{') + if (lastOpenPos === -1) { + // 如果没有找到 {{,直接插入完整的占位符 + const placeholder = `{{${placeholderName}}}` + localSystemPrompt.value = textBeforeCursor + placeholder + textAfterCursor + // 设置光标位置 + nextTick(() => { + const newPos = cursorPos + placeholder.length + textarea.setSelectionRange(newPos, newPos) + textarea.focus() + }) + } else { + // 替换 {{ 到光标位置的内容为完整的占位符 + const beforePlaceholder = textBeforeCursor.substring(0, lastOpenPos) + const placeholder = `{{${placeholderName}}}` + localSystemPrompt.value = beforePlaceholder + placeholder + textAfterCursor + // 设置光标位置 + nextTick(() => { + const newPos = lastOpenPos + placeholder.length + textarea.setSelectionRange(newPos, newPos) + textarea.focus() + }) + } + }) +} + +// 恢复默认 Prompt +const handleResetToDefault = async () => { + const confirmDialog = DialogPlugin.confirm({ + header: '恢复默认 Prompt', + body: '确定要恢复为默认 Prompt 吗?当前的自定义 Prompt 将被覆盖。', + confirmBtn: '确定', + cancelBtn: '取消', + onConfirm: async () => { + try { + isResettingPrompt.value = true + + // 通过设置 system_prompt 为空字符串来获取默认值 + // 后端在 system_prompt 为空时会返回默认值 + const tempConfig: AgentConfig = { + enabled: isAgentReady.value, + max_iterations: localMaxIterations.value, + reflection_enabled: false, + allowed_tools: localAllowedTools.value, + temperature: localTemperature.value, + thinking_model_id: localThinkingModelId.value, + rerank_model_id: localRerankModelId.value, + system_prompt: '' // 空字符串表示使用默认 + } + + await updateAgentConfig(tempConfig) + + // 重新加载配置以获取默认 Prompt 的完整内容 + const res = await getAgentConfig() + const defaultPrompt = res.data.system_prompt || '' + + // 设置为默认 Prompt 的内容 + localSystemPrompt.value = defaultPrompt + savedSystemPrompt = defaultPrompt + + MessagePlugin.success('已恢复为默认 Prompt') + confirmDialog.hide() + } catch (error) { + console.error('恢复默认 Prompt 失败:', error) + MessagePlugin.error(getErrorMessage(error)) + } finally { + isResettingPrompt.value = false + } + } + }) +} + +// 处理系统 Prompt 变化 +const handleSystemPromptChange = async (e?: FocusEvent) => { + // 如果点击的是占位符提示框,不触发保存 + if (e?.relatedTarget) { + const target = e.relatedTarget as HTMLElement + if (target.closest('.placeholder-popup-wrapper')) { + return + } + } + + // 延迟检查,避免点击占位符时立即触发 + await nextTick() + + // 如果占位符提示框还在显示,说明用户点击了占位符,不触发保存 + if (showPlaceholderPopup.value) { + return + } + + // 隐藏占位符提示 + placeholderPrefix.value = '' + + // 如果正在初始化,不触发保存 + if (isInitializing.value) return + + // 检查内容是否变化 + if (localSystemPrompt.value === savedSystemPrompt) { + return // 内容没变,不调用接口 + } + + try { + const config: AgentConfig = { + enabled: isAgentReady.value, + max_iterations: localMaxIterations.value, + reflection_enabled: false, + allowed_tools: localAllowedTools.value, + temperature: localTemperature.value, + thinking_model_id: localThinkingModelId.value, + rerank_model_id: localRerankModelId.value, + system_prompt: localSystemPrompt.value + } + + await updateAgentConfig(config) + savedSystemPrompt = localSystemPrompt.value // 更新已保存的值 + MessagePlugin.success('系统 Prompt 已保存') + } catch (error) { + console.error('保存系统 Prompt 失败:', error) + MessagePlugin.error(getErrorMessage(error)) + } +} + // 监听 Agent 就绪状态变化,同步到 store watch(isAgentReady, (newValue, oldValue) => { if (!isInitializing.value) { @@ -760,7 +1234,6 @@ watch(isAgentReady, (newValue, oldValue) => { .setting-info { flex: 1; max-width: 65%; - padding-right: 24px; label { font-size: 15px; @@ -831,5 +1304,111 @@ watch(isAgentReady, (newValue, oldValue) => { } } +.prompt-header { + display: flex; + justify-content: flex-end; + margin-bottom: 8px; + width: 100%; +} + +.prompt-textarea-wrapper { + width: 100%; +} + +.setting-control.full-width { + display: flex; + flex-direction: column; + align-items: stretch; +} + +.placeholder-hint { + margin-top: 12px; + padding: 12px; + background: #f5f7fa; + border-radius: 4px; + font-size: 12px; + line-height: 1.6; + + .hint-title { + font-weight: 500; + color: #333; + margin: 0 0 8px 0; + } + + .placeholder-list { + margin: 8px 0; + padding-left: 20px; + color: #666; + + li { + margin: 4px 0; + + code { + background: #fff; + padding: 2px 6px; + border-radius: 3px; + font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace; + font-size: 11px; + color: #e83e8c; + border: 1px solid #e1e8ed; + } + } + } + + .hint-tip { + margin: 8px 0 0 0; + color: #999; + font-style: italic; + } +} + +.placeholder-popup-wrapper { + position: fixed; + z-index: 10001; + pointer-events: auto; +} + +.placeholder-popup { + background: #fff; + border: 1px solid #e5e7eb; + border-radius: 4px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + max-width: 400px; + max-height: 300px; + overflow-y: auto; + padding: 4px 0; +} + +.placeholder-item { + padding: 8px 12px; + cursor: pointer; + transition: background-color 0.2s; + + &:hover, + &.active { + background-color: #f5f7fa; + } + + .placeholder-name { + font-weight: 500; + margin-bottom: 4px; + + code { + background: #f5f7fa; + padding: 2px 6px; + border-radius: 3px; + font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace; + font-size: 12px; + color: #e83e8c; + } + } + + .placeholder-desc { + font-size: 12px; + color: #666; + line-height: 1.4; + } +} + diff --git a/internal/agent/engine.go b/internal/agent/engine.go index 375817bbc..14dd81298 100644 --- a/internal/agent/engine.go +++ b/internal/agent/engine.go @@ -31,6 +31,7 @@ type AgentEngine struct { knowledgeBasesInfo []*KnowledgeBaseInfo // Detailed knowledge base information for prompt contextManager interfaces.ContextManager // Context manager for writing agent conversation to LLM context sessionID string // Session ID for context management + systemPromptTemplate string // System prompt template (optional, uses default if empty) } // listToolNames returns tool.function names for logging @@ -52,19 +53,21 @@ func NewAgentEngine( knowledgeBasesInfo []*KnowledgeBaseInfo, contextManager interfaces.ContextManager, sessionID string, + systemPromptTemplate string, ) *AgentEngine { if eventBus == nil { eventBus = event.NewEventBus() } return &AgentEngine{ - config: config, - toolRegistry: toolRegistry, - chatModel: chatModel, - knowledgeService: knowledgeService, - eventBus: eventBus, - knowledgeBasesInfo: knowledgeBasesInfo, - contextManager: contextManager, - sessionID: sessionID, + config: config, + toolRegistry: toolRegistry, + chatModel: chatModel, + knowledgeService: knowledgeService, + eventBus: eventBus, + knowledgeBasesInfo: knowledgeBasesInfo, + contextManager: contextManager, + sessionID: sessionID, + systemPromptTemplate: systemPromptTemplate, } } @@ -85,7 +88,7 @@ func (e *AgentEngine) Execute(ctx context.Context, sessionID, messageID, query s } // Build system prompt - systemPrompt := BuildReActSystemPrompt(e.knowledgeBasesInfo) + systemPrompt := BuildReActSystemPrompt(e.knowledgeBasesInfo, e.systemPromptTemplate) logger.Debugf(ctx, "[Agent] SystemPrompt Length: %d characters", len(systemPrompt)) logger.Debugf(ctx, "[Agent] SystemPrompt (stream)\n----\n%s\n----", systemPrompt) @@ -642,7 +645,7 @@ func (e *AgentEngine) streamFinalAnswerToEventBus( len(state.RoundSteps), countTotalToolCalls(state.RoundSteps)) // Build messages with all context - systemPrompt := BuildReActSystemPrompt(e.knowledgeBasesInfo) + systemPrompt := BuildReActSystemPrompt(e.knowledgeBasesInfo, e.systemPromptTemplate) messages := []chat.Message{ {Role: "system", Content: systemPrompt}, diff --git a/internal/agent/prompts.go b/internal/agent/prompts.go index 2fa487253..91f78c4f2 100644 --- a/internal/agent/prompts.go +++ b/internal/agent/prompts.go @@ -82,11 +82,9 @@ func formatKnowledgeBaseList(kbInfos []*KnowledgeBaseInfo) string { return builder.String() } -// BuildReActSystemPrompt builds the system prompt for ReAct mode with enhanced guidance -func BuildReActSystemPrompt(knowledgeBases []*KnowledgeBaseInfo) string { - kbList := formatKnowledgeBaseList(knowledgeBases) - - prompt := fmt.Sprintf(`# Role +// DefaultReActSystemPrompt is the default system prompt template +// This is used when no custom prompt is configured +const DefaultReActSystemPrompt = `# Role You are WeKnora, an intelligent knowledge base assistant. Your mission is to provide accurate, traceable information through systematic tool use and structured task management. @@ -99,7 +97,7 @@ Core capabilities: # Known Information ## Available Knowledge Bases -%s +{{knowledge_bases}} ## Available Tools @@ -177,7 +175,7 @@ Advanced Tools: # Standard Workflows ## Simple Query Pattern (single factual query) -`+"```"+` +` + "```" + ` User question ↓ think (quick analysis: what information needed? which KB?) @@ -189,13 +187,13 @@ evaluate quality if high quality (>=0.7) → answer with citations else if medium quality (0.5-0.7) → get_related_chunks for context → answer else → retry with different keywords → answer or explain insufficient info -`+"```"+` +` + "```" + ` Example: "What is Docker?" Characteristics: single step, no todo_write needed ## Complex Research Pattern (multi-dimensional, strongly recommend todo_write) -`+"```"+` +` + "```" + ` User question ↓ think (deep analysis: identify multiple dimensions, what information needed) @@ -211,13 +209,13 @@ Execute step3: mark in_progress → query_knowledge_graph → mark completed think (integrate all findings, check completeness) ↓ comprehensive answer (cite all sources) -`+"```"+` +` + "```" + ` Example: "How to design a highly available microservices architecture?" Characteristics: multi-step, must use todo_write for organization ## Comparison Query Pattern (structured comparison) -`+"```"+` +` + "```" + ` User question ↓ think (identify comparison dimensions: performance, features, cost, etc.) @@ -229,13 +227,13 @@ Mark step1 in_progress → knowledge_search(target A) → get_related_chunks → Mark step2 in_progress → knowledge_search(target B) → get_related_chunks → completed ↓ Mark step3 in_progress → think (comparative analysis) → structured answer → completed -`+"```"+` +` + "```" + ` Example: "Compare Redis and Memcached" Characteristics: structured comparison, use todo_write for fair coverage ## Exploration Query Pattern (concept relationships) -`+"```"+` +` + "```" + ` User question ↓ think (analyze exploration needs) @@ -249,7 +247,7 @@ query_knowledge_graph (explore relationships) get_related_chunks (expand understanding) ↓ comprehensive relationship network answer -`+"```"+` +` + "```" + ` Example: "Relationship between Docker and Kubernetes" @@ -261,52 +259,51 @@ When multiple tool calls have no dependencies, execute them in parallel for effi ## Parallelizable Scenarios 1. Multiple independent searches: -`+"```"+` +` + "```" + ` Parallel execution: - knowledge_search(query="Redis performance") - knowledge_search(query="Redis persistence") - knowledge_search(query="Redis clustering") Reason: searches are independent, can execute simultaneously -`+"```"+` +` + "```" + ` 2. Search + document metadata retrieval: -`+"```"+` +` + "```" + ` Parallel execution: - knowledge_search(query="microservices architecture") - get_document_info(document_id="doc123") Reason: search and metadata retrieval are independent -`+"```"+` +` + "```" + ` 3. Multiple related chunks retrieval: -`+"```"+` +` + "```" + ` Parallel execution (if getting different chunks): - get_related_chunks(chunk_ids=["chunk1"], relation_type="sequential") - get_related_chunks(chunk_ids=["chunk2"], relation_type="sequential") Reason: different chunk retrievals are independent -`+"```"+` +` + "```" + ` ## Non-Parallelizable Scenarios 1. Dependent operations: -`+"```"+` +` + "```" + ` Cannot parallelize: Step 1: knowledge_search → obtain chunk_ids Step 2: get_related_chunks(chunk_ids) → needs Step 1 results Correct approach: sequential execution -`+"```"+` - +` + "```" + ` 2. Operations requiring evaluation: -`+"```"+` +` + "```" + ` Cannot parallelize: Step 1: knowledge_search → evaluate result quality Step 2: decide whether to call get_related_chunks based on quality Correct approach: search, think for evaluation, then decide next step -`+"```"+` +` + "```" + ` ## Best Practices - Comparison queries: parallel search for multiple targets @@ -344,15 +341,15 @@ Note: Available knowledge base information is provided in the system prompt, no 4. Combine with content search -> knowledge_search + database_query (parallel) ## Todo_Write Decision Tree -`+"```"+` +` + "```" + ` How many steps does the task require? ↓ 1 step → no todo_write needed, execute directly ↓ 2-3 steps → strongly recommend todo_write (maintain organization) ↓ -4+ steps → must use todo_write (otherwise easily becomes chaotic) -`+"```"+` +4+ steps -> must use todo_write (otherwise easily becomes chaotic) +` + "```" + ` # Failure Recovery Modes @@ -374,14 +371,14 @@ Prohibited: - Do not skip trying other query strategies Example (using todo_write): -`+"```"+` +` + "```" + ` todo_write: step1=search Redis config(in_progress), step2=organize answer(pending) -> knowledge_search("Redis config") -> all results < 0.5 -> think: too broad, try specific config items -> knowledge_search(vector_queries=["Redis persistence config"], keyword_queries=["RDB", "AOF"]) -> if still poor, update todo: step1=completed (result: insufficient info) -> inform user: "Knowledge base has limited Redis configuration details, suggest uploading Redis official documentation" -`+"```"+` +` + "```" + ` ## Mode 2: Incomplete Information @@ -393,7 +390,7 @@ Steps: 5. If still incomplete, provide partial answer + clearly state missing content Example (using todo_write and parallel search): -`+"```"+` +` + "```" + ` Question: "How to deploy highly available Redis cluster?" -> think: multi-dimensional problem, needs structured research -> todo_write: step1=standalone deployment, step2=cluster config, step3=HA solution @@ -403,7 +400,7 @@ Question: "How to deploy highly available Redis cluster?" - knowledge_search(vector_queries=["Redis cluster configuration"]) - knowledge_search(vector_queries=["Redis Sentinel high availability"]) -> Comprehensive answer, mark sources and information completeness -`+"```"+` +` + "```" + ` ## Mode 3: Tool Call Failure @@ -415,14 +412,14 @@ Steps: 5. Continue with existing information, don't get stuck Example: -`+"```"+` +` + "```" + ` get_related_chunks(chunk_ids=[...10 IDs]) -> failed (possible timeout) -> think: too many IDs at once, batch process -> Parallel execute: - get_related_chunks(chunk_ids=[first 5]) - get_related_chunks(chunk_ids=[last 5]) Or: use knowledge_search as alternative to get context -`+"```"+` +` + "```" + ` ## Mode 4: Never Give Up Principle (CRITICAL) @@ -453,17 +450,17 @@ Before generating answer, confirm: ## Citation Format (STRICT) Inline citation (single source): -`+"```"+` +` + "```" + ` According to "Redis Manual" (chunk: abc123, relevance: 0.85), there are two persistence methods... -`+"```"+` +` + "```" + ` Paragraph citation (paragraph-level reference): -`+"```"+` +` + "```" + ` Redis supports RDB and AOF persistence mechanisms. RDB saves data through snapshots with fast recovery but potential recent data loss. AOF records each write operation with better data integrity but larger file size. [Source: "Redis Configuration Guide", chunk: xyz789, relevance: 0.92] -`+"```"+` +` + "```" + ` End summary (required when using 3+ sources): ## References @@ -480,61 +477,61 @@ Suggestion: This information has low relevance, recommend consulting official do # Tool Combination Patterns 1. Deep Research Flow (strongly recommend todo_write): -`+"```"+` +` + "```" + ` think (analyze problem) -> todo_write (plan steps) -> knowledge_search -> evaluate results -> get_related_chunks -> update todo status -> comprehensive answer -`+"```"+` +` + "```" + ` Scenario: need comprehensive understanding of topic Example: How to design microservices architecture? Parallel opportunity: if searching multiple dimensions, can parallelize multiple knowledge_search calls 2. Comparison Research Flow (recommend todo_write + parallel): -`+"```"+` +` + "```" + ` think (identify comparison dimensions) -> todo_write (target A, target B, synthesis) -> Parallel execute: - knowledge_search(target A) + get_related_chunks - knowledge_search(target B) + get_related_chunks -> think (comparative analysis) -> structured answer -`+"```"+` +` + "```" + ` Scenario: compare multiple systems, tools, or approaches Example: Compare Redis and Memcached Parallel advantage: simultaneously search multiple targets for efficiency 3. Document Exploration Flow: -`+"```"+` +` + "```" + ` Parallel execute: - knowledge_search (content search) - get_document_info (metadata retrieval) -> get_related_chunks (dive into key sections) -`+"```"+` +` + "```" + ` Scenario: explore document content and metadata Example: understand details of a specific document Parallel advantage: search and metadata retrieval are independent 4. Context Building Flow: -`+"```"+` +` + "```" + ` knowledge_search -> get_related_chunks(sequential) -> think -> synthesized understanding -`+"```"+` +` + "```" + ` Scenario: need to understand before/after context Example: complete explanation of a configuration item 5. Entity Relationship Exploration Flow: -`+"```"+` +` + "```" + ` query_knowledge_graph -> get_related_chunks(semantic) -`+"```"+` +` + "```" + ` Scenario: understand inter-concept relationships Example: relationship between Docker and Kubernetes 6. Targeted Query Flow: -`+"```"+` +` + "```" + ` knowledge_search (known KB) -> get_related_chunks -> fast accurate answer -`+"```"+` +` + "```" + ` Scenario: know exactly which knowledge base contains information Example: find specific standards in company documentation 7. Multi-dimensional Parallel Research Flow (todo_write most valuable scenario): -`+"```"+` +` + "```" + ` think (identify multiple research dimensions) -> todo_write (dim1, dim2, dim3, synthesis) -> Parallel execute multiple dimensions: - knowledge_search(dimension 1) @@ -543,28 +540,28 @@ Parallel execute multiple dimensions: -> evaluate each, use get_related_chunks if needed -> update each todo to completed -> think (synthesize all dimensions) -> comprehensive answer -`+"```"+` +` + "```" + ` Scenario: complex multi-dimensional problems Example: "Comprehensively analyze microservices architecture design, deployment, monitoring, and security" Key value: todo_write helps track completion status of each dimension 8. Data Analytics + Content Search Flow: -`+"```"+` +` + "```" + ` Parallel execute: - database_query (get statistics/counts/aggregations) - knowledge_search (get detailed content) -> think (combine quantitative + qualitative data) -> comprehensive answer -`+"```"+` +` + "```" + ` Scenario: questions requiring both data statistics and content details Example: "How many knowledge bases do I have and what are their main topics?" Parallel advantage: statistics and content search are independent 9. System Status + Troubleshooting Flow: -`+"```"+` +` + "```" + ` database_query (check processing status, failed documents) -> think (analyze issues) -> knowledge_search (find related documentation for solutions) -`+"```"+` +` + "```" + ` Scenario: system health checks and issue resolution Example: "Which documents failed to process and why?" @@ -610,7 +607,33 @@ Remember: you are a knowledge base assistant, not a general AI. Your value lies - Traceability: all assertions have clear sources - Systematic: use think and todo_write for structured thinking - Efficiency: leverage parallel execution for faster response -- Professional: evidence-based professional answers`, kbList) +- Professional: evidence-based professional answers` - return prompt +// renderPromptPlaceholders renders placeholders in the prompt template +// Supported placeholders: +// - {{knowledge_bases}} - Replaced with formatted knowledge base list +func renderPromptPlaceholders(template string, knowledgeBases []*KnowledgeBaseInfo) string { + result := template + + // Replace {{knowledge_bases}} placeholder + if strings.Contains(result, "{{knowledge_bases}}") { + kbList := formatKnowledgeBaseList(knowledgeBases) + result = strings.ReplaceAll(result, "{{knowledge_bases}}", kbList) + } + + return result +} + +// BuildReActSystemPrompt builds the system prompt for ReAct mode with enhanced guidance +// If systemPromptTemplate is provided and non-empty, it will be used with placeholder rendering. +// Otherwise, the default prompt will be used. +func BuildReActSystemPrompt(knowledgeBases []*KnowledgeBaseInfo, systemPromptTemplate ...string) string { + var template string + if len(systemPromptTemplate) > 0 && systemPromptTemplate[0] != "" { + template = systemPromptTemplate[0] + } else { + template = DefaultReActSystemPrompt + } + + return renderPromptPlaceholders(template, knowledgeBases) } diff --git a/internal/application/service/agent_service.go b/internal/application/service/agent_service.go index 0844bc3ed..18b61808c 100644 --- a/internal/application/service/agent_service.go +++ b/internal/application/service/agent_service.go @@ -144,6 +144,9 @@ func (s *agentService) CreateAgentEngine( } } + // Get system prompt template from config (use default if empty) + systemPromptTemplate := config.SystemPrompt + // Create engine with provided EventBus and contextManager engine := agent.NewAgentEngine( config, @@ -154,6 +157,7 @@ func (s *agentService) CreateAgentEngine( kbInfos, contextManager, sessionID, + systemPromptTemplate, ) return engine, nil diff --git a/internal/handler/tenant.go b/internal/handler/tenant.go index 281ac3507..fe0e9428c 100644 --- a/internal/handler/tenant.go +++ b/internal/handler/tenant.go @@ -6,6 +6,7 @@ import ( "github.com/gin-gonic/gin" + "github.com/Tencent/WeKnora/internal/agent" "github.com/Tencent/WeKnora/internal/errors" "github.com/Tencent/WeKnora/internal/logger" "github.com/Tencent/WeKnora/internal/types" @@ -234,6 +235,7 @@ type AgentConfigRequest struct { Temperature float64 `json:"temperature"` ThinkingModelID string `json:"thinking_model_id"` RerankModelID string `json:"rerank_model_id"` + SystemPrompt string `json:"system_prompt,omitempty"` // System prompt template with placeholders (optional) } // GetTenantAgentConfig retrieves the agent configuration for a tenant @@ -273,6 +275,11 @@ func (h *TenantHandler) GetTenantAgentConfig(c *gin.Context) { {"name": "get_document_info", "label": "获取文档信息", "description": "查看文档元数据"}, {"name": "database_query", "label": "查询数据库", "description": "查询数据库中的信息"}, } + + // 定义可用的占位符列表 + availablePlaceholders := []gin.H{ + {"name": "knowledge_bases", "label": "知识库列表", "description": "自动格式化为表格形式的知识库列表,包含知识库名称、描述、文档数量、最近添加的文档等信息"}, + } if tenant.AgentConfig == nil { // Return default config if not set logger.Info(ctx, "Tenant has no agent config, returning defaults") @@ -280,30 +287,40 @@ func (h *TenantHandler) GetTenantAgentConfig(c *gin.Context) { c.JSON(http.StatusOK, gin.H{ "success": true, "data": gin.H{ - "max_iterations": 20, - "reflection_enabled": false, - "allowed_tools": []string{"thinking", "todo_write", "knowledge_search", "get_related_chunks", "query_knowledge_graph", "get_document_info", "database_query"}, - "temperature": 0.7, - "thinking_model_id": "", - "rerank_model_id": "", - "available_tools": availableTools, + "max_iterations": 20, + "reflection_enabled": false, + "allowed_tools": []string{"thinking", "todo_write", "knowledge_search", "get_related_chunks", "query_knowledge_graph", "get_document_info", "database_query"}, + "temperature": 0.7, + "thinking_model_id": "", + "rerank_model_id": "", + "system_prompt": agent.DefaultReActSystemPrompt, + "available_tools": availableTools, + "available_placeholders": availablePlaceholders, }, }) return } + // Get system prompt, use default if empty + systemPrompt := tenant.AgentConfig.SystemPrompt + if systemPrompt == "" { + systemPrompt = agent.DefaultReActSystemPrompt + } + logger.Infof(ctx, "Retrieved tenant agent config successfully, Tenant ID: %d", tenantID) c.JSON(http.StatusOK, gin.H{ "success": true, "data": gin.H{ - "enabled": tenant.AgentConfig.Enabled, - "max_iterations": tenant.AgentConfig.MaxIterations, - "reflection_enabled": tenant.AgentConfig.ReflectionEnabled, - "allowed_tools": tenant.AgentConfig.AllowedTools, - "temperature": tenant.AgentConfig.Temperature, - "thinking_model_id": tenant.AgentConfig.ThinkingModelID, - "rerank_model_id": tenant.AgentConfig.RerankModelID, - "available_tools": availableTools, + "enabled": tenant.AgentConfig.Enabled, + "max_iterations": tenant.AgentConfig.MaxIterations, + "reflection_enabled": tenant.AgentConfig.ReflectionEnabled, + "allowed_tools": tenant.AgentConfig.AllowedTools, + "temperature": tenant.AgentConfig.Temperature, + "thinking_model_id": tenant.AgentConfig.ThinkingModelID, + "rerank_model_id": tenant.AgentConfig.RerankModelID, + "system_prompt": systemPrompt, + "available_tools": availableTools, + "available_placeholders": availablePlaceholders, }, }) } @@ -371,6 +388,7 @@ func (h *TenantHandler) UpdateTenantAgentConfig(c *gin.Context) { Temperature: req.Temperature, ThinkingModelID: req.ThinkingModelID, RerankModelID: req.RerankModelID, + SystemPrompt: req.SystemPrompt, } updatedTenant, err := h.service.UpdateTenant(ctx, tenant) diff --git a/internal/types/agent.go b/internal/types/agent.go index 947985456..74e4651e8 100644 --- a/internal/types/agent.go +++ b/internal/types/agent.go @@ -10,14 +10,15 @@ import ( // AgentConfig represents the full agent configuration (used at tenant level and runtime) // This includes all configuration parameters for agent execution type AgentConfig struct { - Enabled bool `json:"enabled"` // Whether agent mode is enabled - MaxIterations int `json:"max_iterations"` // Maximum number of ReAct iterations - ReflectionEnabled bool `json:"reflection_enabled"` // Whether to enable reflection - AllowedTools []string `json:"allowed_tools"` // List of allowed tool names - Temperature float64 `json:"temperature"` // LLM temperature for agent - ThinkingModelID string `json:"thinking_model_id"` // Model ID for reasoning - RerankModelID string `json:"rerank_model_id"` // Model ID for reranking search results - KnowledgeBases []string `json:"knowledge_bases"` // Accessible knowledge base IDs + Enabled bool `json:"enabled"` // Whether agent mode is enabled + MaxIterations int `json:"max_iterations"` // Maximum number of ReAct iterations + ReflectionEnabled bool `json:"reflection_enabled"` // Whether to enable reflection + AllowedTools []string `json:"allowed_tools"` // List of allowed tool names + Temperature float64 `json:"temperature"` // LLM temperature for agent + ThinkingModelID string `json:"thinking_model_id"` // Model ID for reasoning + RerankModelID string `json:"rerank_model_id"` // Model ID for reranking search results + KnowledgeBases []string `json:"knowledge_bases"` // Accessible knowledge base IDs + SystemPrompt string `json:"system_prompt,omitempty"` // System prompt template with placeholders (optional) } // SessionAgentConfig represents session-level agent configuration