mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2025-12-24 18:50:56 +08:00
Merge remote-tracking branch 'upstream' into feat/ocr
This commit is contained in:
commit
d3e338e6a8
12
.github/workflows/pr-ci.yml
vendored
12
.github/workflows/pr-ci.yml
vendored
@ -45,8 +45,14 @@ jobs:
|
||||
- name: Install Dependencies
|
||||
run: yarn install
|
||||
|
||||
- name: Build Check
|
||||
run: yarn build:check
|
||||
|
||||
- name: Lint Check
|
||||
run: yarn test:lint
|
||||
|
||||
- name: Type Check
|
||||
run: yarn typecheck
|
||||
|
||||
- name: i18n Check
|
||||
run: yarn check:i18n
|
||||
|
||||
- name: Test
|
||||
run: yarn test
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { isDev, isWin } from '@main/constant'
|
||||
import { app } from 'electron'
|
||||
|
||||
import { getDataPath } from './utils'
|
||||
import { isWin, isDev } from '@main/constant'
|
||||
|
||||
if (isDev) {
|
||||
app.setPath('userData', app.getPath('userData') + 'Dev')
|
||||
|
||||
@ -72,7 +72,7 @@ const dxtService = new DxtService()
|
||||
|
||||
export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
|
||||
const appUpdater = new AppUpdater()
|
||||
const notificationService = new NotificationService(mainWindow)
|
||||
const notificationService = new NotificationService()
|
||||
|
||||
// Initialize Python service with main window
|
||||
pythonService.setMainWindow(mainWindow)
|
||||
|
||||
@ -1,14 +1,9 @@
|
||||
import { BrowserWindow, Notification as ElectronNotification } from 'electron'
|
||||
import { Notification as ElectronNotification } from 'electron'
|
||||
import { Notification } from 'src/renderer/src/types/notification'
|
||||
|
||||
import { windowService } from './WindowService'
|
||||
|
||||
class NotificationService {
|
||||
private window: BrowserWindow
|
||||
|
||||
constructor(window: BrowserWindow) {
|
||||
// Initialize the service
|
||||
this.window = window
|
||||
}
|
||||
|
||||
public async sendNotification(notification: Notification) {
|
||||
// 使用 Electron Notification API
|
||||
const electronNotification = new ElectronNotification({
|
||||
@ -17,8 +12,8 @@ class NotificationService {
|
||||
})
|
||||
|
||||
electronNotification.on('click', () => {
|
||||
this.window.show()
|
||||
this.window.webContents.send('notification-click', notification)
|
||||
windowService.getMainWindow()?.show()
|
||||
windowService.getMainWindow()?.webContents.send('notification-click', notification)
|
||||
})
|
||||
|
||||
electronNotification.show()
|
||||
|
||||
@ -1,5 +1,26 @@
|
||||
@use './container.scss';
|
||||
|
||||
/* Modal 关闭按钮不应该可拖拽,以确保点击正常 */
|
||||
.ant-modal-close {
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
|
||||
/* 普通 Drawer 内容不应该可拖拽 */
|
||||
.ant-drawer-content {
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
|
||||
/* minapp-drawer 有自己的拖拽规则 */
|
||||
|
||||
/* 下拉菜单和弹出框内容不应该可拖拽 */
|
||||
.ant-dropdown,
|
||||
.ant-dropdown-menu,
|
||||
.ant-popover-content,
|
||||
.ant-tooltip-content,
|
||||
.ant-popconfirm {
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
|
||||
#inputbar {
|
||||
resize: none;
|
||||
}
|
||||
@ -66,6 +87,7 @@
|
||||
}
|
||||
|
||||
.ant-drawer-header {
|
||||
/* 普通 drawer header 不应该可拖拽,除非被 minapp-drawer 覆盖 */
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
|
||||
@ -148,6 +170,7 @@
|
||||
border-radius: 10px;
|
||||
}
|
||||
.ant-modal-body {
|
||||
/* 保持 body 在视口内,使用标准的最大高度 */
|
||||
max-height: 80vh;
|
||||
overflow-y: auto;
|
||||
padding: 0 16px 0 16px;
|
||||
|
||||
@ -47,7 +47,7 @@ const ShadowDOMRenderer: React.FC<Props> = ({ children }) => {
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={hostRef}>
|
||||
<div ref={hostRef} style={{ display: 'none' }}>
|
||||
{createPortal(
|
||||
<StyleSheetManager target={shadowRoot}>
|
||||
<StyleProvider container={shadowRoot} layer>
|
||||
|
||||
@ -206,8 +206,16 @@ const TabsBar = styled.div<{ $isFullscreen: boolean }>`
|
||||
gap: 5px;
|
||||
padding-left: ${({ $isFullscreen }) => (!$isFullscreen && isMac ? '75px' : '15px')};
|
||||
padding-right: ${({ $isFullscreen }) => ($isFullscreen ? '12px' : isWin ? '140px' : isLinux ? '120px' : '12px')};
|
||||
-webkit-app-region: drag;
|
||||
height: var(--navbar-height);
|
||||
position: relative;
|
||||
-webkit-app-region: drag;
|
||||
|
||||
/* 确保交互元素在拖拽区域之上 */
|
||||
> * {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
`
|
||||
|
||||
const Tab = styled.div<{ active?: boolean }>`
|
||||
@ -220,7 +228,6 @@ const Tab = styled.div<{ active?: boolean }>`
|
||||
border-radius: var(--list-item-border-radius);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
-webkit-app-region: none;
|
||||
height: 30px;
|
||||
min-width: 90px;
|
||||
transition: background 0.2s;
|
||||
@ -273,7 +280,6 @@ const AddTabButton = styled.div`
|
||||
height: 30px;
|
||||
cursor: pointer;
|
||||
color: var(--color-text-2);
|
||||
-webkit-app-region: none;
|
||||
border-radius: var(--list-item-border-radius);
|
||||
&.active {
|
||||
background: var(--color-list-item);
|
||||
@ -298,7 +304,6 @@ const ThemeButton = styled.div`
|
||||
height: 30px;
|
||||
cursor: pointer;
|
||||
color: var(--color-text);
|
||||
-webkit-app-region: none;
|
||||
|
||||
&:hover {
|
||||
background: var(--color-list-item);
|
||||
@ -314,7 +319,6 @@ const SettingsButton = styled.div<{ $active: boolean }>`
|
||||
height: 30px;
|
||||
cursor: pointer;
|
||||
color: var(--color-text);
|
||||
-webkit-app-region: none;
|
||||
border-radius: 8px;
|
||||
background: ${(props) => (props.$active ? 'var(--color-list-item)' : 'transparent')};
|
||||
&:hover {
|
||||
|
||||
@ -7,10 +7,10 @@ import {
|
||||
MODEL_SUPPORTED_REASONING_EFFORT
|
||||
} from '@renderer/config/models'
|
||||
import { db } from '@renderer/databases'
|
||||
import { getDefaultTopic } from '@renderer/services/AssistantService'
|
||||
import { getDefaultAssistant, getDefaultTopic } from '@renderer/services/AssistantService'
|
||||
import { useAppDispatch, useAppSelector } from '@renderer/store'
|
||||
import {
|
||||
addAssistant,
|
||||
addAssistant as _addAssistant,
|
||||
addTopic,
|
||||
insertAssistant,
|
||||
removeAllTopics,
|
||||
@ -27,6 +27,7 @@ import {
|
||||
import { setDefaultModel, setQuickModel, setTranslateModel } from '@renderer/store/llm'
|
||||
import { Assistant, AssistantSettings, Model, ThinkingOption, Topic } from '@renderer/types'
|
||||
import { uuid } from '@renderer/utils'
|
||||
import { formatErrorMessage } from '@renderer/utils/error'
|
||||
import { useCallback, useEffect, useMemo, useRef } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
@ -38,10 +39,25 @@ export function useAssistants() {
|
||||
const dispatch = useAppDispatch()
|
||||
const logger = loggerService.withContext('useAssistants')
|
||||
|
||||
/**
|
||||
* 添加一个新的助手
|
||||
* @param assistant - 要添加的助手对象
|
||||
* @throws {Error} 如果添加助手失败会抛出错误
|
||||
*/
|
||||
const addAssistant = (assistant: Assistant) => {
|
||||
try {
|
||||
dispatch(_addAssistant(assistant))
|
||||
} catch (e) {
|
||||
logger.error('Failed to add assistant', e as Error)
|
||||
window.message.error(t('assistants.error.add' + ': ' + formatErrorMessage(e)))
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
assistants,
|
||||
updateAssistants: (assistants: Assistant[]) => dispatch(updateAssistants(assistants)),
|
||||
addAssistant: (assistant: Assistant) => dispatch(addAssistant(assistant)),
|
||||
addAssistant,
|
||||
insertAssistant: (index: number, assistant: Assistant) => dispatch(insertAssistant({ index, assistant })),
|
||||
copyAssistant: (assistant: Assistant): Assistant | undefined => {
|
||||
if (!assistant) {
|
||||
@ -52,7 +68,7 @@ export function useAssistants() {
|
||||
const _assistant: Assistant = { ...assistant, id: uuid(), topics: [getDefaultTopic(assistant.id)] }
|
||||
if (index === -1) {
|
||||
logger.warn("Origin assistant's id not found. Fallback to addAssistant.")
|
||||
dispatch(addAssistant(_assistant))
|
||||
addAssistant(_assistant)
|
||||
} else {
|
||||
// 插入到后面
|
||||
try {
|
||||
@ -74,7 +90,22 @@ export function useAssistants() {
|
||||
}
|
||||
|
||||
export function useAssistant(id: string) {
|
||||
const assistant = useAppSelector((state) => state.assistants.assistants.find((a) => a.id === id) as Assistant)
|
||||
let assistant = useAppSelector((state) => state.assistants.assistants.find((a) => a.id === id))
|
||||
const { addAssistant } = useAssistants()
|
||||
const { t } = useTranslation()
|
||||
|
||||
if (!assistant) {
|
||||
window.message.warning(t('warning.missing_assistant'))
|
||||
const newAssistant = { ...getDefaultAssistant(), id }
|
||||
try {
|
||||
addAssistant(newAssistant)
|
||||
assistant = newAssistant
|
||||
} catch (e) {
|
||||
window.message.warning(t('warning.fallback.deafult_assistant'))
|
||||
assistant = getDefaultAssistant()
|
||||
}
|
||||
}
|
||||
|
||||
const dispatch = useAppDispatch()
|
||||
const { defaultModel } = useDefaultModel()
|
||||
|
||||
@ -88,7 +119,7 @@ export function useAssistant(id: string) {
|
||||
const settingsRef = useRef(assistant?.settings)
|
||||
|
||||
useEffect(() => {
|
||||
settingsRef.current = assistant.settings
|
||||
settingsRef.current = assistant?.settings
|
||||
}, [assistant?.settings])
|
||||
|
||||
const updateAssistantSettings = useCallback(
|
||||
|
||||
@ -148,6 +148,9 @@
|
||||
"edit": {
|
||||
"title": "Edit Assistant"
|
||||
},
|
||||
"error": {
|
||||
"add": "Failed to add assistant"
|
||||
},
|
||||
"icon": {
|
||||
"type": "Assistant Icon"
|
||||
},
|
||||
@ -885,6 +888,9 @@
|
||||
},
|
||||
"history": {
|
||||
"continue_chat": "Continue Chatting",
|
||||
"error": {
|
||||
"topic_not_found": "Topic not found"
|
||||
},
|
||||
"locate": {
|
||||
"message": "Locate the message"
|
||||
},
|
||||
@ -3803,6 +3809,10 @@
|
||||
"title": "Update"
|
||||
},
|
||||
"warning": {
|
||||
"fallback": {
|
||||
"deafult_assistant": "Reverted to default assistant, which may cause issues"
|
||||
},
|
||||
"missing_assistant": "Assistant does not exist",
|
||||
"missing_provider": "The supplier does not exist; reverted to the default supplier {{provider}}. This may cause issues."
|
||||
},
|
||||
"words": {
|
||||
|
||||
@ -148,6 +148,9 @@
|
||||
"edit": {
|
||||
"title": "アシスタントを編集"
|
||||
},
|
||||
"error": {
|
||||
"add": "アシスタントの追加に失敗しました"
|
||||
},
|
||||
"icon": {
|
||||
"type": "アシスタントアイコン"
|
||||
},
|
||||
@ -885,6 +888,9 @@
|
||||
},
|
||||
"history": {
|
||||
"continue_chat": "チャットを続ける",
|
||||
"error": {
|
||||
"topic_not_found": "トピックが見つかりません"
|
||||
},
|
||||
"locate": {
|
||||
"message": "メッセージを探す"
|
||||
},
|
||||
@ -3803,6 +3809,10 @@
|
||||
"title": "更新"
|
||||
},
|
||||
"warning": {
|
||||
"fallback": {
|
||||
"deafult_assistant": "既定のアシスタントに戻されました。これにより問題が発生する可能性があります。"
|
||||
},
|
||||
"missing_assistant": "アシスタントが存在しません",
|
||||
"missing_provider": "サプライヤーが存在しないため、デフォルトのサプライヤー {{provider}} にロールバックされました。これにより問題が発生する可能性があります。"
|
||||
},
|
||||
"words": {
|
||||
|
||||
@ -148,6 +148,9 @@
|
||||
"edit": {
|
||||
"title": "Редактировать ассистента"
|
||||
},
|
||||
"error": {
|
||||
"add": "Не удалось добавить помощника"
|
||||
},
|
||||
"icon": {
|
||||
"type": "Иконка ассистента"
|
||||
},
|
||||
@ -885,6 +888,9 @@
|
||||
},
|
||||
"history": {
|
||||
"continue_chat": "Продолжить чат",
|
||||
"error": {
|
||||
"topic_not_found": "Топик не найден"
|
||||
},
|
||||
"locate": {
|
||||
"message": "Найти сообщение"
|
||||
},
|
||||
@ -3803,6 +3809,10 @@
|
||||
"title": "Обновление"
|
||||
},
|
||||
"warning": {
|
||||
"fallback": {
|
||||
"deafult_assistant": "Возвращено к помощнику по умолчанию, что может привести к проблемам"
|
||||
},
|
||||
"missing_assistant": "Ассистент не существует",
|
||||
"missing_provider": "Поставщик не существует, возвращение к поставщику по умолчанию {{provider}}. Это может привести к проблемам."
|
||||
},
|
||||
"words": {
|
||||
|
||||
@ -148,6 +148,9 @@
|
||||
"edit": {
|
||||
"title": "编辑助手"
|
||||
},
|
||||
"error": {
|
||||
"add": "添加助手失败"
|
||||
},
|
||||
"icon": {
|
||||
"type": "助手图标"
|
||||
},
|
||||
@ -885,6 +888,9 @@
|
||||
},
|
||||
"history": {
|
||||
"continue_chat": "继续聊天",
|
||||
"error": {
|
||||
"topic_not_found": "话题不存在"
|
||||
},
|
||||
"locate": {
|
||||
"message": "定位到消息"
|
||||
},
|
||||
@ -3803,6 +3809,10 @@
|
||||
"title": "更新提示"
|
||||
},
|
||||
"warning": {
|
||||
"fallback": {
|
||||
"deafult_assistant": "已回退到默认助手,这可能导致问题"
|
||||
},
|
||||
"missing_assistant": "助手不存在",
|
||||
"missing_provider": "供应商不存在,已回退到默认供应商 {{provider}}。这可能导致问题。"
|
||||
},
|
||||
"words": {
|
||||
|
||||
@ -148,6 +148,9 @@
|
||||
"edit": {
|
||||
"title": "編輯助手"
|
||||
},
|
||||
"error": {
|
||||
"add": "添加助手失敗"
|
||||
},
|
||||
"icon": {
|
||||
"type": "助手圖示"
|
||||
},
|
||||
@ -885,6 +888,9 @@
|
||||
},
|
||||
"history": {
|
||||
"continue_chat": "繼續聊天",
|
||||
"error": {
|
||||
"topic_not_found": "話題不存在"
|
||||
},
|
||||
"locate": {
|
||||
"message": "定位到訊息"
|
||||
},
|
||||
@ -3803,6 +3809,10 @@
|
||||
"title": "更新提示"
|
||||
},
|
||||
"warning": {
|
||||
"fallback": {
|
||||
"deafult_assistant": "已回退到預設助手,這可能導致問題"
|
||||
},
|
||||
"missing_assistant": "助手不存在",
|
||||
"missing_provider": "供應商不存在,已回退到預設供應商 {{provider}}。這可能導致問題。"
|
||||
},
|
||||
"words": {
|
||||
|
||||
@ -148,6 +148,9 @@
|
||||
"edit": {
|
||||
"title": "Επεξεργασία βοηθού"
|
||||
},
|
||||
"error": {
|
||||
"add": "Αποτυχία προσθήκης βοηθού"
|
||||
},
|
||||
"icon": {
|
||||
"type": "Εικόνα Βοηθού"
|
||||
},
|
||||
@ -2688,7 +2691,7 @@
|
||||
"title": "Αυτόματη ενημέρωση"
|
||||
},
|
||||
"avatar": {
|
||||
"builtin": "Ενσωματωμένο αναγνωριστικό προφίλ",
|
||||
"builtin": "ενσωματωμένο avatar",
|
||||
"reset": "Επαναφορά εικονιδίου"
|
||||
},
|
||||
"backup": {
|
||||
@ -3803,6 +3806,10 @@
|
||||
"title": "Ενημέρωση"
|
||||
},
|
||||
"warning": {
|
||||
"fallback": {
|
||||
"deafult_assistant": "Επαναφέρθηκε στον προεπιλεγμένο βοηθό, γεγονός που ενδέχεται να προκαλέσει προβλήματα"
|
||||
},
|
||||
"missing_assistant": "Ο βοηθός δεν υπάρχει",
|
||||
"missing_provider": "Ο προμηθευτής δεν υπάρχει, έγινε επαναφορά στον προεπιλεγμένο προμηθευτή {{provider}}. Αυτό μπορεί να προκαλέσει προβλήματα."
|
||||
},
|
||||
"words": {
|
||||
|
||||
@ -148,6 +148,9 @@
|
||||
"edit": {
|
||||
"title": "Editar Asistente"
|
||||
},
|
||||
"error": {
|
||||
"add": "Error al agregar asistente"
|
||||
},
|
||||
"icon": {
|
||||
"type": "Ícono del Asistente"
|
||||
},
|
||||
@ -3803,6 +3806,10 @@
|
||||
"title": "Actualización"
|
||||
},
|
||||
"warning": {
|
||||
"fallback": {
|
||||
"deafult_assistant": "Se ha revertido al asistente predeterminado, lo que podría causar problemas"
|
||||
},
|
||||
"missing_assistant": "El asistente no existe",
|
||||
"missing_provider": "El proveedor no existe, se ha revertido al proveedor predeterminado {{provider}}. Esto podría causar problemas."
|
||||
},
|
||||
"words": {
|
||||
|
||||
@ -148,6 +148,9 @@
|
||||
"edit": {
|
||||
"title": "Modifier l'Aide"
|
||||
},
|
||||
"error": {
|
||||
"add": "Échec de l'ajout de l'assistant"
|
||||
},
|
||||
"icon": {
|
||||
"type": "Icône de l'assistant"
|
||||
},
|
||||
@ -3803,6 +3806,10 @@
|
||||
"title": "Mise à jour"
|
||||
},
|
||||
"warning": {
|
||||
"fallback": {
|
||||
"deafult_assistant": "Revenu à l'assistant par défaut, ce qui pourrait entraîner des problèmes"
|
||||
},
|
||||
"missing_assistant": "L'assistant n'existe pas",
|
||||
"missing_provider": "Le fournisseur n’existe pas, retour au fournisseur par défaut {{provider}}. Cela peut entraîner des problèmes."
|
||||
},
|
||||
"words": {
|
||||
|
||||
@ -148,6 +148,9 @@
|
||||
"edit": {
|
||||
"title": "Editar Assistente"
|
||||
},
|
||||
"error": {
|
||||
"add": "Falha ao adicionar assistente"
|
||||
},
|
||||
"icon": {
|
||||
"type": "Ícone do Assistente"
|
||||
},
|
||||
@ -3803,6 +3806,10 @@
|
||||
"title": "Atualização"
|
||||
},
|
||||
"warning": {
|
||||
"fallback": {
|
||||
"deafult_assistant": "Voltou ao assistente padrão, o que pode causar problemas"
|
||||
},
|
||||
"missing_assistant": "O assistente não existe",
|
||||
"missing_provider": "O fornecedor não existe; foi revertido para o fornecedor predefinido {{provider}}. Isto pode causar problemas."
|
||||
},
|
||||
"words": {
|
||||
|
||||
@ -22,7 +22,7 @@ let _stack: Route[] = ['topics']
|
||||
let _topic: Topic | undefined
|
||||
let _message: Message | undefined
|
||||
|
||||
const TopicsPage: FC = () => {
|
||||
const HistoryPage: FC = () => {
|
||||
const { t } = useTranslation()
|
||||
const [search, setSearch] = useState(_search)
|
||||
const [searchKeywords, setSearchKeywords] = useState(_search)
|
||||
@ -52,7 +52,12 @@ const TopicsPage: FC = () => {
|
||||
setTopic(undefined)
|
||||
}
|
||||
|
||||
const onTopicClick = (topic: Topic) => {
|
||||
// topic 不包含 messages,用到的时候才会获取
|
||||
const onTopicClick = (topic: Topic | null | undefined) => {
|
||||
if (!topic) {
|
||||
window.message.error(t('history.error.topic_not_found'))
|
||||
return
|
||||
}
|
||||
setStack((prev) => [...prev, 'topic'])
|
||||
setTopic(topic)
|
||||
}
|
||||
@ -86,7 +91,7 @@ const TopicsPage: FC = () => {
|
||||
</SearchIcon>
|
||||
)
|
||||
}
|
||||
suffix={search.length >= 2 ? <CornerDownLeft size={16} /> : null}
|
||||
suffix={search.length ? <CornerDownLeft size={16} /> : null}
|
||||
ref={inputRef}
|
||||
placeholder={t('history.search.placeholder')}
|
||||
value={search}
|
||||
@ -146,4 +151,4 @@ const SearchIcon = styled.div`
|
||||
}
|
||||
`
|
||||
|
||||
export default TopicsPage
|
||||
export default HistoryPage
|
||||
|
||||
@ -1,16 +1,23 @@
|
||||
import { LoadingIcon } from '@renderer/components/Icons'
|
||||
import db from '@renderer/databases'
|
||||
import useScrollPosition from '@renderer/hooks/useScrollPosition'
|
||||
import { useTimer } from '@renderer/hooks/useTimer'
|
||||
import { getTopicById } from '@renderer/hooks/useTopic'
|
||||
import { selectTopicsMap } from '@renderer/store/assistants'
|
||||
import { Topic } from '@renderer/types'
|
||||
import { type Message, MessageBlockType } from '@renderer/types/newMessage'
|
||||
import { List, Typography } from 'antd'
|
||||
import { List, Spin, Typography } from 'antd'
|
||||
import { useLiveQuery } from 'dexie-react-hooks'
|
||||
import { FC, memo, useCallback, useEffect, useState } from 'react'
|
||||
import { FC, memo, useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useSelector } from 'react-redux'
|
||||
import styled from 'styled-components'
|
||||
|
||||
const { Text, Title } = Typography
|
||||
|
||||
type SearchResult = {
|
||||
message: Message
|
||||
topic: Topic
|
||||
content: string
|
||||
}
|
||||
|
||||
interface Props extends React.HTMLAttributes<HTMLDivElement> {
|
||||
keywords: string
|
||||
onMessageClick: (message: Message) => void
|
||||
@ -19,7 +26,7 @@ interface Props extends React.HTMLAttributes<HTMLDivElement> {
|
||||
|
||||
const SearchResults: FC<Props> = ({ keywords, onMessageClick, onTopicClick, ...props }) => {
|
||||
const { handleScroll, containerRef } = useScrollPosition('SearchResults')
|
||||
const { setTimeoutTimer } = useTimer()
|
||||
const observerRef = useRef<MutationObserver | null>(null)
|
||||
|
||||
const [searchTerms, setSearchTerms] = useState<string[]>(
|
||||
keywords
|
||||
@ -29,9 +36,12 @@ const SearchResults: FC<Props> = ({ keywords, onMessageClick, onTopicClick, ...p
|
||||
)
|
||||
|
||||
const topics = useLiveQuery(() => db.topics.toArray(), [])
|
||||
// FIXME: db 中没有 topic.name 等信息,只能从 store 获取
|
||||
const storeTopicsMap = useSelector(selectTopicsMap)
|
||||
|
||||
const [searchResults, setSearchResults] = useState<{ message: Message; topic: Topic; content: string }[]>([])
|
||||
const [searchResults, setSearchResults] = useState<SearchResult[]>([])
|
||||
const [searchStats, setSearchStats] = useState({ count: 0, time: 0 })
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
|
||||
const removeMarkdown = (text: string) => {
|
||||
return text
|
||||
@ -46,33 +56,40 @@ const SearchResults: FC<Props> = ({ keywords, onMessageClick, onTopicClick, ...p
|
||||
|
||||
const onSearch = useCallback(async () => {
|
||||
setSearchResults([])
|
||||
setIsLoading(true)
|
||||
|
||||
if (keywords.length === 0) {
|
||||
setSearchStats({ count: 0, time: 0 })
|
||||
setSearchTerms([])
|
||||
setIsLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
const startTime = performance.now()
|
||||
const results: { message: Message; topic: Topic; content: string }[] = []
|
||||
const newSearchTerms = keywords
|
||||
.toLowerCase()
|
||||
.split(' ')
|
||||
.filter((term) => term.length > 0)
|
||||
const searchRegexes = newSearchTerms.map((term) => new RegExp(term, 'i'))
|
||||
|
||||
const blocksArray = await db.message_blocks.toArray()
|
||||
const blocks = blocksArray
|
||||
const blocks = (await db.message_blocks.toArray())
|
||||
.filter((block) => block.type === MessageBlockType.MAIN_TEXT)
|
||||
.filter((block) => newSearchTerms.some((term) => block.content.toLowerCase().includes(term)))
|
||||
.filter((block) => searchRegexes.some((regex) => regex.test(block.content)))
|
||||
|
||||
const messages = topics?.map((topic) => topic.messages).flat()
|
||||
const messages = topics?.flatMap((topic) => topic.messages)
|
||||
|
||||
for (const block of blocks) {
|
||||
const message = messages?.find((message) => message.id === block.messageId)
|
||||
if (message) {
|
||||
results.push({ message, topic: await getTopicById(message.topicId)!, content: block.content })
|
||||
}
|
||||
}
|
||||
const results = await Promise.all(
|
||||
blocks.map(async (block) => {
|
||||
const message = messages?.find((message) => message.id === block.messageId)
|
||||
if (message) {
|
||||
const topic = storeTopicsMap.get(message.topicId)
|
||||
if (topic) {
|
||||
return { message, topic, content: block.content }
|
||||
}
|
||||
}
|
||||
return null
|
||||
})
|
||||
).then((results) => results.filter(Boolean) as SearchResult[])
|
||||
|
||||
const endTime = performance.now()
|
||||
setSearchResults(results)
|
||||
@ -81,7 +98,8 @@ const SearchResults: FC<Props> = ({ keywords, onMessageClick, onTopicClick, ...p
|
||||
time: (endTime - startTime) / 1000
|
||||
})
|
||||
setSearchTerms(newSearchTerms)
|
||||
}, [keywords, topics])
|
||||
setIsLoading(false)
|
||||
}, [keywords, storeTopicsMap, topics])
|
||||
|
||||
const highlightText = (text: string) => {
|
||||
let highlightedText = removeMarkdown(text)
|
||||
@ -100,9 +118,24 @@ const SearchResults: FC<Props> = ({ keywords, onMessageClick, onTopicClick, ...p
|
||||
onSearch()
|
||||
}, [onSearch])
|
||||
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return
|
||||
|
||||
observerRef.current = new MutationObserver(() => {
|
||||
containerRef.current?.scrollTo({ top: 0, behavior: 'smooth' })
|
||||
})
|
||||
|
||||
observerRef.current.observe(containerRef.current, {
|
||||
childList: true,
|
||||
subtree: true
|
||||
})
|
||||
|
||||
return () => observerRef.current?.disconnect()
|
||||
}, [containerRef])
|
||||
|
||||
return (
|
||||
<Container ref={containerRef} {...props} onScroll={handleScroll}>
|
||||
<ContainerWrapper>
|
||||
<Spin spinning={isLoading} indicator={<LoadingIcon color="var(--color-text-2)" />}>
|
||||
{searchResults.length > 0 && (
|
||||
<SearchStats>
|
||||
Found {searchStats.count} results in {searchStats.time.toFixed(3)} seconds
|
||||
@ -113,19 +146,15 @@ const SearchResults: FC<Props> = ({ keywords, onMessageClick, onTopicClick, ...p
|
||||
dataSource={searchResults}
|
||||
pagination={{
|
||||
pageSize: 10,
|
||||
onChange: () => {
|
||||
setTimeoutTimer('scroll', () => containerRef.current?.scrollTo({ top: 0 }), 0)
|
||||
}
|
||||
hideOnSinglePage: true
|
||||
}}
|
||||
style={{ opacity: isLoading ? 0 : 1 }}
|
||||
renderItem={({ message, topic, content }) => (
|
||||
<List.Item>
|
||||
<Title
|
||||
level={5}
|
||||
style={{ color: 'var(--color-primary)', cursor: 'pointer' }}
|
||||
onClick={async () => {
|
||||
const _topic = await getTopicById(topic.id)
|
||||
onTopicClick(_topic)
|
||||
}}>
|
||||
onClick={() => onTopicClick(topic)}>
|
||||
{topic.name}
|
||||
</Title>
|
||||
<div style={{ cursor: 'pointer' }} onClick={() => onMessageClick(message)}>
|
||||
@ -138,24 +167,17 @@ const SearchResults: FC<Props> = ({ keywords, onMessageClick, onTopicClick, ...p
|
||||
)}
|
||||
/>
|
||||
<div style={{ minHeight: 30 }}></div>
|
||||
</ContainerWrapper>
|
||||
</Spin>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
const Container = styled.div`
|
||||
width: 100%;
|
||||
padding: 20px;
|
||||
height: 100%;
|
||||
padding: 20px 36px;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: center;
|
||||
`
|
||||
|
||||
const ContainerWrapper = styled.div`
|
||||
width: 100%;
|
||||
padding: 0 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
`
|
||||
|
||||
@ -166,6 +188,7 @@ const SearchStats = styled.div`
|
||||
|
||||
const SearchResultTime = styled.div`
|
||||
margin-top: 10px;
|
||||
text-align: right;
|
||||
`
|
||||
|
||||
export default memo(SearchResults)
|
||||
|
||||
@ -5,18 +5,17 @@ import { MessageEditingProvider } from '@renderer/context/MessageEditingContext'
|
||||
import useScrollPosition from '@renderer/hooks/useScrollPosition'
|
||||
import { useSettings } from '@renderer/hooks/useSettings'
|
||||
import { useTimer } from '@renderer/hooks/useTimer'
|
||||
import { getTopicById } from '@renderer/hooks/useTopic'
|
||||
import { getAssistantById } from '@renderer/services/AssistantService'
|
||||
import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService'
|
||||
import { isGenerating, locateToMessage } from '@renderer/services/MessagesService'
|
||||
import NavigationService from '@renderer/services/NavigationService'
|
||||
import { useAppDispatch } from '@renderer/store'
|
||||
import { loadTopicMessagesThunk } from '@renderer/store/thunk/messageThunk'
|
||||
import { Topic } from '@renderer/types'
|
||||
import { classNames } from '@renderer/utils'
|
||||
import { classNames, runAsyncFunction } from '@renderer/utils'
|
||||
import { Button, Divider, Empty } from 'antd'
|
||||
import { t } from 'i18next'
|
||||
import { Forward } from 'lucide-react'
|
||||
import { FC, useEffect } from 'react'
|
||||
import { FC, useEffect, useState } from 'react'
|
||||
import styled from 'styled-components'
|
||||
|
||||
import { default as MessageItem } from '../../home/Messages/Message'
|
||||
@ -25,16 +24,22 @@ interface Props extends React.HTMLAttributes<HTMLDivElement> {
|
||||
topic?: Topic
|
||||
}
|
||||
|
||||
const TopicMessages: FC<Props> = ({ topic, ...props }) => {
|
||||
const TopicMessages: FC<Props> = ({ topic: _topic, ...props }) => {
|
||||
const navigate = NavigationService.navigate!
|
||||
const { handleScroll, containerRef } = useScrollPosition('TopicMessages')
|
||||
const dispatch = useAppDispatch()
|
||||
const { messageStyle } = useSettings()
|
||||
const { setTimeoutTimer } = useTimer()
|
||||
|
||||
const [topic, setTopic] = useState<Topic | undefined>(_topic)
|
||||
|
||||
useEffect(() => {
|
||||
topic && dispatch(loadTopicMessagesThunk(topic.id))
|
||||
}, [dispatch, topic])
|
||||
if (!_topic) return
|
||||
|
||||
runAsyncFunction(async () => {
|
||||
const topic = await getTopicById(_topic.id)
|
||||
setTopic(topic)
|
||||
})
|
||||
}, [_topic, topic])
|
||||
|
||||
const isEmpty = (topic?.messages || []).length === 0
|
||||
|
||||
|
||||
@ -1,14 +1,14 @@
|
||||
import { SearchOutlined } from '@ant-design/icons'
|
||||
import { VStack } from '@renderer/components/Layout'
|
||||
import { useAssistants } from '@renderer/hooks/useAssistant'
|
||||
import useScrollPosition from '@renderer/hooks/useScrollPosition'
|
||||
import { getTopicById } from '@renderer/hooks/useTopic'
|
||||
import { selectAllTopics } from '@renderer/store/assistants'
|
||||
import { Topic } from '@renderer/types'
|
||||
import { Button, Divider, Empty, Segmented } from 'antd'
|
||||
import dayjs from 'dayjs'
|
||||
import { groupBy, isEmpty, orderBy } from 'lodash'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useSelector } from 'react-redux'
|
||||
import styled from 'styled-components'
|
||||
|
||||
type SortType = 'createdAt' | 'updatedAt'
|
||||
@ -20,18 +20,18 @@ type Props = {
|
||||
} & React.HTMLAttributes<HTMLDivElement>
|
||||
|
||||
const TopicsHistory: React.FC<Props> = ({ keywords, onClick, onSearch, ...props }) => {
|
||||
const { assistants } = useAssistants()
|
||||
const { t } = useTranslation()
|
||||
const { handleScroll, containerRef } = useScrollPosition('TopicsHistory')
|
||||
const [sortType, setSortType] = useState<SortType>('createdAt')
|
||||
|
||||
const topics = orderBy(assistants.map((assistant) => assistant.topics).flat(), sortType, 'desc')
|
||||
// FIXME: db 中没有 topic.name 等信息,只能从 store 获取
|
||||
const topics = useSelector(selectAllTopics)
|
||||
|
||||
const filteredTopics = topics.filter((topic) => {
|
||||
return topic.name.toLowerCase().includes(keywords.toLowerCase())
|
||||
})
|
||||
|
||||
const groupedTopics = groupBy(filteredTopics, (topic) => {
|
||||
const groupedTopics = groupBy(orderBy(filteredTopics, sortType, 'desc'), (topic) => {
|
||||
return dayjs(topic[sortType]).format('MM/DD')
|
||||
})
|
||||
|
||||
@ -66,19 +66,14 @@ const TopicsHistory: React.FC<Props> = ({ keywords, onClick, onSearch, ...props
|
||||
<Date>{date}</Date>
|
||||
<Divider style={{ margin: '5px 0' }} />
|
||||
{items.map((topic) => (
|
||||
<TopicItem
|
||||
key={topic.id}
|
||||
onClick={async () => {
|
||||
const _topic = await getTopicById(topic.id)
|
||||
onClick(_topic)
|
||||
}}>
|
||||
<TopicItem key={topic.id} onClick={() => onClick(topic)}>
|
||||
<TopicName>{topic.name.substring(0, 50)}</TopicName>
|
||||
<TopicDate>{dayjs(topic[sortType]).format('HH:mm')}</TopicDate>
|
||||
</TopicItem>
|
||||
))}
|
||||
</ListItem>
|
||||
))}
|
||||
{keywords.length >= 2 && (
|
||||
{keywords && (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', width: '100%' }}>
|
||||
<Button style={{ width: 200, marginTop: 20 }} type="primary" onClick={onSearch} icon={<SearchOutlined />}>
|
||||
{t('history.search.messages')}
|
||||
|
||||
@ -3,7 +3,7 @@ import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService'
|
||||
import store from '@renderer/store'
|
||||
import { messageBlocksSelectors } from '@renderer/store/messageBlock'
|
||||
import { MessageBlockStatus } from '@renderer/types/newMessage'
|
||||
import { getCodeBlockId } from '@renderer/utils/markdown'
|
||||
import { getCodeBlockId, isOpenFenceBlock } from '@renderer/utils/markdown'
|
||||
import type { Node } from 'mdast'
|
||||
import React, { memo, useCallback, useMemo } from 'react'
|
||||
|
||||
@ -16,8 +16,9 @@ interface Props {
|
||||
}
|
||||
|
||||
const CodeBlock: React.FC<Props> = ({ children, className, node, blockId }) => {
|
||||
const match = /language-([\w-+]+)/.exec(className || '') || children?.includes('\n')
|
||||
const language = match?.[1] ?? 'text'
|
||||
const languageMatch = /language-([\w-+]+)/.exec(className || '')
|
||||
const isMultiline = children?.includes('\n')
|
||||
const language = languageMatch?.[1] ?? (isMultiline ? 'text' : null)
|
||||
|
||||
// 代码块 id
|
||||
const id = useMemo(() => getCodeBlockId(node?.position?.start), [node?.position?.start])
|
||||
@ -39,11 +40,11 @@ const CodeBlock: React.FC<Props> = ({ children, className, node, blockId }) => {
|
||||
[blockId, id]
|
||||
)
|
||||
|
||||
if (match) {
|
||||
if (language !== null) {
|
||||
// HTML 代码块特殊处理
|
||||
// FIXME: 感觉没有必要用 isHtmlCode 判断
|
||||
if (language === 'html') {
|
||||
return <HtmlArtifactsCard html={children} onSave={handleSave} isStreaming={isStreaming} />
|
||||
const isOpenFence = isOpenFenceBlock(children?.length, languageMatch?.[1]?.length, node?.position)
|
||||
return <HtmlArtifactsCard html={children} onSave={handleSave} isStreaming={isStreaming && isOpenFence} />
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@ -10,6 +10,7 @@ const mocks = vi.hoisted(() => ({
|
||||
emit: vi.fn()
|
||||
},
|
||||
getCodeBlockId: vi.fn(),
|
||||
isOpenFenceBlock: vi.fn(),
|
||||
selectById: vi.fn(),
|
||||
CodeBlockView: vi.fn(({ onSave, children }) => (
|
||||
<div>
|
||||
@ -36,7 +37,8 @@ vi.mock('@renderer/services/EventService', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('@renderer/utils/markdown', () => ({
|
||||
getCodeBlockId: mocks.getCodeBlockId
|
||||
getCodeBlockId: mocks.getCodeBlockId,
|
||||
isOpenFenceBlock: mocks.isOpenFenceBlock
|
||||
}))
|
||||
|
||||
vi.mock('@renderer/store', () => ({
|
||||
@ -74,6 +76,7 @@ describe('CodeBlock', () => {
|
||||
vi.clearAllMocks()
|
||||
// Default mock return values
|
||||
mocks.getCodeBlockId.mockReturnValue('test-code-block-id')
|
||||
mocks.isOpenFenceBlock.mockReturnValue(false)
|
||||
mocks.selectById.mockReturnValue({
|
||||
id: 'test-msg-block-id',
|
||||
status: MessageBlockStatus.SUCCESS
|
||||
|
||||
@ -286,6 +286,7 @@ const NewApiPage: FC<{ Options: string[] }> = ({ Options }) => {
|
||||
|
||||
const formData = new FormData()
|
||||
formData.append('prompt', prompt)
|
||||
formData.append('model', painting.model)
|
||||
if (painting.background && painting.background !== 'auto') {
|
||||
formData.append('background', painting.background)
|
||||
}
|
||||
|
||||
@ -1,10 +1,12 @@
|
||||
import { createSlice, PayloadAction } from '@reduxjs/toolkit'
|
||||
import { createSelector, createSlice, PayloadAction } from '@reduxjs/toolkit'
|
||||
import { DEFAULT_CONTEXTCOUNT, DEFAULT_TEMPERATURE } from '@renderer/config/constant'
|
||||
import { TopicManager } from '@renderer/hooks/useTopic'
|
||||
import { getDefaultAssistant, getDefaultTopic } from '@renderer/services/AssistantService'
|
||||
import { Assistant, AssistantSettings, Model, Topic } from '@renderer/types'
|
||||
import { isEmpty, uniqBy } from 'lodash'
|
||||
|
||||
import { RootState } from '.'
|
||||
|
||||
export interface AssistantsState {
|
||||
defaultAssistant: Assistant
|
||||
assistants: Assistant[]
|
||||
@ -30,7 +32,13 @@ const assistantsSlice = createSlice({
|
||||
state.assistants = action.payload
|
||||
},
|
||||
addAssistant: (state, action: PayloadAction<Assistant>) => {
|
||||
state.assistants.push(action.payload)
|
||||
const newAssistant = action.payload
|
||||
const existing = state.assistants.find((item) => item.id === newAssistant.id)
|
||||
if (!existing) {
|
||||
state.assistants.push(action.payload)
|
||||
} else {
|
||||
throw new Error('Assistant with this ID already exists')
|
||||
}
|
||||
},
|
||||
insertAssistant: (state, action: PayloadAction<{ index: number; assistant: Assistant }>) => {
|
||||
const { index, assistant } = action.payload
|
||||
@ -194,4 +202,15 @@ export const {
|
||||
updateTagCollapse
|
||||
} = assistantsSlice.actions
|
||||
|
||||
export const selectAllTopics = createSelector([(state: RootState) => state.assistants.assistants], (assistants) =>
|
||||
assistants.flatMap((assistant: Assistant) => assistant.topics)
|
||||
)
|
||||
|
||||
export const selectTopicsMap = createSelector([selectAllTopics], (topics) => {
|
||||
return topics.reduce((map, topic) => {
|
||||
map.set(topic.id, topic)
|
||||
return map
|
||||
}, new Map())
|
||||
})
|
||||
|
||||
export default assistantsSlice.reducer
|
||||
|
||||
@ -2,6 +2,7 @@ import remarkParse from 'remark-parse'
|
||||
import remarkStringify from 'remark-stringify'
|
||||
import removeMarkdown from 'remove-markdown'
|
||||
import { unified } from 'unified'
|
||||
import type { Point, Position } from 'unist'
|
||||
import { visit } from 'unist-util-visit'
|
||||
|
||||
/**
|
||||
@ -189,7 +190,7 @@ export function removeTrailingDoubleSpaces(markdown: string): string {
|
||||
* @param start 代码块节点的起始位置
|
||||
* @returns 代码块在 Markdown 字符串中的 ID
|
||||
*/
|
||||
export function getCodeBlockId(start: any): string | null {
|
||||
export function getCodeBlockId(start?: Point): string | null {
|
||||
return start ? `${start.line}:${start.column}:${start.offset}` : null
|
||||
}
|
||||
|
||||
@ -218,6 +219,28 @@ export function updateCodeBlock(raw: string, id: string, newContent: string): st
|
||||
return unified().use(remarkStringify).stringify(tree)
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查代码块是否包含 open fence。
|
||||
* 限制:
|
||||
* - 语言名不能包含空格,因为 remark-math 无法处理,会导致 end.offset 过长。
|
||||
*
|
||||
* 这个算法基于 remark/micromark 解析代码块的原理,所有参数实际上都可以从 node 中获取。
|
||||
* 一个代码块的 node.position 包含 fences,而 children 不包含 fences,通过它们之间的
|
||||
* 差值就可以判断有没有 closed fence。
|
||||
*
|
||||
* @param codeLength 代码长度(不包含语言信息)
|
||||
* @param metaLength 元数据长度(```之后的语言信息)
|
||||
* @param position 位置(unist 节点位置)
|
||||
* @returns 是否为 open fence 代码块
|
||||
*/
|
||||
export function isOpenFenceBlock(codeLength?: number, metaLength?: number, position?: Position): boolean {
|
||||
const contentLength = (codeLength ?? 0) + (metaLength ?? 0)
|
||||
const start = position?.start?.offset ?? 0
|
||||
const end = position?.end?.offset ?? 0
|
||||
// 余量至少是 fence (3) + newlines (2)
|
||||
return end - start <= contentLength + 5
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查代码是否具有HTML特征
|
||||
* @param code 输入的代码字符串
|
||||
|
||||
Loading…
Reference in New Issue
Block a user