cherry-studio/src/renderer/src/windows/mini/home/HomeWindow.tsx
Phantom 156ceca4a7 fix: use system prompt variables in quick assistant (#10925)
* feat: replace prompt variables in assistant before chat completion

* refactor(home-window): reorder prompt variable replacement for clarity

Move prompt variable replacement before message preparation to improve logical flow

(cherry picked from commit c7c9e1ee44)
2025-10-24 18:35:17 +08:00

622 lines
21 KiB
TypeScript

import { loggerService } from '@logger'
import { isMac } from '@renderer/config/constant'
import { useTheme } from '@renderer/context/ThemeProvider'
import { useAssistant } from '@renderer/hooks/useAssistant'
import { useSettings } from '@renderer/hooks/useSettings'
import i18n from '@renderer/i18n'
import { fetchChatCompletion } from '@renderer/services/ApiService'
import { getDefaultTopic } from '@renderer/services/AssistantService'
import { ConversationService } from '@renderer/services/ConversationService'
import { getAssistantMessage, getUserMessage } from '@renderer/services/MessagesService'
import store, { useAppSelector } from '@renderer/store'
import { updateOneBlock, upsertManyBlocks, upsertOneBlock } from '@renderer/store/messageBlock'
import { newMessagesActions, selectMessagesForTopic } from '@renderer/store/newMessage'
import { cancelThrottledBlockUpdate, throttledBlockUpdate } from '@renderer/store/thunk/messageThunk'
import { ThemeMode, Topic } from '@renderer/types'
import { Chunk, ChunkType } from '@renderer/types/chunk'
import { AssistantMessageStatus, MessageBlockStatus } from '@renderer/types/newMessage'
import { abortCompletion } from '@renderer/utils/abortController'
import { isAbortError } from '@renderer/utils/error'
import { createMainTextBlock, createThinkingBlock } from '@renderer/utils/messageUtils/create'
import { getMainTextContent } from '@renderer/utils/messageUtils/find'
import { replacePromptVariables } from '@renderer/utils/prompt'
import { defaultLanguage } from '@shared/config/constant'
import { IpcChannel } from '@shared/IpcChannel'
import { Divider } from 'antd'
import { cloneDeep, isEmpty } from 'lodash'
import { last } from 'lodash'
import React, { FC, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
import ChatWindow from '../chat/ChatWindow'
import TranslateWindow from '../translate/TranslateWindow'
import ClipboardPreview from './components/ClipboardPreview'
import FeatureMenus, { FeatureMenusRef } from './components/FeatureMenus'
import Footer from './components/Footer'
import InputBar from './components/InputBar'
const logger = loggerService.withContext('HomeWindow')
const HomeWindow: FC<{ draggable?: boolean }> = ({ draggable = true }) => {
const { language, readClipboardAtStartup, windowStyle } = useSettings()
const { theme } = useTheme()
const { t } = useTranslation()
const [route, setRoute] = useState<'home' | 'chat' | 'translate' | 'summary' | 'explanation'>('home')
const [isFirstMessage, setIsFirstMessage] = useState(true)
const [userInputText, setUserInputText] = useState('')
const [clipboardText, setClipboardText] = useState('')
const lastClipboardTextRef = useRef<string | null>(null)
const [isPinned, setIsPinned] = useState(false)
// Indicator for loading(thinking/streaming)
const [isLoading, setIsLoading] = useState(false)
// Indicator for whether the first message is outputted
const [isOutputted, setIsOutputted] = useState(false)
const [error, setError] = useState<string | null>(null)
const { quickAssistantId } = useAppSelector((state) => state.llm)
const { assistant: currentAssistant } = useAssistant(quickAssistantId)
const currentTopic = useRef<Topic>(getDefaultTopic(currentAssistant.id))
const currentAskId = useRef('')
const inputBarRef = useRef<HTMLDivElement>(null)
const featureMenusRef = useRef<FeatureMenusRef>(null)
const referenceText = useMemo(() => clipboardText || userInputText, [clipboardText, userInputText])
const userContent = useMemo(() => {
if (isFirstMessage) {
return referenceText === userInputText ? userInputText : `${referenceText}\n\n${userInputText}`.trim()
}
return userInputText.trim()
}, [isFirstMessage, referenceText, userInputText])
useEffect(() => {
i18n.changeLanguage(language || navigator.language || defaultLanguage)
}, [language])
// Reset state when switching to home route
useEffect(() => {
if (route === 'home') {
setIsFirstMessage(true)
setError(null)
}
}, [route])
const focusInput = useCallback(() => {
if (inputBarRef.current) {
const input = inputBarRef.current.querySelector('input')
if (input) {
input.focus()
}
}
}, [])
// Use useCallback with stable dependencies to avoid infinite loops
const readClipboard = useCallback(async () => {
if (!readClipboardAtStartup || !document.hasFocus()) return
try {
const text = await navigator.clipboard.readText()
if (text && text !== lastClipboardTextRef.current) {
lastClipboardTextRef.current = text
setClipboardText(text.trim())
}
} catch (error) {
// Silently handle clipboard read errors (common in some environments)
logger.warn('Failed to read clipboard:', error as Error)
}
}, [readClipboardAtStartup])
const clearClipboard = useCallback(async () => {
setClipboardText('')
lastClipboardTextRef.current = null
focusInput()
}, [focusInput])
const onWindowShow = useCallback(async () => {
featureMenusRef.current?.resetSelectedIndex()
await readClipboard()
focusInput()
}, [readClipboard, focusInput])
useEffect(() => {
window.api.miniWindow.setPin(isPinned)
}, [isPinned])
useEffect(() => {
window.electron.ipcRenderer.on(IpcChannel.ShowMiniWindow, onWindowShow)
return () => {
window.electron.ipcRenderer.removeAllListeners(IpcChannel.ShowMiniWindow)
}
}, [onWindowShow])
useEffect(() => {
readClipboard()
}, [readClipboard])
const handleCloseWindow = useCallback(() => window.api.miniWindow.hide(), [])
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
// 使用非直接输入法时(例如中文、日文输入法),存在输入法键入过程
// 键入过程不应有任何响应
// 例子,中文输入法候选词过程使用`Enter`直接上屏字母,日文输入法候选词过程使用`Enter`输入假名
// 输入法可以`Esc`终止候选词过程
// 这两个例子的`Enter`和`Esc`快捷助手都不应该响应
if (e.nativeEvent.isComposing || e.key === 'Process') {
return
}
switch (e.code) {
case 'Enter':
case 'NumpadEnter':
{
if (isLoading) return
e.preventDefault()
if (userContent) {
if (route === 'home') {
featureMenusRef.current?.useFeature()
} else {
// Currently text input is only available in 'chat' mode
setRoute('chat')
handleSendMessage()
focusInput()
}
}
}
break
case 'Backspace':
{
if (userInputText.length === 0) {
clearClipboard()
}
}
break
case 'ArrowUp':
{
if (route === 'home') {
e.preventDefault()
featureMenusRef.current?.prevFeature()
}
}
break
case 'ArrowDown':
{
if (route === 'home') {
e.preventDefault()
featureMenusRef.current?.nextFeature()
}
}
break
case 'Escape':
{
handleEsc()
}
break
}
}
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setUserInputText(e.target.value)
}
const handleError = (error: Error) => {
setIsLoading(false)
setError(error.message)
}
const handleSendMessage = useCallback(
async (prompt?: string) => {
if (isEmpty(userContent) || !currentTopic.current) {
return
}
try {
const topicId = currentTopic.current.id
const { message: userMessage, blocks } = getUserMessage({
content: [prompt, userContent].filter(Boolean).join('\n\n'),
assistant: currentAssistant,
topic: currentTopic.current
})
store.dispatch(newMessagesActions.addMessage({ topicId, message: userMessage }))
store.dispatch(upsertManyBlocks(blocks))
const assistantMessage = getAssistantMessage({
assistant: currentAssistant,
topic: currentTopic.current
})
assistantMessage.askId = userMessage.id
currentAskId.current = userMessage.id
store.dispatch(newMessagesActions.addMessage({ topicId, message: assistantMessage }))
const allMessagesForTopic = selectMessagesForTopic(store.getState(), topicId)
const userMessageIndex = allMessagesForTopic.findIndex((m) => m?.id === userMessage.id)
const messagesForContext = allMessagesForTopic
.slice(0, userMessageIndex + 1)
.filter((m) => m && !m.status?.includes('ing'))
let blockId: string | null = null
let thinkingBlockId: string | null = null
setIsLoading(true)
setIsOutputted(false)
setError(null)
setIsFirstMessage(false)
setUserInputText('')
const newAssistant = cloneDeep(currentAssistant)
if (!newAssistant.settings) {
newAssistant.settings = {}
}
newAssistant.settings.streamOutput = true
// 显式关闭这些功能
newAssistant.webSearchProviderId = undefined
newAssistant.mcpServers = undefined
newAssistant.knowledge_bases = undefined
// replace prompt vars
newAssistant.prompt = await replacePromptVariables(currentAssistant.prompt, currentAssistant?.model.name)
// logger.debug('newAssistant', newAssistant)
const { modelMessages, uiMessages } = await ConversationService.prepareMessagesForModel(
messagesForContext,
newAssistant
)
await fetchChatCompletion({
messages: modelMessages,
assistant: newAssistant,
options: {},
topicId,
uiMessages: uiMessages,
onChunkReceived: (chunk: Chunk) => {
switch (chunk.type) {
case ChunkType.THINKING_START:
{
setIsOutputted(true)
if (thinkingBlockId) {
store.dispatch(
updateOneBlock({ id: thinkingBlockId, changes: { status: MessageBlockStatus.STREAMING } })
)
} else {
const block = createThinkingBlock(assistantMessage.id, '', {
status: MessageBlockStatus.STREAMING
})
thinkingBlockId = block.id
store.dispatch(
newMessagesActions.updateMessage({
topicId,
messageId: assistantMessage.id,
updates: { blockInstruction: { id: block.id } }
})
)
store.dispatch(upsertOneBlock(block))
}
}
break
case ChunkType.THINKING_DELTA:
{
setIsOutputted(true)
if (thinkingBlockId) {
throttledBlockUpdate(thinkingBlockId, {
content: chunk.text,
thinking_millsec: chunk.thinking_millsec
})
}
}
break
case ChunkType.THINKING_COMPLETE:
{
if (thinkingBlockId) {
cancelThrottledBlockUpdate(thinkingBlockId)
store.dispatch(
updateOneBlock({
id: thinkingBlockId,
changes: { status: MessageBlockStatus.SUCCESS, thinking_millsec: chunk.thinking_millsec }
})
)
}
}
break
case ChunkType.TEXT_START:
{
setIsOutputted(true)
if (blockId) {
store.dispatch(updateOneBlock({ id: blockId, changes: { status: MessageBlockStatus.STREAMING } }))
} else {
const block = createMainTextBlock(assistantMessage.id, '', {
status: MessageBlockStatus.STREAMING
})
blockId = block.id
store.dispatch(
newMessagesActions.updateMessage({
topicId,
messageId: assistantMessage.id,
updates: { blockInstruction: { id: block.id } }
})
)
store.dispatch(upsertOneBlock(block))
}
}
break
case ChunkType.TEXT_DELTA:
{
setIsOutputted(true)
if (blockId) {
throttledBlockUpdate(blockId, { content: chunk.text })
}
}
break
case ChunkType.TEXT_COMPLETE:
{
if (blockId) {
cancelThrottledBlockUpdate(blockId)
store.dispatch(
updateOneBlock({
id: blockId,
changes: { content: chunk.text, status: MessageBlockStatus.SUCCESS }
})
)
}
}
break
case ChunkType.ERROR: {
//stop the thinking timer
const isAborted = isAbortError(chunk.error)
const possibleBlockId = thinkingBlockId || blockId
if (possibleBlockId) {
store.dispatch(
updateOneBlock({
id: possibleBlockId,
changes: {
status: isAborted ? MessageBlockStatus.PAUSED : MessageBlockStatus.ERROR
}
})
)
store.dispatch(
newMessagesActions.updateMessage({
topicId,
messageId: assistantMessage.id,
updates: {
status: isAborted ? AssistantMessageStatus.PAUSED : AssistantMessageStatus.SUCCESS
}
})
)
}
if (!isAborted) {
throw new Error(chunk.error.message)
}
}
//fall through
case ChunkType.BLOCK_COMPLETE:
setIsLoading(false)
setIsOutputted(true)
currentAskId.current = ''
store.dispatch(
newMessagesActions.updateMessage({
topicId,
messageId: assistantMessage.id,
updates: { status: AssistantMessageStatus.SUCCESS }
})
)
break
}
}
})
} catch (err) {
if (isAbortError(err)) return
handleError(err instanceof Error ? err : new Error('An error occurred'))
logger.error('Error fetching result:', err as Error)
} finally {
setIsLoading(false)
setIsOutputted(true)
currentAskId.current = ''
}
},
[userContent, currentAssistant]
)
const handlePause = useCallback(() => {
if (currentAskId.current) {
abortCompletion(currentAskId.current)
setIsLoading(false)
setIsOutputted(true)
currentAskId.current = ''
}
}, [])
const handleEsc = useCallback(() => {
if (isLoading) {
handlePause()
} else {
if (route === 'home') {
handleCloseWindow()
} else {
// Clear the topic messages to reduce memory usage
if (currentTopic.current) {
store.dispatch(newMessagesActions.clearTopicMessages(currentTopic.current.id))
}
// Reset the topic
currentTopic.current = getDefaultTopic(currentAssistant.id)
setError(null)
setRoute('home')
setUserInputText('')
}
}
}, [isLoading, route, handleCloseWindow, currentAssistant.id, handlePause])
const handleCopy = useCallback(() => {
if (!currentTopic.current) return
const messages = selectMessagesForTopic(store.getState(), currentTopic.current.id)
const lastMessage = last(messages)
if (lastMessage) {
const content = getMainTextContent(lastMessage)
navigator.clipboard.writeText(content)
window.toast.success(t('message.copy.success'))
}
}, [currentTopic, t])
const backgroundColor = useMemo(() => {
// ONLY MAC: when transparent style + light theme: use vibrancy effect
// because the dark style under mac's vibrancy effect has not been implemented
if (isMac && windowStyle === 'transparent' && theme === ThemeMode.light) {
return 'transparent'
}
return 'var(--color-background)'
}, [windowStyle, theme])
// Memoize placeholder text
const inputPlaceholder = useMemo(() => {
if (referenceText && route === 'home') {
return t('miniwindow.input.placeholder.title')
}
return t('miniwindow.input.placeholder.empty', {
model: quickAssistantId ? currentAssistant.name : currentAssistant.model.name
})
}, [referenceText, route, t, quickAssistantId, currentAssistant])
// Memoize footer props
const baseFooterProps = useMemo(
() => ({
route,
loading: isLoading,
onEsc: handleEsc,
setIsPinned,
isPinned
}),
[route, isLoading, handleEsc, isPinned]
)
switch (route) {
case 'chat':
case 'summary':
case 'explanation':
return (
<Container style={{ backgroundColor }} $draggable={draggable}>
{route === 'chat' && (
<>
<InputBar
text={userInputText}
assistant={currentAssistant}
referenceText={referenceText}
placeholder={inputPlaceholder}
loading={isLoading}
handleKeyDown={handleKeyDown}
handleChange={handleChange}
ref={inputBarRef}
/>
<Divider style={{ margin: '10px 0' }} />
</>
)}
{['summary', 'explanation'].includes(route) && (
<div style={{ marginTop: 10 }}>
<ClipboardPreview referenceText={referenceText} clearClipboard={clearClipboard} t={t} />
</div>
)}
<ChatWindow
route={route}
assistant={currentAssistant}
topic={currentTopic.current}
isOutputted={isOutputted}
/>
{error && <ErrorMsg>{error}</ErrorMsg>}
<Divider style={{ margin: '10px 0' }} />
<Footer key="footer" {...baseFooterProps} onCopy={handleCopy} />
</Container>
)
case 'translate':
return (
<Container style={{ backgroundColor }} $draggable={draggable}>
<TranslateWindow text={referenceText} />
<Divider style={{ margin: '10px 0' }} />
<Footer key="footer" {...baseFooterProps} />
</Container>
)
// Home
default:
return (
<Container style={{ backgroundColor }} $draggable={draggable}>
<InputBar
text={userInputText}
assistant={currentAssistant}
referenceText={referenceText}
placeholder={inputPlaceholder}
loading={isLoading}
handleKeyDown={handleKeyDown}
handleChange={handleChange}
ref={inputBarRef}
/>
<Divider style={{ margin: '10px 0' }} />
<ClipboardPreview referenceText={referenceText} clearClipboard={clearClipboard} t={t} />
<Main>
<FeatureMenus
setRoute={setRoute}
onSendMessage={handleSendMessage}
text={userContent}
ref={featureMenusRef}
/>
</Main>
<Divider style={{ margin: '10px 0' }} />
<Footer
key="footer"
{...baseFooterProps}
canUseBackspace={userInputText.length > 0 || clipboardText.length === 0}
clearClipboard={clearClipboard}
/>
</Container>
)
}
}
const Container = styled.div<{ $draggable: boolean }>`
display: flex;
flex: 1;
height: 100%;
width: 100%;
flex-direction: column;
-webkit-app-region: ${({ $draggable }) => ($draggable ? 'drag' : 'no-drag')};
padding: 8px 10px;
`
const Main = styled.main`
display: flex;
flex-direction: column;
flex: 1;
overflow: hidden;
`
const ErrorMsg = styled.div`
color: var(--color-error);
background: rgba(255, 0, 0, 0.15);
border: 1px solid var(--color-error);
padding: 8px 12px;
border-radius: 4px;
margin-bottom: 12px;
font-size: 13px;
word-break: break-all;
`
export default HomeWindow