fix(prompt): resolve variable replacement in function mode and add UI features (#6581)

* fix(prompt): fix variable replacement in function mode

* fix(i18n): update available variables in prompt tip

* feat(prompt):  replace prompt variable in Prompt and AssistantPromptSettings components

* fix(prompt): add fallback value if replace failed

* feat(prompt): add hook and settings for automatic prompt replacement

* feat(prompt): add supported variables and utility function to check if they exist

* feat(prompt): enhance variable handling in prompt settings and tooltips

* feat(i18n): add prompt settings translations for multiple languages

* refactor(prompt): remove debug log from prompt processing

* fix(prompt): handle model name variables and improve prompt processing

* fix: correct variable replacement setting and update migration defaults

* remove prompt settings

* refactor: simplify model name replacement logic

- Remove unnecessary assistant parameter from buildSystemPrompt function
- Update all API clients to use the simplified function signature
- Centralize model name replacement logic in promptVariableReplacer
- Improve code maintainability by reducing parameter coupling

* fix: eslint error

* refactor: remove unused interval handling in usePromptProcessor

* test: add tests, remove redundant replacing

* feat: animate prompt substitution

* chore: prepare for merge

* refactor: update utils

* refactor: remove getStoreSettings

* refactor: update utils

* style(Message/Prompt): 禁止文本选中以提升用户体验

* fix(Prompt): 修复内存泄漏问题,清除内部定时器

* refactor: move prompt replacement to api service

---------

Co-authored-by: one <wangan.cs@gmail.com>
Co-authored-by: icarus <eurfelux@gmail.com>
This commit is contained in:
熊可狸 2025-07-31 19:11:31 +08:00 committed by GitHub
parent e634279481
commit 10b7c70a59
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 186 additions and 74 deletions

View File

@ -0,0 +1,34 @@
import { loggerService } from '@logger'
import { containsSupportedVariables, replacePromptVariables } from '@renderer/utils/prompt'
import { useEffect, useState } from 'react'
const logger = loggerService.withContext('usePromptProcessor')
interface PromptProcessor {
prompt: string
modelName?: string
}
export function usePromptProcessor({ prompt, modelName }: PromptProcessor): string {
const [processedPrompt, setProcessedPrompt] = useState(prompt)
useEffect(() => {
const processPrompt = async () => {
try {
if (containsSupportedVariables(prompt)) {
const result = await replacePromptVariables(prompt, modelName)
setProcessedPrompt(result)
} else {
setProcessedPrompt(prompt)
}
} catch (error) {
logger.error('Failed to process prompt variables, falling back:', error as Error)
setProcessedPrompt(prompt)
}
}
processPrompt()
}, [prompt, modelName])
return processedPrompt
}

View File

@ -1,7 +1,9 @@
import { useTheme } from '@renderer/context/ThemeProvider'
import { usePromptProcessor } from '@renderer/hooks/usePromptProcessor'
import AssistantSettingsPopup from '@renderer/pages/settings/AssistantSettings'
import { Assistant, Topic } from '@renderer/types'
import { FC } from 'react'
import { containsSupportedVariables } from '@renderer/utils/prompt'
import { FC, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
@ -18,13 +20,50 @@ const Prompt: FC<Props> = ({ assistant, topic }) => {
const topicPrompt = topic?.prompt || ''
const isDark = theme === 'dark'
const processedPrompt = usePromptProcessor({ prompt, modelName: assistant.model?.name })
// 用于控制显示的状态
const [displayText, setDisplayText] = useState(prompt)
const [isVisible, setIsVisible] = useState(true)
useEffect(() => {
// 如果没有变量需要替换,直接显示处理后的内容
if (!containsSupportedVariables(prompt)) {
setDisplayText(processedPrompt)
setIsVisible(true)
return
}
// 如果有变量需要替换先显示原始prompt
setDisplayText(prompt)
setIsVisible(true)
// 延迟过渡
let innerTimer: NodeJS.Timeout
const outerTimer = setTimeout(() => {
// 先淡出
setIsVisible(false)
// 切换内容并淡入
innerTimer = setTimeout(() => {
setDisplayText(processedPrompt)
setIsVisible(true)
}, 300)
}, 300)
return () => {
clearTimeout(outerTimer)
clearTimeout(innerTimer)
}
}, [prompt, processedPrompt])
if (!prompt && !topicPrompt) {
return null
}
return (
<Container className="system-prompt" onClick={() => AssistantSettingsPopup.show({ assistant })} $isDark={isDark}>
<Text>{prompt}</Text>
<Text $isVisible={isVisible}>{displayText}</Text>
</Container>
)
}
@ -38,13 +77,17 @@ const Container = styled.div<{ $isDark: boolean }>`
margin-bottom: 0;
`
const Text = styled.div`
const Text = styled.div<{ $isVisible: boolean }>`
color: var(--color-text-2);
font-size: 12px;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
user-select: none;
opacity: ${(props) => (props.$isVisible ? 1 : 0)};
transition: opacity 0.3s ease-in-out;
`
export default Prompt

View File

@ -4,6 +4,7 @@ import { CloseCircleFilled, QuestionCircleOutlined } from '@ant-design/icons'
import CodeEditor from '@renderer/components/CodeEditor'
import EmojiPicker from '@renderer/components/EmojiPicker'
import { Box, HSpaceBetweenStack, HStack } from '@renderer/components/Layout'
import { usePromptProcessor } from '@renderer/hooks/usePromptProcessor'
import { estimateTextTokens } from '@renderer/services/TokenService'
import { Assistant, AssistantSettings } from '@renderer/types'
import { getLeadingEmoji } from '@renderer/utils'
@ -38,6 +39,11 @@ const AssistantPromptSettings: React.FC<Props> = ({ assistant, updateAssistant }
updateTokenCount()
}, [prompt])
const processedPrompt = usePromptProcessor({
prompt,
modelName: assistant.model?.name
})
const onUpdate = () => {
const _assistant = { ...assistant, name: name.trim(), emoji, prompt }
updateAssistant(_assistant)
@ -112,7 +118,7 @@ const AssistantPromptSettings: React.FC<Props> = ({ assistant, updateAssistant }
<TextAreaContainer>
{showMarkdown ? (
<MarkdownContainer className="markdown" onClick={() => setShowMarkdown(false)}>
<ReactMarkdown>{prompt}</ReactMarkdown>
<ReactMarkdown>{processedPrompt || prompt}</ReactMarkdown>
<div style={{ height: '30px' }} />
</MarkdownContainer>
) : (

View File

@ -41,7 +41,12 @@ import { removeSpecialCharactersForTopicName } from '@renderer/utils'
import { isAbortError } from '@renderer/utils/error'
import { extractInfoFromXML, ExtractResults } from '@renderer/utils/extract'
import { findFileBlocks, getMainTextContent } from '@renderer/utils/messageUtils/find'
import { buildSystemPromptWithThinkTool, buildSystemPromptWithTools } from '@renderer/utils/prompt'
import {
buildSystemPromptWithThinkTool,
buildSystemPromptWithTools,
containsSupportedVariables,
replacePromptVariables
} from '@renderer/utils/prompt'
import { findLast, isEmpty, takeRight } from 'lodash'
import AiProvider from '../aiCore'
@ -426,6 +431,10 @@ export async function fetchChatCompletion({
}) {
logger.debug('fetchChatCompletion', messages, assistant)
if (assistant.prompt && containsSupportedVariables(assistant.prompt)) {
assistant.prompt = await replacePromptVariables(assistant.prompt, assistant.model?.name)
}
const provider = getAssistantProvider(assistant)
const AI = new AiProvider(provider)
@ -643,9 +652,13 @@ export async function fetchTranslate({ content, assistant, onResponse }: FetchTr
}
export async function fetchMessagesSummary({ messages, assistant }: { messages: Message[]; assistant: Assistant }) {
const prompt = (getStoreSetting('topicNamingPrompt') as string) || i18n.t('prompts.title')
let prompt = (getStoreSetting('topicNamingPrompt') as string) || i18n.t('prompts.title')
const model = getTopNamingModel() || assistant.model || getDefaultModel()
if (prompt && containsSupportedVariables(prompt)) {
prompt = await replacePromptVariables(prompt, model.name)
}
// 总结上下文总是取最后5条消息
const contextMessages = takeRight(messages, 5)

View File

@ -17,7 +17,6 @@ import {
createTranslationBlock,
resetAssistantMessage
} from '@renderer/utils/messageUtils/create'
import { buildSystemPrompt } from '@renderer/utils/prompt'
import { getTopicQueue } from '@renderer/utils/queue'
import { waitForTopicQueue } from '@renderer/utils/queue'
import { t } from 'i18next'
@ -878,8 +877,6 @@ const fetchAndProcessAssistantResponseImpl = async (
// }
// }
assistant.prompt = await buildSystemPrompt(assistant.prompt || '', assistant)
callbacks = createCallbacks({
blockManager,
dispatch,

View File

@ -4,9 +4,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
AvailableTools,
buildSystemPrompt,
buildSystemPromptWithThinkTool,
buildSystemPromptWithTools,
replacePromptVariables,
SYSTEM_PROMPT,
THINK_TOOL_PROMPT,
ToolUseExamples
@ -130,7 +130,7 @@ describe('prompt', () => {
- : {{username}};
`
const assistant = createMockAssistant('MyAssistant', 'Super-Model-X')
const result = await buildSystemPrompt(userPrompt, assistant)
const result = await replacePromptVariables(userPrompt, assistant.model?.name)
const expectedPrompt = `
:
- 日期和时间: ${mockDate.toLocaleString()};
@ -148,13 +148,13 @@ describe('prompt', () => {
mockApi.getAppInfo.mockRejectedValue(new Error('API Error'))
const userPrompt = 'System: {{system}}, Architecture: {{arch}}'
const result = await buildSystemPrompt(userPrompt)
const result = await replacePromptVariables(userPrompt)
const expectedPrompt = 'System: Unknown System, Architecture: Unknown Architecture'
expect(result).toEqual(expectedPrompt)
})
it('should handle non-string input gracefully', async () => {
const result = await buildSystemPrompt(null as any)
const result = await replacePromptVariables(null as any)
expect(result).toBe(null)
})
})
@ -173,7 +173,7 @@ describe('prompt', () => {
Instructions: Be helpful.
`
const assistant = createMockAssistant('Test Assistant', 'Advanced-AI-Model')
basePrompt = await buildSystemPrompt(initialPrompt, assistant)
basePrompt = await replacePromptVariables(initialPrompt, assistant.model?.name)
expectedBasePrompt = `
System Information:
- Date: ${mockDate.toLocaleDateString()}
@ -212,7 +212,7 @@ describe('prompt', () => {
describe('buildSystemPromptWithTools', () => {
it('should build a full prompt for "prompt" toolUseMode', async () => {
const assistant = createMockAssistant('Test Assistant', 'Advanced-AI-Model')
const basePrompt = await buildSystemPrompt('Be helpful.', assistant)
const basePrompt = await replacePromptVariables('Be helpful.', assistant.model?.name)
const tools = [createMockTool('web_search', 'Search the web')]
const finalPrompt = buildSystemPromptWithTools(basePrompt, tools)
@ -236,7 +236,7 @@ describe('prompt', () => {
Instructions: Be helpful.
`
const assistant = createMockAssistant('Test Assistant', 'Advanced-AI-Model')
const basePrompt = await buildSystemPrompt(initialPrompt, assistant)
const basePrompt = await replacePromptVariables(initialPrompt, assistant.model?.name)
const expectedBasePrompt = `
System Information:
- Date: ${mockDate.toLocaleDateString()}

View File

@ -1,6 +1,6 @@
import { loggerService } from '@logger'
import store from '@renderer/store'
import { Assistant, MCPTool } from '@renderer/types'
import { MCPTool } from '@renderer/types'
const logger = loggerService.withContext('Utils:Prompt')
@ -196,71 +196,90 @@ ${availableTools}
</tools>`
}
export const buildSystemPrompt = async (userSystemPrompt: string, assistant?: Assistant): Promise<string> => {
if (typeof userSystemPrompt === 'string') {
const now = new Date()
if (userSystemPrompt.includes('{{date}}')) {
const date = now.toLocaleDateString()
userSystemPrompt = userSystemPrompt.replace(/{{date}}/g, date)
}
const supportedVariables = [
'{{username}}',
'{{date}}',
'{{time}}',
'{{datetime}}',
'{{system}}',
'{{language}}',
'{{arch}}',
'{{model_name}}'
]
if (userSystemPrompt.includes('{{time}}')) {
const time = now.toLocaleTimeString()
userSystemPrompt = userSystemPrompt.replace(/{{time}}/g, time)
}
export const containsSupportedVariables = (userSystemPrompt: string): boolean => {
return supportedVariables.some((variable) => userSystemPrompt.includes(variable))
}
if (userSystemPrompt.includes('{{datetime}}')) {
const datetime = now.toLocaleString()
userSystemPrompt = userSystemPrompt.replace(/{{datetime}}/g, datetime)
}
export const replacePromptVariables = async (userSystemPrompt: string, modelName?: string): Promise<string> => {
if (typeof userSystemPrompt !== 'string') {
logger.warn('User system prompt is not a string:', userSystemPrompt)
return userSystemPrompt
}
if (userSystemPrompt.includes('{{system}}')) {
try {
const systemType = await window.api.system.getDeviceType()
userSystemPrompt = userSystemPrompt.replace(/{{system}}/g, systemType)
} catch (error) {
logger.error('Failed to get system type:', error as Error)
userSystemPrompt = userSystemPrompt.replace(/{{system}}/g, 'Unknown System')
}
}
const now = new Date()
if (userSystemPrompt.includes('{{date}}')) {
const date = now.toLocaleDateString()
userSystemPrompt = userSystemPrompt.replace(/{{date}}/g, date)
}
if (userSystemPrompt.includes('{{language}}')) {
try {
const language = store.getState().settings.language
userSystemPrompt = userSystemPrompt.replace(/{{language}}/g, language)
} catch (error) {
logger.error('Failed to get language:', error as Error)
userSystemPrompt = userSystemPrompt.replace(/{{language}}/g, 'Unknown System Language')
}
}
if (userSystemPrompt.includes('{{time}}')) {
const time = now.toLocaleTimeString()
userSystemPrompt = userSystemPrompt.replace(/{{time}}/g, time)
}
if (userSystemPrompt.includes('{{arch}}')) {
try {
const appInfo = await window.api.getAppInfo()
userSystemPrompt = userSystemPrompt.replace(/{{arch}}/g, appInfo.arch)
} catch (error) {
logger.error('Failed to get architecture:', error as Error)
userSystemPrompt = userSystemPrompt.replace(/{{arch}}/g, 'Unknown Architecture')
}
}
if (userSystemPrompt.includes('{{datetime}}')) {
const datetime = now.toLocaleString()
userSystemPrompt = userSystemPrompt.replace(/{{datetime}}/g, datetime)
}
if (userSystemPrompt.includes('{{model_name}}')) {
try {
userSystemPrompt = userSystemPrompt.replace(/{{model_name}}/g, assistant?.model?.name || 'Unknown Model')
} catch (error) {
logger.error('Failed to get model name:', error as Error)
userSystemPrompt = userSystemPrompt.replace(/{{model_name}}/g, 'Unknown Model')
}
if (userSystemPrompt.includes('{{username}}')) {
try {
const userName = store.getState().settings.userName || 'Unknown Username'
userSystemPrompt = userSystemPrompt.replace(/{{username}}/g, userName)
} catch (error) {
logger.error('Failed to get username:', error as Error)
userSystemPrompt = userSystemPrompt.replace(/{{username}}/g, 'Unknown Username')
}
}
if (userSystemPrompt.includes('{{username}}')) {
try {
const username = store.getState().settings.userName || 'Unknown Username'
userSystemPrompt = userSystemPrompt.replace(/{{username}}/g, username)
} catch (error) {
logger.error('Failed to get username:', error as Error)
userSystemPrompt = userSystemPrompt.replace(/{{username}}/g, 'Unknown Username')
}
if (userSystemPrompt.includes('{{system}}')) {
try {
const systemType = await window.api.system.getDeviceType()
userSystemPrompt = userSystemPrompt.replace(/{{system}}/g, systemType)
} catch (error) {
logger.error('Failed to get system type:', error as Error)
userSystemPrompt = userSystemPrompt.replace(/{{system}}/g, 'Unknown System')
}
}
if (userSystemPrompt.includes('{{language}}')) {
try {
const language = store.getState().settings.language
userSystemPrompt = userSystemPrompt.replace(/{{language}}/g, language)
} catch (error) {
logger.error('Failed to get language:', error as Error)
userSystemPrompt = userSystemPrompt.replace(/{{language}}/g, 'Unknown System Language')
}
}
if (userSystemPrompt.includes('{{arch}}')) {
try {
const appInfo = await window.api.getAppInfo()
userSystemPrompt = userSystemPrompt.replace(/{{arch}}/g, appInfo.arch)
} catch (error) {
logger.error('Failed to get architecture:', error as Error)
userSystemPrompt = userSystemPrompt.replace(/{{arch}}/g, 'Unknown Architecture')
}
}
if (userSystemPrompt.includes('{{model_name}}')) {
try {
const name = modelName || store.getState().llm.defaultModel?.name
userSystemPrompt = userSystemPrompt.replace(/{{model_name}}/g, name)
} catch (error) {
logger.error('Failed to get model name:', error as Error)
userSystemPrompt = userSystemPrompt.replace(/{{model_name}}/g, 'Unknown Model')
}
}