Compare commits
3 Commits
fix/quick-
...
refactor/m
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e845d8194a | ||
|
|
68c1a3e1cc | ||
|
|
8459e53e39 |
@@ -2863,10 +2863,10 @@ export const findTokenLimit = (modelId: string): { min: number; max: number } |
|
||||
|
||||
// Doubao 支持思考模式的模型正则
|
||||
export const DOUBAO_THINKING_MODEL_REGEX =
|
||||
/doubao-(?:1(\.|-5)-thinking-vision-pro|1(\.|-)5-thinking-pro-m|seed-1\.6|seed-1\.6-flash)(?:-[\\w-]+)?/i
|
||||
/doubao-(?:1[.-]5-thinking-vision-pro|1[.-]5-thinking-pro-m|seed-1[.-]6(?:-flash)?)(?:-[\w-]+)?/i
|
||||
|
||||
// 支持 auto 的 Doubao 模型
|
||||
export const DOUBAO_THINKING_AUTO_MODEL_REGEX = /doubao-(?:1-5-thinking-pro-m|seed-1.6)(?:-[\\w-]+)?/i
|
||||
// 支持 auto 的 Doubao 模型 doubao-seed-1.6-xxx doubao-seed-1-6-xxx doubao-1-5-thinking-pro-m-xxx
|
||||
export const DOUBAO_THINKING_AUTO_MODEL_REGEX = /doubao-(1-5-thinking-pro-m|seed-1\.6|seed-1-6-[\w-]+)(?:-[\w-]+)*/i
|
||||
|
||||
export function isDoubaoThinkingAutoModel(model: Model): boolean {
|
||||
return DOUBAO_THINKING_AUTO_MODEL_REGEX.test(model.id)
|
||||
|
||||
@@ -755,8 +755,7 @@
|
||||
"backspace_clear": "Backspace to clear",
|
||||
"esc": "ESC to {{action}}",
|
||||
"esc_back": "return",
|
||||
"esc_close": "close",
|
||||
"esc_pause": "pause"
|
||||
"esc_close": "close"
|
||||
},
|
||||
"input": {
|
||||
"placeholder": {
|
||||
@@ -1105,7 +1104,8 @@
|
||||
"obsidian": "Export to Obsidian",
|
||||
"siyuan": "Export to SiYuan Note",
|
||||
"joplin": "Export to Joplin",
|
||||
"docx": "Export as Word"
|
||||
"docx": "Export as Word",
|
||||
"plain_text": "Copy as Plain Text"
|
||||
},
|
||||
"joplin": {
|
||||
"check": {
|
||||
|
||||
@@ -752,11 +752,10 @@
|
||||
},
|
||||
"footer": {
|
||||
"copy_last_message": "C キーを押してコピー",
|
||||
"backspace_clear": "バックスペースを押してクリアします",
|
||||
"esc": "ESC キーを押して{{action}}",
|
||||
"esc_back": "戻る",
|
||||
"esc_close": "ウィンドウを閉じる",
|
||||
"esc_pause": "一時停止"
|
||||
"backspace_clear": "バックスペースを押してクリアします"
|
||||
},
|
||||
"input": {
|
||||
"placeholder": {
|
||||
@@ -1103,7 +1102,8 @@
|
||||
"obsidian": "Obsidianにエクスポート",
|
||||
"siyuan": "思源ノートにエクスポート",
|
||||
"joplin": "Joplinにエクスポート",
|
||||
"docx": "Wordとしてエクスポート"
|
||||
"docx": "Wordとしてエクスポート",
|
||||
"plain_text": "プレーンテキストとしてコピー"
|
||||
},
|
||||
"joplin": {
|
||||
"check": {
|
||||
|
||||
@@ -752,11 +752,10 @@
|
||||
},
|
||||
"footer": {
|
||||
"copy_last_message": "Нажмите C для копирования",
|
||||
"backspace_clear": "Нажмите Backspace, чтобы очистить",
|
||||
"esc": "Нажмите ESC {{action}}",
|
||||
"esc_back": "возвращения",
|
||||
"esc_close": "закрытия окна",
|
||||
"esc_pause": "пауза"
|
||||
"backspace_clear": "Нажмите Backspace, чтобы очистить"
|
||||
},
|
||||
"input": {
|
||||
"placeholder": {
|
||||
@@ -1102,7 +1101,8 @@
|
||||
"obsidian": "Экспорт в Obsidian",
|
||||
"siyuan": "Экспорт в SiYuan Note",
|
||||
"joplin": "Экспорт в Joplin",
|
||||
"docx": "Экспорт в Word"
|
||||
"docx": "Экспорт в Word",
|
||||
"plain_text": "Копировать как чистый текст"
|
||||
},
|
||||
"joplin": {
|
||||
"check": {
|
||||
|
||||
@@ -755,8 +755,7 @@
|
||||
"backspace_clear": "按 Backspace 清空",
|
||||
"esc": "按 ESC {{action}}",
|
||||
"esc_back": "返回",
|
||||
"esc_close": "关闭",
|
||||
"esc_pause": "暂停"
|
||||
"esc_close": "关闭"
|
||||
},
|
||||
"input": {
|
||||
"placeholder": {
|
||||
@@ -1105,7 +1104,8 @@
|
||||
"obsidian": "导出到Obsidian",
|
||||
"siyuan": "导出到思源笔记",
|
||||
"joplin": "导出到Joplin",
|
||||
"docx": "导出为Word"
|
||||
"docx": "导出为Word",
|
||||
"plain_text": "复制为纯文本"
|
||||
},
|
||||
"joplin": {
|
||||
"check": {
|
||||
|
||||
@@ -752,11 +752,10 @@
|
||||
},
|
||||
"footer": {
|
||||
"copy_last_message": "按 C 鍵複製",
|
||||
"backspace_clear": "按 Backspace 清空",
|
||||
"esc": "按 ESC {{action}}",
|
||||
"esc_back": "返回",
|
||||
"esc_close": "關閉視窗",
|
||||
"esc_pause": "暫停"
|
||||
"backspace_clear": "按 Backspace 清空"
|
||||
},
|
||||
"input": {
|
||||
"placeholder": {
|
||||
@@ -1105,7 +1104,8 @@
|
||||
"obsidian": "匯出到Obsidian",
|
||||
"siyuan": "匯出到思源筆記",
|
||||
"joplin": "匯出到Joplin",
|
||||
"docx": "匯出為Word"
|
||||
"docx": "匯出為Word",
|
||||
"plain_text": "複製為純文本"
|
||||
},
|
||||
"joplin": {
|
||||
"check": {
|
||||
|
||||
@@ -205,7 +205,7 @@ const MessageMenubar: FC<Props> = (props) => {
|
||||
key: 'export',
|
||||
icon: <Share size={16} color="var(--color-icon)" style={{ marginTop: 3 }} />,
|
||||
children: [
|
||||
{
|
||||
exportMenuOptions.plain_text && {
|
||||
label: t('chat.topics.copy.plain_text'),
|
||||
key: 'copy_message_plain_text',
|
||||
onClick: () => copyMessageAsPlainText(message)
|
||||
|
||||
@@ -84,6 +84,16 @@ const ExportMenuOptions: FC = () => {
|
||||
<SettingRowTitle>{t('settings.data.export_menu.docx')}</SettingRowTitle>
|
||||
<Switch checked={exportMenuOptions.docx} onChange={(checked) => handleToggleOption('docx', checked)} />
|
||||
</SettingRow>
|
||||
|
||||
<SettingDivider />
|
||||
|
||||
<SettingRow>
|
||||
<SettingRowTitle>{t('settings.data.export_menu.plain_text')}</SettingRowTitle>
|
||||
<Switch
|
||||
checked={exportMenuOptions.plain_text}
|
||||
onChange={(checked) => handleToggleOption('plain_text', checked)}
|
||||
/>
|
||||
</SettingRow>
|
||||
</SettingGroup>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -170,7 +170,7 @@ const ModelSettings: FC = () => {
|
||||
<HStack alignItems="center" gap={0}>
|
||||
<StyledButton
|
||||
type={!quickAssistantId ? 'primary' : 'default'}
|
||||
onClick={() => dispatch(setQuickAssistantId(''))}
|
||||
onClick={() => dispatch(setQuickAssistantId(null))}
|
||||
selected={!quickAssistantId}>
|
||||
{t('settings.models.use_model')}
|
||||
</StyledButton>
|
||||
@@ -188,29 +188,22 @@ const ModelSettings: FC = () => {
|
||||
{!quickAssistantId ? null : (
|
||||
<HStack alignItems="center" style={{ marginTop: 12 }}>
|
||||
<Select
|
||||
value={quickAssistantId || defaultAssistant.id}
|
||||
value={quickAssistantId}
|
||||
style={{ width: 360 }}
|
||||
onChange={(value) => dispatch(setQuickAssistantId(value))}
|
||||
placeholder={t('settings.models.quick_assistant_selection')}>
|
||||
<Select.Option key={defaultAssistant.id} value={defaultAssistant.id}>
|
||||
<AssistantItem>
|
||||
<ModelAvatar model={defaultAssistant.model || defaultModel} size={18} />
|
||||
<AssistantName>{defaultAssistant.name}</AssistantName>
|
||||
<Spacer />
|
||||
<DefaultTag isCurrent={true}>{t('settings.models.quick_assistant_default_tag')}</DefaultTag>
|
||||
</AssistantItem>
|
||||
</Select.Option>
|
||||
{assistants
|
||||
.filter((a) => a.id !== defaultAssistant.id)
|
||||
.map((a) => (
|
||||
<Select.Option key={a.id} value={a.id}>
|
||||
<AssistantItem>
|
||||
<ModelAvatar model={a.model || defaultModel} size={18} />
|
||||
<AssistantName>{a.name}</AssistantName>
|
||||
<Spacer />
|
||||
</AssistantItem>
|
||||
</Select.Option>
|
||||
))}
|
||||
{assistants.map((a) => (
|
||||
<Select.Option key={a.id} value={a.id}>
|
||||
<AssistantItem>
|
||||
<ModelAvatar model={a.model || defaultModel} size={18} />
|
||||
<AssistantName>{a.name}</AssistantName>
|
||||
<Spacer />
|
||||
{a.id === defaultAssistant.id && (
|
||||
<DefaultTag isCurrent={true}>{t('settings.models.quick_assistant_default_tag')}</DefaultTag>
|
||||
)}
|
||||
</AssistantItem>
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
</HStack>
|
||||
)}
|
||||
|
||||
@@ -29,7 +29,7 @@ export interface LlmState {
|
||||
defaultModel: Model
|
||||
topicNamingModel: Model
|
||||
translateModel: Model
|
||||
quickAssistantId: string
|
||||
quickAssistantId: string | null
|
||||
settings: LlmSettings
|
||||
}
|
||||
|
||||
@@ -534,7 +534,7 @@ export const initialState: LlmState = {
|
||||
defaultModel: SYSTEM_MODELS.defaultModel[0],
|
||||
topicNamingModel: SYSTEM_MODELS.defaultModel[1],
|
||||
translateModel: SYSTEM_MODELS.defaultModel[2],
|
||||
quickAssistantId: '',
|
||||
quickAssistantId: null,
|
||||
providers: INITIAL_PROVIDERS,
|
||||
settings: {
|
||||
ollama: {
|
||||
@@ -650,7 +650,7 @@ const llmSlice = createSlice({
|
||||
state.translateModel = action.payload.model
|
||||
},
|
||||
|
||||
setQuickAssistantId: (state, action: PayloadAction<string>) => {
|
||||
setQuickAssistantId: (state, action: PayloadAction<string | null>) => {
|
||||
state.quickAssistantId = action.payload
|
||||
},
|
||||
setOllamaKeepAliveTime: (state, action: PayloadAction<number>) => {
|
||||
|
||||
@@ -1596,6 +1596,18 @@ const migrateConfig = {
|
||||
} catch (error) {
|
||||
return state
|
||||
}
|
||||
},
|
||||
'114': (state: RootState) => {
|
||||
try {
|
||||
if (state.settings && state.settings.exportMenuOptions) {
|
||||
if (typeof state.settings.exportMenuOptions.plain_text === 'undefined') {
|
||||
state.settings.exportMenuOptions.plain_text = true
|
||||
}
|
||||
}
|
||||
return state
|
||||
} catch (error) {
|
||||
return state
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -167,6 +167,7 @@ export interface SettingsState {
|
||||
obsidian: boolean
|
||||
siyuan: boolean
|
||||
docx: boolean
|
||||
plain_text: boolean
|
||||
}
|
||||
// OpenAI
|
||||
openAI: {
|
||||
@@ -308,7 +309,8 @@ export const initialState: SettingsState = {
|
||||
joplin: true,
|
||||
obsidian: true,
|
||||
siyuan: true,
|
||||
docx: true
|
||||
docx: true,
|
||||
plain_text: true
|
||||
},
|
||||
// OpenAI
|
||||
openAI: {
|
||||
|
||||
222
src/renderer/src/types/model.ts
Normal file
222
src/renderer/src/types/model.ts
Normal file
@@ -0,0 +1,222 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
export const InputType = z.enum(['text', 'image', 'audio', 'video', 'document'])
|
||||
export type InputType = z.infer<typeof InputType>
|
||||
|
||||
export const OutputType = z.enum(['text', 'image', 'audio', 'video', 'vector'])
|
||||
export type OutputType = z.infer<typeof OutputType>
|
||||
|
||||
export const OutputMode = z.enum(['sync', 'streaming'])
|
||||
export type OutputMode = z.infer<typeof OutputMode>
|
||||
|
||||
export const ModelCapability = z.enum([
|
||||
'audioGeneration',
|
||||
'cache',
|
||||
'codeExecution',
|
||||
'embedding',
|
||||
'fineTuning',
|
||||
'imageGeneration',
|
||||
'OCR',
|
||||
'realTime',
|
||||
'rerank',
|
||||
'reasoning',
|
||||
'streaming',
|
||||
'structuredOutput',
|
||||
'textGeneration',
|
||||
'translation',
|
||||
'transcription',
|
||||
'toolUse',
|
||||
'videoGeneration',
|
||||
'webSearch'
|
||||
])
|
||||
export type ModelCapability = z.infer<typeof ModelCapability>
|
||||
|
||||
export const ModelSchema = z
|
||||
.object({
|
||||
id: z.string(),
|
||||
modelId: z.string(),
|
||||
providerId: z.string(),
|
||||
name: z.string(),
|
||||
group: z.string(),
|
||||
description: z.string().optional(),
|
||||
owned_by: z.string().optional(),
|
||||
|
||||
supportedInputs: z.array(InputType),
|
||||
supportedOutputs: z.array(OutputType),
|
||||
supportedOutputModes: z.array(OutputMode),
|
||||
|
||||
limits: z
|
||||
.object({
|
||||
inputTokenLimit: z.number().optional(),
|
||||
outputTokenLimit: z.number().optional(),
|
||||
contextWindow: z.number().optional()
|
||||
})
|
||||
.optional(),
|
||||
|
||||
price: z
|
||||
.object({
|
||||
inputTokenPrice: z.number().optional(),
|
||||
outputTokenPrice: z.number().optional()
|
||||
})
|
||||
.optional(),
|
||||
|
||||
capabilities: z.array(ModelCapability)
|
||||
})
|
||||
.refine(
|
||||
(data) => {
|
||||
// 如果模型支持streaming,则必须支持streamingOutputMode
|
||||
if (data.capabilities.includes('streaming') && !data.supportedOutputModes.includes('streaming')) {
|
||||
return false
|
||||
}
|
||||
|
||||
// 如果模型有OCR能力,则必须支持图像输入类型或者文件输入类型
|
||||
if (
|
||||
data.capabilities.includes('OCR') &&
|
||||
!data.supportedInputs.includes('image') &&
|
||||
!data.supportedInputs.includes('document')
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
// 如果模型有图像生成能力,则必须支持图像输出
|
||||
if (data.capabilities.includes('imageGeneration') && !data.supportedOutputs.includes('image')) {
|
||||
return false
|
||||
}
|
||||
|
||||
// 如果有音频生成能力,则必须支持音频输出类型
|
||||
if (data.capabilities.includes('audioGeneration') && !data.supportedOutputs.includes('audio')) {
|
||||
return false
|
||||
}
|
||||
|
||||
// 如果有音频识别能力,则必须支持音频输入类型
|
||||
if (
|
||||
(data.capabilities.includes('transcription') || data.capabilities.includes('translation')) &&
|
||||
!data.supportedInputs.includes('audio')
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
// 如果有视频生成能力,则必须支持视频输出类型
|
||||
if (data.capabilities.includes('videoGeneration') && !data.supportedOutputs.includes('video')) {
|
||||
return false
|
||||
}
|
||||
|
||||
// 如果模型有embedding能力,则必须支持向量输出类型
|
||||
if (data.capabilities.includes('embedding') && !data.supportedOutputs.includes('vector')) {
|
||||
return false
|
||||
}
|
||||
|
||||
// 如果模型有toolUse, Reasoning, streaming, cache, codeExecution, imageGeneration, audioGeneration, videoGeneration, webSearch能力,则必须支持文字的输入
|
||||
if (
|
||||
(data.capabilities.includes('toolUse') ||
|
||||
data.capabilities.includes('reasoning') ||
|
||||
data.capabilities.includes('streaming') ||
|
||||
data.capabilities.includes('cache') ||
|
||||
data.capabilities.includes('codeExecution') ||
|
||||
data.capabilities.includes('imageGeneration') ||
|
||||
data.capabilities.includes('audioGeneration') ||
|
||||
data.capabilities.includes('videoGeneration') ||
|
||||
data.capabilities.includes('webSearch')) &&
|
||||
!data.supportedInputs.includes('text')
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
// 如果模型有toolUse, Reasoning, streaming, cache, codeExecution, OCR, textGeneration, translation, transcription, webSearch, structuredOutput能力,则必须支持文字的输出
|
||||
if (
|
||||
(data.capabilities.includes('toolUse') ||
|
||||
data.capabilities.includes('reasoning') ||
|
||||
data.capabilities.includes('streaming') ||
|
||||
data.capabilities.includes('cache') ||
|
||||
data.capabilities.includes('codeExecution') ||
|
||||
data.capabilities.includes('OCR') ||
|
||||
data.capabilities.includes('textGeneration') ||
|
||||
data.capabilities.includes('translation') ||
|
||||
data.capabilities.includes('transcription') ||
|
||||
data.capabilities.includes('webSearch') ||
|
||||
data.capabilities.includes('structuredOutput')) &&
|
||||
!data.supportedOutputs.includes('text')
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
},
|
||||
{
|
||||
message: 'ModelCard has inconsistent capabilities and supported input/output type'
|
||||
}
|
||||
)
|
||||
|
||||
export type ModelCard = z.infer<typeof ModelSchema>
|
||||
|
||||
export function createModelCard(model: ModelCard): ModelCard {
|
||||
return ModelSchema.parse(model)
|
||||
}
|
||||
|
||||
export function supportesInputType(model: ModelCard, inputType: InputType) {
|
||||
return model.supportedInputs.includes(inputType)
|
||||
}
|
||||
|
||||
export function supportesOutputType(model: ModelCard, outputType: OutputType) {
|
||||
return model.supportedOutputs.includes(outputType)
|
||||
}
|
||||
|
||||
export function supportesOutputMode(model: ModelCard, outputMode: OutputMode) {
|
||||
return model.supportedOutputModes.includes(outputMode)
|
||||
}
|
||||
|
||||
export function supportesCapability(model: ModelCard, capability: ModelCapability) {
|
||||
return model.capabilities.includes(capability)
|
||||
}
|
||||
|
||||
export function isVisionModel(model: ModelCard) {
|
||||
return supportesInputType(model, 'image')
|
||||
}
|
||||
|
||||
export function isImageGenerationModel(model: ModelCard) {
|
||||
return isVisionModel(model) && supportesCapability(model, 'imageGeneration')
|
||||
}
|
||||
|
||||
export function isAudioModel(model: ModelCard) {
|
||||
return supportesInputType(model, 'audio')
|
||||
}
|
||||
|
||||
export function isAudioGenerationModel(model: ModelCard) {
|
||||
return supportesCapability(model, 'audioGeneration')
|
||||
}
|
||||
|
||||
export function isVideoModel(model: ModelCard) {
|
||||
return supportesInputType(model, 'video')
|
||||
}
|
||||
|
||||
export function isEmbedModel(model: ModelCard) {
|
||||
return supportesOutputType(model, 'vector') && supportesCapability(model, 'embedding')
|
||||
}
|
||||
|
||||
export function isTextEmbeddingModel(model: ModelCard) {
|
||||
return isEmbedModel(model) && supportesInputType(model, 'text') && model.supportedInputs.length === 1
|
||||
}
|
||||
|
||||
export function isMultiModalEmbeddingModel(model: ModelCard) {
|
||||
return isEmbedModel(model) && model.supportedInputs.length > 1
|
||||
}
|
||||
|
||||
export function isRerankModel(model: ModelCard) {
|
||||
return supportesCapability(model, 'rerank')
|
||||
}
|
||||
|
||||
export function isReasoningModel(model: ModelCard) {
|
||||
return supportesCapability(model, 'reasoning')
|
||||
}
|
||||
|
||||
export function isToolUseModel(model: ModelCard) {
|
||||
return supportesCapability(model, 'toolUse')
|
||||
}
|
||||
|
||||
export function isOnlyStreamingModel(model: ModelCard) {
|
||||
return (
|
||||
supportesCapability(model, 'streaming') &&
|
||||
supportesOutputMode(model, 'streaming') &&
|
||||
model.supportedOutputModes.length === 1
|
||||
)
|
||||
}
|
||||
@@ -1,22 +1,18 @@
|
||||
import Scrollbar from '@renderer/components/Scrollbar'
|
||||
import { Assistant, Topic } from '@renderer/types'
|
||||
import { Assistant } from '@renderer/types'
|
||||
import { FC } from 'react'
|
||||
import styled from 'styled-components'
|
||||
|
||||
import Messages from './components/Messages'
|
||||
interface Props {
|
||||
route: string
|
||||
assistant: Assistant | null
|
||||
topic: Topic | null
|
||||
isOutputted: boolean
|
||||
assistant: Assistant
|
||||
}
|
||||
|
||||
const ChatWindow: FC<Props> = ({ route, assistant, topic, isOutputted }) => {
|
||||
if (!assistant || !topic) return null
|
||||
|
||||
const ChatWindow: FC<Props> = ({ route, assistant }) => {
|
||||
return (
|
||||
<Main className="bubble">
|
||||
<Messages assistant={assistant} topic={topic} route={route} isOutputted={isOutputted} />
|
||||
<Messages assistant={{ ...assistant }} route={route} />
|
||||
</Main>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { LoadingOutlined } from '@ant-design/icons'
|
||||
import Scrollbar from '@renderer/components/Scrollbar'
|
||||
import { useTopicMessages } from '@renderer/hooks/useMessageOperations'
|
||||
import { Assistant, Topic } from '@renderer/types'
|
||||
import { Assistant } from '@renderer/types'
|
||||
import { getMainTextContent } from '@renderer/utils/messageUtils/find'
|
||||
import { last } from 'lodash'
|
||||
import { FC } from 'react'
|
||||
import { FC, useRef } from 'react'
|
||||
import { useHotkeys } from 'react-hotkeys-hook'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
@@ -13,19 +12,40 @@ import MessageItem from './Message'
|
||||
|
||||
interface Props {
|
||||
assistant: Assistant
|
||||
topic: Topic
|
||||
route: string
|
||||
isOutputted: boolean
|
||||
}
|
||||
|
||||
interface ContainerProps {
|
||||
right?: boolean
|
||||
}
|
||||
|
||||
const Messages: FC<Props> = ({ assistant, topic, route, isOutputted }) => {
|
||||
const messages = useTopicMessages(topic.id)
|
||||
const Messages: FC<Props> = ({ assistant, route }) => {
|
||||
// const [messages, setMessages] = useState<Message[]>([])
|
||||
const messages = useTopicMessages(assistant.topics[0].id)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const messagesRef = useRef(messages)
|
||||
|
||||
const { t } = useTranslation()
|
||||
|
||||
messagesRef.current = messages
|
||||
|
||||
// const onSendMessage = useCallback(
|
||||
// async (message: Message) => {
|
||||
// setMessages((prev) => {
|
||||
// const assistantMessage = getAssistantMessage({ assistant, topic: assistant.topics[0] })
|
||||
// store.dispatch(newMessagesActions.addMessage({ topicId: assistant.topics[0].id, message: assistantMessage }))
|
||||
// const messages = prev.concat([message, assistantMessage])
|
||||
// return messages
|
||||
// })
|
||||
// },
|
||||
// [assistant]
|
||||
// )
|
||||
|
||||
// useEffect(() => {
|
||||
// const unsubscribes = [EventEmitter.on(EVENT_NAMES.SEND_MESSAGE, onSendMessage)]
|
||||
// return () => unsubscribes.forEach((unsub) => unsub())
|
||||
// }, [assistant.id])
|
||||
|
||||
useHotkeys('c', () => {
|
||||
const lastMessage = last(messages)
|
||||
if (lastMessage) {
|
||||
@@ -35,8 +55,7 @@ const Messages: FC<Props> = ({ assistant, topic, route, isOutputted }) => {
|
||||
}
|
||||
})
|
||||
return (
|
||||
<Container id="messages" key={assistant.id}>
|
||||
{!isOutputted && <LoadingOutlined style={{ fontSize: 16 }} spin />}
|
||||
<Container id="messages" key={assistant.id} ref={containerRef}>
|
||||
{[...messages].reverse().map((message, index) => (
|
||||
<MessageItem key={message.id} message={message} index={index} total={messages.length} route={route} />
|
||||
))}
|
||||
|
||||
@@ -1,32 +1,27 @@
|
||||
import { isMac } from '@renderer/config/constant'
|
||||
import { useTheme } from '@renderer/context/ThemeProvider'
|
||||
import { useDefaultAssistant, useDefaultModel } from '@renderer/hooks/useAssistant'
|
||||
import { useSettings } from '@renderer/hooks/useSettings'
|
||||
import i18n from '@renderer/i18n'
|
||||
import { fetchChatCompletion } from '@renderer/services/ApiService'
|
||||
import {
|
||||
getAssistantById,
|
||||
getDefaultAssistant,
|
||||
getDefaultModel,
|
||||
getDefaultTopic
|
||||
} from '@renderer/services/AssistantService'
|
||||
import { getAssistantById } from '@renderer/services/AssistantService'
|
||||
import { getAssistantMessage, getUserMessage } from '@renderer/services/MessagesService'
|
||||
import store, { useAppSelector } from '@renderer/store'
|
||||
import { upsertManyBlocks } from '@renderer/store/messageBlock'
|
||||
import { updateOneBlock, upsertOneBlock } from '@renderer/store/messageBlock'
|
||||
import { newMessagesActions } from '@renderer/store/newMessage'
|
||||
import { selectMessagesForTopic } from '@renderer/store/newMessage'
|
||||
import { Assistant, ThemeMode, Topic } from '@renderer/types'
|
||||
import { Assistant, ThemeMode } from '@renderer/types'
|
||||
import { Chunk, ChunkType } from '@renderer/types/chunk'
|
||||
import { AssistantMessageStatus } from '@renderer/types/newMessage'
|
||||
import { 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 { createMainTextBlock } from '@renderer/utils/messageUtils/create'
|
||||
import { defaultLanguage } from '@shared/config/constant'
|
||||
import { IpcChannel } from '@shared/IpcChannel'
|
||||
import { Divider } from 'antd'
|
||||
import dayjs from 'dayjs'
|
||||
import { isEmpty } from 'lodash'
|
||||
import React, { FC, useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useHotkeys } from 'react-hotkeys-hook'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
|
||||
@@ -38,63 +33,28 @@ import Footer from './components/Footer'
|
||||
import InputBar from './components/InputBar'
|
||||
|
||||
const HomeWindow: FC = () => {
|
||||
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 [clipboardText, setClipboardText] = useState('')
|
||||
const [selectedText, setSelectedText] = useState('')
|
||||
|
||||
const [userInputText, setUserInputText] = useState('')
|
||||
const [currentAssistant, setCurrentAssistant] = useState<Assistant>({} as Assistant)
|
||||
const [text, setText] = useState('')
|
||||
const [lastClipboardText, setLastClipboardText] = useState<string | null>(null)
|
||||
const textChange = useState(() => {})[1]
|
||||
|
||||
//indicator for loading(thinking/streaming)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
//indicator for wether the first message is outputted
|
||||
const [isOutputted, setIsOutputted] = useState(false)
|
||||
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const { quickAssistantId } = useAppSelector((state) => state.llm)
|
||||
const currentAssistant = useRef<Assistant | null>(null)
|
||||
const currentTopic = useRef<Topic | null>(null)
|
||||
const currentAskId = useRef('')
|
||||
|
||||
const { defaultAssistant } = useDefaultAssistant()
|
||||
const topic = defaultAssistant.topics[0]
|
||||
const { defaultModel } = useDefaultModel()
|
||||
const model = currentAssistant.model || defaultModel
|
||||
const { language, readClipboardAtStartup, windowStyle } = useSettings()
|
||||
const { theme } = useTheme()
|
||||
const { t } = useTranslation()
|
||||
const inputBarRef = useRef<HTMLDivElement>(null)
|
||||
const featureMenusRef = useRef<FeatureMenusRef>(null)
|
||||
const referenceText = selectedText || clipboardText || userInputText
|
||||
const referenceText = selectedText || clipboardText || text
|
||||
|
||||
const content = isFirstMessage
|
||||
? (referenceText === userInputText ? userInputText : `${referenceText}\n\n${userInputText}`).trim()
|
||||
: userInputText.trim()
|
||||
const content = isFirstMessage ? (referenceText === text ? text : `${referenceText}\n\n${text}`).trim() : text.trim()
|
||||
|
||||
//init the assistant and topic
|
||||
useEffect(() => {
|
||||
if (quickAssistantId) {
|
||||
currentAssistant.current = getAssistantById(quickAssistantId) || getDefaultAssistant()
|
||||
} else {
|
||||
currentAssistant.current = getDefaultAssistant()
|
||||
}
|
||||
|
||||
if (!currentAssistant.current?.model) {
|
||||
currentAssistant.current.model = getDefaultModel()
|
||||
}
|
||||
currentTopic.current = getDefaultTopic(currentAssistant.current?.id)
|
||||
}, [quickAssistantId])
|
||||
|
||||
useEffect(() => {
|
||||
i18n.changeLanguage(language || navigator.language || defaultLanguage)
|
||||
}, [language])
|
||||
|
||||
// 当路由为home时,初始化isFirstMessage为true
|
||||
useEffect(() => {
|
||||
if (route === 'home') {
|
||||
setIsFirstMessage(true)
|
||||
}
|
||||
}, [route])
|
||||
const { quickAssistantId } = useAppSelector((state) => state.llm)
|
||||
|
||||
const readClipboard = useCallback(async () => {
|
||||
if (!readClipboardAtStartup) return
|
||||
@@ -106,12 +66,6 @@ const HomeWindow: FC = () => {
|
||||
}
|
||||
}, [readClipboardAtStartup, lastClipboardText])
|
||||
|
||||
const clearClipboard = () => {
|
||||
setClipboardText('')
|
||||
setSelectedText('')
|
||||
focusInput()
|
||||
}
|
||||
|
||||
const focusInput = () => {
|
||||
if (inputBarRef.current) {
|
||||
const input = inputBarRef.current.querySelector('input')
|
||||
@@ -127,19 +81,15 @@ const HomeWindow: FC = () => {
|
||||
focusInput()
|
||||
}, [readClipboard])
|
||||
|
||||
useEffect(() => {
|
||||
window.electron.ipcRenderer.on(IpcChannel.ShowMiniWindow, onWindowShow)
|
||||
|
||||
return () => {
|
||||
window.electron.ipcRenderer.removeAllListeners(IpcChannel.ShowMiniWindow)
|
||||
}
|
||||
}, [onWindowShow])
|
||||
|
||||
useEffect(() => {
|
||||
readClipboard()
|
||||
}, [readClipboard])
|
||||
|
||||
const handleCloseWindow = () => window.api.miniWindow.hide()
|
||||
useEffect(() => {
|
||||
i18n.changeLanguage(language || navigator.language || defaultLanguage)
|
||||
}, [language])
|
||||
|
||||
const onCloseWindow = () => window.api.miniWindow.hide()
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
// 使用非直接输入法时(例如中文、日文输入法),存在输入法键入过程
|
||||
@@ -165,7 +115,7 @@ const HomeWindow: FC = () => {
|
||||
} else {
|
||||
// 目前文本框只在'chat'时可以继续输入,这里相当于 route === 'chat'
|
||||
setRoute('chat')
|
||||
handleSendMessage().then()
|
||||
onSendMessage().then()
|
||||
focusInput()
|
||||
}
|
||||
}
|
||||
@@ -174,7 +124,7 @@ const HomeWindow: FC = () => {
|
||||
case 'Backspace':
|
||||
{
|
||||
textChange(() => {
|
||||
if (userInputText.length === 0) {
|
||||
if (text.length === 0) {
|
||||
clearClipboard()
|
||||
}
|
||||
})
|
||||
@@ -198,233 +148,137 @@ const HomeWindow: FC = () => {
|
||||
break
|
||||
case 'Escape':
|
||||
{
|
||||
handleEsc()
|
||||
setText('')
|
||||
setRoute('home')
|
||||
route === 'home' && onCloseWindow()
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setUserInputText(e.target.value)
|
||||
setText(e.target.value)
|
||||
}
|
||||
|
||||
const handleError = (error: Error) => {
|
||||
setIsLoading(false)
|
||||
setError(error.message)
|
||||
}
|
||||
useEffect(() => {
|
||||
const defaultCurrentAssistant = {
|
||||
...defaultAssistant,
|
||||
model: defaultModel
|
||||
}
|
||||
|
||||
const handleSendMessage = useCallback(
|
||||
if (quickAssistantId) {
|
||||
// 獲取指定助手,如果不存在則使用默認助手
|
||||
const assistantFromId = getAssistantById(quickAssistantId)
|
||||
const currentAssistant = assistantFromId || defaultCurrentAssistant
|
||||
// 如果助手本身沒有設定模型,則使用預設模型
|
||||
if (!currentAssistant.model) {
|
||||
currentAssistant.model = defaultModel
|
||||
}
|
||||
setCurrentAssistant(currentAssistant)
|
||||
} else {
|
||||
setCurrentAssistant(defaultCurrentAssistant)
|
||||
}
|
||||
}, [quickAssistantId, defaultAssistant, defaultModel])
|
||||
|
||||
const onSendMessage = useCallback(
|
||||
async (prompt?: string) => {
|
||||
if (isEmpty(content) || !currentAssistant.current || !currentTopic.current) {
|
||||
if (isEmpty(content)) {
|
||||
return
|
||||
}
|
||||
const topic = currentAssistant.topics[0]
|
||||
const messageParams = {
|
||||
role: 'user',
|
||||
content: [prompt, content].filter(Boolean).join('\n\n'),
|
||||
assistant: currentAssistant,
|
||||
topic,
|
||||
createdAt: dayjs().format('YYYY-MM-DD HH:mm:ss'),
|
||||
status: 'success'
|
||||
}
|
||||
const topicId = topic.id
|
||||
const { message: userMessage, blocks } = getUserMessage(messageParams)
|
||||
|
||||
try {
|
||||
const topicId = currentTopic.current.id
|
||||
store.dispatch(newMessagesActions.addMessage({ topicId, message: userMessage }))
|
||||
store.dispatch(upsertManyBlocks(blocks))
|
||||
|
||||
const { message: userMessage, blocks } = getUserMessage({
|
||||
content: [prompt, content].filter(Boolean).join('\n\n'),
|
||||
assistant: currentAssistant.current,
|
||||
topic: currentTopic.current
|
||||
})
|
||||
const assistant = currentAssistant
|
||||
let blockId: string | null = null
|
||||
let blockContent: string = ''
|
||||
|
||||
store.dispatch(newMessagesActions.addMessage({ topicId, message: userMessage }))
|
||||
store.dispatch(upsertManyBlocks(blocks))
|
||||
const assistantMessage = getAssistantMessage({ assistant, topic: assistant.topics[0] })
|
||||
store.dispatch(newMessagesActions.addMessage({ topicId, message: assistantMessage }))
|
||||
|
||||
const assistantMessage = getAssistantMessage({
|
||||
assistant: currentAssistant.current,
|
||||
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 blockContent: string = ''
|
||||
let thinkingBlockId: string | null = null
|
||||
let thinkingBlockContent: string = ''
|
||||
|
||||
setIsLoading(true)
|
||||
setIsOutputted(false)
|
||||
setError(null)
|
||||
|
||||
setIsFirstMessage(false)
|
||||
setUserInputText('')
|
||||
|
||||
await fetchChatCompletion({
|
||||
messages: messagesForContext,
|
||||
assistant: { ...currentAssistant.current, settings: { streamOutput: true } },
|
||||
onChunkReceived: (chunk: Chunk) => {
|
||||
switch (chunk.type) {
|
||||
case ChunkType.THINKING_DELTA:
|
||||
{
|
||||
thinkingBlockContent += chunk.text
|
||||
setIsOutputted(true)
|
||||
if (!thinkingBlockId) {
|
||||
const block = createThinkingBlock(assistantMessage.id, chunk.text, {
|
||||
status: MessageBlockStatus.STREAMING,
|
||||
thinking_millsec: chunk.thinking_millsec
|
||||
})
|
||||
thinkingBlockId = block.id
|
||||
store.dispatch(
|
||||
newMessagesActions.updateMessage({
|
||||
topicId,
|
||||
messageId: assistantMessage.id,
|
||||
updates: { blockInstruction: { id: block.id } }
|
||||
})
|
||||
)
|
||||
store.dispatch(upsertOneBlock(block))
|
||||
} else {
|
||||
store.dispatch(
|
||||
updateOneBlock({
|
||||
id: thinkingBlockId,
|
||||
changes: { content: thinkingBlockContent, thinking_millsec: chunk.thinking_millsec }
|
||||
})
|
||||
)
|
||||
}
|
||||
}
|
||||
break
|
||||
case ChunkType.THINKING_COMPLETE:
|
||||
{
|
||||
if (thinkingBlockId) {
|
||||
store.dispatch(
|
||||
updateOneBlock({
|
||||
id: thinkingBlockId,
|
||||
changes: { status: MessageBlockStatus.SUCCESS, thinking_millsec: chunk.thinking_millsec }
|
||||
})
|
||||
)
|
||||
}
|
||||
}
|
||||
break
|
||||
case ChunkType.TEXT_DELTA:
|
||||
{
|
||||
blockContent += chunk.text
|
||||
setIsOutputted(true)
|
||||
if (!blockId) {
|
||||
const block = createMainTextBlock(assistantMessage.id, chunk.text, {
|
||||
status: MessageBlockStatus.STREAMING
|
||||
})
|
||||
blockId = block.id
|
||||
store.dispatch(
|
||||
newMessagesActions.updateMessage({
|
||||
topicId,
|
||||
messageId: assistantMessage.id,
|
||||
updates: { blockInstruction: { id: block.id } }
|
||||
})
|
||||
)
|
||||
store.dispatch(upsertOneBlock(block))
|
||||
} else {
|
||||
store.dispatch(updateOneBlock({ id: blockId, changes: { content: blockContent } }))
|
||||
}
|
||||
}
|
||||
break
|
||||
|
||||
case ChunkType.TEXT_COMPLETE:
|
||||
{
|
||||
blockId &&
|
||||
store.dispatch(updateOneBlock({ id: blockId, changes: { status: MessageBlockStatus.SUCCESS } }))
|
||||
store.dispatch(
|
||||
newMessagesActions.updateMessage({
|
||||
topicId,
|
||||
messageId: assistantMessage.id,
|
||||
updates: { status: AssistantMessageStatus.SUCCESS }
|
||||
})
|
||||
)
|
||||
}
|
||||
break
|
||||
case ChunkType.ERROR: {
|
||||
const possibleBlockId = thinkingBlockId || blockId
|
||||
if (possibleBlockId) {
|
||||
store.dispatch(
|
||||
updateOneBlock({
|
||||
id: possibleBlockId,
|
||||
changes: {
|
||||
status: isAbortError(chunk.error) ? MessageBlockStatus.PAUSED : MessageBlockStatus.ERROR
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
if (!isAbortError(chunk.error)) {
|
||||
throw new Error(chunk.error.message)
|
||||
}
|
||||
break
|
||||
}
|
||||
//fall through
|
||||
case ChunkType.BLOCK_COMPLETE:
|
||||
setIsLoading(false)
|
||||
setIsOutputted(true)
|
||||
currentAskId.current = ''
|
||||
break
|
||||
fetchChatCompletion({
|
||||
messages: [userMessage],
|
||||
assistant: { ...assistant, settings: { streamOutput: true } },
|
||||
onChunkReceived: (chunk: Chunk) => {
|
||||
if (chunk.type === ChunkType.TEXT_DELTA) {
|
||||
blockContent += chunk.text
|
||||
if (!blockId) {
|
||||
const block = createMainTextBlock(assistantMessage.id, chunk.text, {
|
||||
status: MessageBlockStatus.STREAMING
|
||||
})
|
||||
blockId = block.id
|
||||
store.dispatch(
|
||||
newMessagesActions.updateMessage({
|
||||
topicId,
|
||||
messageId: assistantMessage.id,
|
||||
updates: { blockInstruction: { id: block.id } }
|
||||
})
|
||||
)
|
||||
store.dispatch(upsertOneBlock(block))
|
||||
} else {
|
||||
store.dispatch(updateOneBlock({ id: blockId, changes: { content: blockContent } }))
|
||||
}
|
||||
}
|
||||
})
|
||||
} catch (err) {
|
||||
if (isAbortError(err)) return
|
||||
handleError(err instanceof Error ? err : new Error('An error occurred'))
|
||||
console.error('Error fetching result:', err)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
setIsOutputted(true)
|
||||
currentAskId.current = ''
|
||||
}
|
||||
if (chunk.type === ChunkType.TEXT_COMPLETE) {
|
||||
blockId && store.dispatch(updateOneBlock({ id: blockId, changes: { status: MessageBlockStatus.SUCCESS } }))
|
||||
store.dispatch(
|
||||
newMessagesActions.updateMessage({
|
||||
topicId,
|
||||
messageId: assistantMessage.id,
|
||||
updates: { status: AssistantMessageStatus.SUCCESS }
|
||||
})
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
setIsFirstMessage(false)
|
||||
setText('') // ✅ 清除输入框内容
|
||||
},
|
||||
[content, currentAssistant]
|
||||
[content, currentAssistant, topic]
|
||||
)
|
||||
|
||||
const handleEsc = () => {
|
||||
if (isLoading) {
|
||||
handlePause()
|
||||
const clearClipboard = () => {
|
||||
setClipboardText('')
|
||||
setSelectedText('')
|
||||
focusInput()
|
||||
}
|
||||
|
||||
// If the input is focused, the `Esc` callback will not be triggered here.
|
||||
useHotkeys('esc', () => {
|
||||
if (route === 'home') {
|
||||
onCloseWindow()
|
||||
} else {
|
||||
if (route === 'home') {
|
||||
handleCloseWindow()
|
||||
} else {
|
||||
//if we go back to home, we should clear the topic
|
||||
|
||||
//clear the topic messages in order to reduce memory usage
|
||||
store.dispatch(newMessagesActions.clearTopicMessages(currentTopic.current!.id))
|
||||
|
||||
//reset the topic
|
||||
if (currentAssistant.current?.id) {
|
||||
currentTopic.current = getDefaultTopic(currentAssistant.current.id)
|
||||
}
|
||||
|
||||
setError(null)
|
||||
setRoute('home')
|
||||
setUserInputText('')
|
||||
}
|
||||
setRoute('home')
|
||||
setText('')
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const handlePause = () => {
|
||||
if (currentAskId.current) {
|
||||
// const topicId = currentTopic.current!.id
|
||||
useEffect(() => {
|
||||
window.electron.ipcRenderer.on(IpcChannel.ShowMiniWindow, onWindowShow)
|
||||
|
||||
// const topicMessages = selectMessagesForTopic(store.getState(), topicId)
|
||||
// if (!topicMessages) return
|
||||
|
||||
// const streamingMessages = topicMessages.filter((m) => m.status === 'processing' || m.status === 'pending')
|
||||
// const askIds = [...new Set(streamingMessages?.map((m) => m.askId).filter((id) => !!id) as string[])]
|
||||
|
||||
// for (const askId of askIds) {
|
||||
// abortCompletion(askId)
|
||||
// }
|
||||
// store.dispatch(newMessagesActions.setTopicLoading({ topicId: topicId, loading: false }))
|
||||
|
||||
abortCompletion(currentAskId.current)
|
||||
// store.dispatch(newMessagesActions.setTopicLoading({ topicId: currentTopic.current!.id, loading: false }))
|
||||
setIsLoading(false)
|
||||
setIsOutputted(true)
|
||||
currentAskId.current = ''
|
||||
return () => {
|
||||
window.electron.ipcRenderer.removeAllListeners(IpcChannel.ShowMiniWindow)
|
||||
}
|
||||
}
|
||||
}, [onWindowShow, onSendMessage, setRoute])
|
||||
|
||||
// 当路由为home时,初始化isFirstMessage为true
|
||||
useEffect(() => {
|
||||
if (route === 'home') {
|
||||
setIsFirstMessage(true)
|
||||
}
|
||||
}, [route])
|
||||
|
||||
const backgroundColor = () => {
|
||||
// ONLY MAC: when transparent style + light theme: use vibrancy effect
|
||||
@@ -432,96 +286,88 @@ const HomeWindow: FC = () => {
|
||||
if (isMac && windowStyle === 'transparent' && theme === ThemeMode.light) {
|
||||
return 'transparent'
|
||||
}
|
||||
|
||||
return 'var(--color-background)'
|
||||
}
|
||||
|
||||
switch (route) {
|
||||
case 'chat':
|
||||
case 'summary':
|
||||
case 'explanation':
|
||||
return (
|
||||
<Container style={{ backgroundColor: backgroundColor() }}>
|
||||
{route === 'chat' && (
|
||||
<>
|
||||
<InputBar
|
||||
text={userInputText}
|
||||
model={currentAssistant.current?.model}
|
||||
referenceText={referenceText}
|
||||
placeholder={t('miniwindow.input.placeholder.empty', {
|
||||
model: quickAssistantId ? currentAssistant.current?.name : getDefaultModel().name
|
||||
})}
|
||||
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.current!}
|
||||
topic={currentTopic.current!}
|
||||
isOutputted={isOutputted}
|
||||
/>
|
||||
{error && <ErrorMsg>{error}</ErrorMsg>}
|
||||
|
||||
<Divider style={{ margin: '10px 0' }} />
|
||||
<Footer key="footer" route={route} loading={isLoading} onEsc={handleEsc} />
|
||||
</Container>
|
||||
)
|
||||
|
||||
case 'translate':
|
||||
return (
|
||||
<Container style={{ backgroundColor: backgroundColor() }}>
|
||||
<TranslateWindow text={referenceText} />
|
||||
<Divider style={{ margin: '10px 0' }} />
|
||||
<Footer key="footer" route={route} onEsc={handleEsc} />
|
||||
</Container>
|
||||
)
|
||||
|
||||
//Home
|
||||
default:
|
||||
return (
|
||||
<Container style={{ backgroundColor: backgroundColor() }}>
|
||||
<InputBar
|
||||
text={userInputText}
|
||||
model={currentAssistant.current?.model}
|
||||
referenceText={referenceText}
|
||||
placeholder={
|
||||
referenceText && route === 'home'
|
||||
? t('miniwindow.input.placeholder.title')
|
||||
: t('miniwindow.input.placeholder.empty', {
|
||||
model: quickAssistantId ? currentAssistant.current?.name : getDefaultModel().name
|
||||
})
|
||||
}
|
||||
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={content} ref={featureMenusRef} />
|
||||
</Main>
|
||||
<Divider style={{ margin: '10px 0' }} />
|
||||
<Footer
|
||||
key="footer"
|
||||
route={route}
|
||||
canUseBackspace={userInputText.length > 0 || clipboardText.length == 0}
|
||||
loading={isLoading}
|
||||
clearClipboard={clearClipboard}
|
||||
onEsc={handleEsc}
|
||||
/>
|
||||
</Container>
|
||||
)
|
||||
if (['chat', 'summary', 'explanation'].includes(route)) {
|
||||
return (
|
||||
<Container style={{ backgroundColor: backgroundColor() }}>
|
||||
{route === 'chat' && (
|
||||
<>
|
||||
<InputBar
|
||||
text={text}
|
||||
model={model}
|
||||
referenceText={referenceText}
|
||||
placeholder={
|
||||
quickAssistantId
|
||||
? t('miniwindow.input.placeholder.empty', { model: currentAssistant.name })
|
||||
: t('miniwindow.input.placeholder.empty', { model: model.name })
|
||||
}
|
||||
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 ?? defaultAssistant} />
|
||||
<Divider style={{ margin: '10px 0' }} />
|
||||
<Footer route={route} onExit={() => setRoute('home')} />
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
if (route === 'translate') {
|
||||
return (
|
||||
<Container style={{ backgroundColor: backgroundColor() }}>
|
||||
<TranslateWindow text={referenceText} />
|
||||
<Divider style={{ margin: '10px 0' }} />
|
||||
<Footer route={route} onExit={() => setRoute('home')} />
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Container style={{ backgroundColor: backgroundColor() }}>
|
||||
<InputBar
|
||||
text={text}
|
||||
model={model}
|
||||
referenceText={referenceText}
|
||||
placeholder={
|
||||
referenceText && route === 'home'
|
||||
? t('miniwindow.input.placeholder.title')
|
||||
: quickAssistantId
|
||||
? t('miniwindow.input.placeholder.empty', { model: currentAssistant.name })
|
||||
: t('miniwindow.input.placeholder.empty', { model: model.name })
|
||||
}
|
||||
handleKeyDown={handleKeyDown}
|
||||
handleChange={handleChange}
|
||||
ref={inputBarRef}
|
||||
/>
|
||||
<Divider style={{ margin: '10px 0' }} />
|
||||
<ClipboardPreview referenceText={referenceText} clearClipboard={clearClipboard} t={t} />
|
||||
<Main>
|
||||
<FeatureMenus setRoute={setRoute} onSendMessage={onSendMessage} text={content} ref={featureMenusRef} />
|
||||
</Main>
|
||||
<Divider style={{ margin: '10px 0' }} />
|
||||
<Footer
|
||||
route={route}
|
||||
canUseBackspace={text.length > 0 || clipboardText.length == 0}
|
||||
clearClipboard={clearClipboard}
|
||||
onExit={() => {
|
||||
setRoute('home')
|
||||
setText('')
|
||||
onCloseWindow()
|
||||
}}
|
||||
/>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
const Container = styled.div`
|
||||
@@ -542,15 +388,4 @@ const Main = styled.main`
|
||||
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
|
||||
|
||||
@@ -1,53 +1,37 @@
|
||||
import { ArrowLeftOutlined, LoadingOutlined } from '@ant-design/icons'
|
||||
import { ArrowLeftOutlined } from '@ant-design/icons'
|
||||
import { Tag as AntdTag, Tooltip } from 'antd'
|
||||
import { CircleArrowLeft, Copy, Pin } from 'lucide-react'
|
||||
import { FC, useState } from 'react'
|
||||
import { useHotkeys } from 'react-hotkeys-hook'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
|
||||
interface FooterProps {
|
||||
route: string
|
||||
canUseBackspace?: boolean
|
||||
loading?: boolean
|
||||
clearClipboard?: () => void
|
||||
onEsc: () => void
|
||||
onExit: () => void
|
||||
}
|
||||
|
||||
const Footer: FC<FooterProps> = ({ route, canUseBackspace, loading, clearClipboard, onEsc }) => {
|
||||
const Footer: FC<FooterProps> = ({ route, canUseBackspace, clearClipboard, onExit }) => {
|
||||
const { t } = useTranslation()
|
||||
const [isPinned, setIsPinned] = useState(false)
|
||||
|
||||
const handlePin = () => {
|
||||
const onClickPin = () => {
|
||||
window.api.miniWindow.setPin(!isPinned).then(() => {
|
||||
setIsPinned(!isPinned)
|
||||
})
|
||||
}
|
||||
|
||||
useHotkeys('esc', () => {
|
||||
onEsc()
|
||||
})
|
||||
|
||||
return (
|
||||
<WindowFooter className="drag">
|
||||
<FooterText>
|
||||
<Tag
|
||||
bordered={false}
|
||||
icon={
|
||||
loading ? (
|
||||
<LoadingOutlined style={{ fontSize: 12, color: 'var(--color-error)', padding: 0 }} spin />
|
||||
) : (
|
||||
<CircleArrowLeft size={14} color="var(--color-text)" />
|
||||
)
|
||||
}
|
||||
icon={<CircleArrowLeft size={14} color="var(--color-text)" />}
|
||||
className="nodrag"
|
||||
onClick={onEsc}>
|
||||
onClick={() => onExit()}>
|
||||
{t('miniwindow.footer.esc', {
|
||||
action: loading
|
||||
? t('miniwindow.footer.esc_pause')
|
||||
: route === 'home'
|
||||
? t('miniwindow.footer.esc_close')
|
||||
: t('miniwindow.footer.esc_back')
|
||||
action: route === 'home' ? t('miniwindow.footer.esc_close') : t('miniwindow.footer.esc_back')
|
||||
})}
|
||||
</Tag>
|
||||
{route === 'home' && !canUseBackspace && (
|
||||
@@ -70,7 +54,7 @@ const Footer: FC<FooterProps> = ({ route, canUseBackspace, loading, clearClipboa
|
||||
</Tag>
|
||||
)}
|
||||
</FooterText>
|
||||
<PinButtonArea onClick={() => handlePin()} className="nodrag">
|
||||
<PinButtonArea onClick={() => onClickPin()} className="nodrag">
|
||||
<Tooltip title={t('miniwindow.tooltip.pin')} mouseEnterDelay={0.8} placement="left">
|
||||
<Pin size={14} stroke={isPinned ? 'var(--color-primary)' : 'var(--color-text)'} />
|
||||
</Tooltip>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import ModelAvatar from '@renderer/components/Avatar/ModelAvatar'
|
||||
import { useRuntime } from '@renderer/hooks/useRuntime'
|
||||
import { Input as AntdInput } from 'antd'
|
||||
import { InputRef } from 'rc-input/lib/interface'
|
||||
import React, { useRef } from 'react'
|
||||
@@ -9,7 +10,6 @@ interface InputBarProps {
|
||||
model: any
|
||||
referenceText: string
|
||||
placeholder: string
|
||||
loading: boolean
|
||||
handleKeyDown: (e: React.KeyboardEvent<HTMLInputElement>) => void
|
||||
handleChange: (e: React.ChangeEvent<HTMLInputElement>) => void
|
||||
}
|
||||
@@ -19,12 +19,12 @@ const InputBar = ({
|
||||
text,
|
||||
model,
|
||||
placeholder,
|
||||
loading,
|
||||
handleKeyDown,
|
||||
handleChange
|
||||
}: InputBarProps & { ref?: React.RefObject<HTMLDivElement | null> }) => {
|
||||
const { generating } = useRuntime()
|
||||
const inputRef = useRef<InputRef>(null)
|
||||
if (!loading) {
|
||||
if (!generating) {
|
||||
setTimeout(() => inputRef.current?.input?.focus(), 0)
|
||||
}
|
||||
return (
|
||||
@@ -37,7 +37,7 @@ const InputBar = ({
|
||||
autoFocus
|
||||
onKeyDown={handleKeyDown}
|
||||
onChange={handleChange}
|
||||
disabled={loading}
|
||||
disabled={generating}
|
||||
ref={inputRef}
|
||||
/>
|
||||
</InputWrapper>
|
||||
|
||||
Reference in New Issue
Block a user