Merge remote-tracking branch 'upstream' into feat/ocr

This commit is contained in:
icarus 2025-08-23 00:37:48 +08:00
commit d3e338e6a8
26 changed files with 310 additions and 98 deletions

View File

@ -45,8 +45,14 @@ jobs:
- name: Install Dependencies - name: Install Dependencies
run: yarn install run: yarn install
- name: Build Check
run: yarn build:check
- name: Lint Check - name: Lint Check
run: yarn test:lint run: yarn test:lint
- name: Type Check
run: yarn typecheck
- name: i18n Check
run: yarn check:i18n
- name: Test
run: yarn test

View File

@ -1,7 +1,7 @@
import { isDev, isWin } from '@main/constant'
import { app } from 'electron' import { app } from 'electron'
import { getDataPath } from './utils' import { getDataPath } from './utils'
import { isWin, isDev } from '@main/constant'
if (isDev) { if (isDev) {
app.setPath('userData', app.getPath('userData') + 'Dev') app.setPath('userData', app.getPath('userData') + 'Dev')

View File

@ -72,7 +72,7 @@ const dxtService = new DxtService()
export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) { export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
const appUpdater = new AppUpdater() const appUpdater = new AppUpdater()
const notificationService = new NotificationService(mainWindow) const notificationService = new NotificationService()
// Initialize Python service with main window // Initialize Python service with main window
pythonService.setMainWindow(mainWindow) pythonService.setMainWindow(mainWindow)

View File

@ -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 { Notification } from 'src/renderer/src/types/notification'
import { windowService } from './WindowService'
class NotificationService { class NotificationService {
private window: BrowserWindow
constructor(window: BrowserWindow) {
// Initialize the service
this.window = window
}
public async sendNotification(notification: Notification) { public async sendNotification(notification: Notification) {
// 使用 Electron Notification API // 使用 Electron Notification API
const electronNotification = new ElectronNotification({ const electronNotification = new ElectronNotification({
@ -17,8 +12,8 @@ class NotificationService {
}) })
electronNotification.on('click', () => { electronNotification.on('click', () => {
this.window.show() windowService.getMainWindow()?.show()
this.window.webContents.send('notification-click', notification) windowService.getMainWindow()?.webContents.send('notification-click', notification)
}) })
electronNotification.show() electronNotification.show()

View File

@ -1,5 +1,26 @@
@use './container.scss'; @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 { #inputbar {
resize: none; resize: none;
} }
@ -66,6 +87,7 @@
} }
.ant-drawer-header { .ant-drawer-header {
/* 普通 drawer header 不应该可拖拽,除非被 minapp-drawer 覆盖 */
-webkit-app-region: no-drag; -webkit-app-region: no-drag;
} }
@ -148,6 +170,7 @@
border-radius: 10px; border-radius: 10px;
} }
.ant-modal-body { .ant-modal-body {
/* 保持 body 在视口内,使用标准的最大高度 */
max-height: 80vh; max-height: 80vh;
overflow-y: auto; overflow-y: auto;
padding: 0 16px 0 16px; padding: 0 16px 0 16px;

View File

@ -47,7 +47,7 @@ const ShadowDOMRenderer: React.FC<Props> = ({ children }) => {
} }
return ( return (
<div ref={hostRef}> <div ref={hostRef} style={{ display: 'none' }}>
{createPortal( {createPortal(
<StyleSheetManager target={shadowRoot}> <StyleSheetManager target={shadowRoot}>
<StyleProvider container={shadowRoot} layer> <StyleProvider container={shadowRoot} layer>

View File

@ -206,8 +206,16 @@ const TabsBar = styled.div<{ $isFullscreen: boolean }>`
gap: 5px; gap: 5px;
padding-left: ${({ $isFullscreen }) => (!$isFullscreen && isMac ? '75px' : '15px')}; padding-left: ${({ $isFullscreen }) => (!$isFullscreen && isMac ? '75px' : '15px')};
padding-right: ${({ $isFullscreen }) => ($isFullscreen ? '12px' : isWin ? '140px' : isLinux ? '120px' : '12px')}; padding-right: ${({ $isFullscreen }) => ($isFullscreen ? '12px' : isWin ? '140px' : isLinux ? '120px' : '12px')};
-webkit-app-region: drag;
height: var(--navbar-height); 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 }>` const Tab = styled.div<{ active?: boolean }>`
@ -220,7 +228,6 @@ const Tab = styled.div<{ active?: boolean }>`
border-radius: var(--list-item-border-radius); border-radius: var(--list-item-border-radius);
cursor: pointer; cursor: pointer;
user-select: none; user-select: none;
-webkit-app-region: none;
height: 30px; height: 30px;
min-width: 90px; min-width: 90px;
transition: background 0.2s; transition: background 0.2s;
@ -273,7 +280,6 @@ const AddTabButton = styled.div`
height: 30px; height: 30px;
cursor: pointer; cursor: pointer;
color: var(--color-text-2); color: var(--color-text-2);
-webkit-app-region: none;
border-radius: var(--list-item-border-radius); border-radius: var(--list-item-border-radius);
&.active { &.active {
background: var(--color-list-item); background: var(--color-list-item);
@ -298,7 +304,6 @@ const ThemeButton = styled.div`
height: 30px; height: 30px;
cursor: pointer; cursor: pointer;
color: var(--color-text); color: var(--color-text);
-webkit-app-region: none;
&:hover { &:hover {
background: var(--color-list-item); background: var(--color-list-item);
@ -314,7 +319,6 @@ const SettingsButton = styled.div<{ $active: boolean }>`
height: 30px; height: 30px;
cursor: pointer; cursor: pointer;
color: var(--color-text); color: var(--color-text);
-webkit-app-region: none;
border-radius: 8px; border-radius: 8px;
background: ${(props) => (props.$active ? 'var(--color-list-item)' : 'transparent')}; background: ${(props) => (props.$active ? 'var(--color-list-item)' : 'transparent')};
&:hover { &:hover {

View File

@ -7,10 +7,10 @@ import {
MODEL_SUPPORTED_REASONING_EFFORT MODEL_SUPPORTED_REASONING_EFFORT
} from '@renderer/config/models' } from '@renderer/config/models'
import { db } from '@renderer/databases' 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 { useAppDispatch, useAppSelector } from '@renderer/store'
import { import {
addAssistant, addAssistant as _addAssistant,
addTopic, addTopic,
insertAssistant, insertAssistant,
removeAllTopics, removeAllTopics,
@ -27,6 +27,7 @@ import {
import { setDefaultModel, setQuickModel, setTranslateModel } from '@renderer/store/llm' import { setDefaultModel, setQuickModel, setTranslateModel } from '@renderer/store/llm'
import { Assistant, AssistantSettings, Model, ThinkingOption, Topic } from '@renderer/types' import { Assistant, AssistantSettings, Model, ThinkingOption, Topic } from '@renderer/types'
import { uuid } from '@renderer/utils' import { uuid } from '@renderer/utils'
import { formatErrorMessage } from '@renderer/utils/error'
import { useCallback, useEffect, useMemo, useRef } from 'react' import { useCallback, useEffect, useMemo, useRef } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
@ -38,10 +39,25 @@ export function useAssistants() {
const dispatch = useAppDispatch() const dispatch = useAppDispatch()
const logger = loggerService.withContext('useAssistants') 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 { return {
assistants, assistants,
updateAssistants: (assistants: Assistant[]) => dispatch(updateAssistants(assistants)), updateAssistants: (assistants: Assistant[]) => dispatch(updateAssistants(assistants)),
addAssistant: (assistant: Assistant) => dispatch(addAssistant(assistant)), addAssistant,
insertAssistant: (index: number, assistant: Assistant) => dispatch(insertAssistant({ index, assistant })), insertAssistant: (index: number, assistant: Assistant) => dispatch(insertAssistant({ index, assistant })),
copyAssistant: (assistant: Assistant): Assistant | undefined => { copyAssistant: (assistant: Assistant): Assistant | undefined => {
if (!assistant) { if (!assistant) {
@ -52,7 +68,7 @@ export function useAssistants() {
const _assistant: Assistant = { ...assistant, id: uuid(), topics: [getDefaultTopic(assistant.id)] } const _assistant: Assistant = { ...assistant, id: uuid(), topics: [getDefaultTopic(assistant.id)] }
if (index === -1) { if (index === -1) {
logger.warn("Origin assistant's id not found. Fallback to addAssistant.") logger.warn("Origin assistant's id not found. Fallback to addAssistant.")
dispatch(addAssistant(_assistant)) addAssistant(_assistant)
} else { } else {
// 插入到后面 // 插入到后面
try { try {
@ -74,7 +90,22 @@ export function useAssistants() {
} }
export function useAssistant(id: string) { 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 dispatch = useAppDispatch()
const { defaultModel } = useDefaultModel() const { defaultModel } = useDefaultModel()
@ -88,7 +119,7 @@ export function useAssistant(id: string) {
const settingsRef = useRef(assistant?.settings) const settingsRef = useRef(assistant?.settings)
useEffect(() => { useEffect(() => {
settingsRef.current = assistant.settings settingsRef.current = assistant?.settings
}, [assistant?.settings]) }, [assistant?.settings])
const updateAssistantSettings = useCallback( const updateAssistantSettings = useCallback(

View File

@ -148,6 +148,9 @@
"edit": { "edit": {
"title": "Edit Assistant" "title": "Edit Assistant"
}, },
"error": {
"add": "Failed to add assistant"
},
"icon": { "icon": {
"type": "Assistant Icon" "type": "Assistant Icon"
}, },
@ -885,6 +888,9 @@
}, },
"history": { "history": {
"continue_chat": "Continue Chatting", "continue_chat": "Continue Chatting",
"error": {
"topic_not_found": "Topic not found"
},
"locate": { "locate": {
"message": "Locate the message" "message": "Locate the message"
}, },
@ -3803,6 +3809,10 @@
"title": "Update" "title": "Update"
}, },
"warning": { "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." "missing_provider": "The supplier does not exist; reverted to the default supplier {{provider}}. This may cause issues."
}, },
"words": { "words": {

View File

@ -148,6 +148,9 @@
"edit": { "edit": {
"title": "アシスタントを編集" "title": "アシスタントを編集"
}, },
"error": {
"add": "アシスタントの追加に失敗しました"
},
"icon": { "icon": {
"type": "アシスタントアイコン" "type": "アシスタントアイコン"
}, },
@ -885,6 +888,9 @@
}, },
"history": { "history": {
"continue_chat": "チャットを続ける", "continue_chat": "チャットを続ける",
"error": {
"topic_not_found": "トピックが見つかりません"
},
"locate": { "locate": {
"message": "メッセージを探す" "message": "メッセージを探す"
}, },
@ -3803,6 +3809,10 @@
"title": "更新" "title": "更新"
}, },
"warning": { "warning": {
"fallback": {
"deafult_assistant": "既定のアシスタントに戻されました。これにより問題が発生する可能性があります。"
},
"missing_assistant": "アシスタントが存在しません",
"missing_provider": "サプライヤーが存在しないため、デフォルトのサプライヤー {{provider}} にロールバックされました。これにより問題が発生する可能性があります。" "missing_provider": "サプライヤーが存在しないため、デフォルトのサプライヤー {{provider}} にロールバックされました。これにより問題が発生する可能性があります。"
}, },
"words": { "words": {

View File

@ -148,6 +148,9 @@
"edit": { "edit": {
"title": "Редактировать ассистента" "title": "Редактировать ассистента"
}, },
"error": {
"add": "Не удалось добавить помощника"
},
"icon": { "icon": {
"type": "Иконка ассистента" "type": "Иконка ассистента"
}, },
@ -885,6 +888,9 @@
}, },
"history": { "history": {
"continue_chat": "Продолжить чат", "continue_chat": "Продолжить чат",
"error": {
"topic_not_found": "Топик не найден"
},
"locate": { "locate": {
"message": "Найти сообщение" "message": "Найти сообщение"
}, },
@ -3803,6 +3809,10 @@
"title": "Обновление" "title": "Обновление"
}, },
"warning": { "warning": {
"fallback": {
"deafult_assistant": "Возвращено к помощнику по умолчанию, что может привести к проблемам"
},
"missing_assistant": "Ассистент не существует",
"missing_provider": "Поставщик не существует, возвращение к поставщику по умолчанию {{provider}}. Это может привести к проблемам." "missing_provider": "Поставщик не существует, возвращение к поставщику по умолчанию {{provider}}. Это может привести к проблемам."
}, },
"words": { "words": {

View File

@ -148,6 +148,9 @@
"edit": { "edit": {
"title": "编辑助手" "title": "编辑助手"
}, },
"error": {
"add": "添加助手失败"
},
"icon": { "icon": {
"type": "助手图标" "type": "助手图标"
}, },
@ -885,6 +888,9 @@
}, },
"history": { "history": {
"continue_chat": "继续聊天", "continue_chat": "继续聊天",
"error": {
"topic_not_found": "话题不存在"
},
"locate": { "locate": {
"message": "定位到消息" "message": "定位到消息"
}, },
@ -3803,6 +3809,10 @@
"title": "更新提示" "title": "更新提示"
}, },
"warning": { "warning": {
"fallback": {
"deafult_assistant": "已回退到默认助手,这可能导致问题"
},
"missing_assistant": "助手不存在",
"missing_provider": "供应商不存在,已回退到默认供应商 {{provider}}。这可能导致问题。" "missing_provider": "供应商不存在,已回退到默认供应商 {{provider}}。这可能导致问题。"
}, },
"words": { "words": {

View File

@ -148,6 +148,9 @@
"edit": { "edit": {
"title": "編輯助手" "title": "編輯助手"
}, },
"error": {
"add": "添加助手失敗"
},
"icon": { "icon": {
"type": "助手圖示" "type": "助手圖示"
}, },
@ -885,6 +888,9 @@
}, },
"history": { "history": {
"continue_chat": "繼續聊天", "continue_chat": "繼續聊天",
"error": {
"topic_not_found": "話題不存在"
},
"locate": { "locate": {
"message": "定位到訊息" "message": "定位到訊息"
}, },
@ -3803,6 +3809,10 @@
"title": "更新提示" "title": "更新提示"
}, },
"warning": { "warning": {
"fallback": {
"deafult_assistant": "已回退到預設助手,這可能導致問題"
},
"missing_assistant": "助手不存在",
"missing_provider": "供應商不存在,已回退到預設供應商 {{provider}}。這可能導致問題。" "missing_provider": "供應商不存在,已回退到預設供應商 {{provider}}。這可能導致問題。"
}, },
"words": { "words": {

View File

@ -148,6 +148,9 @@
"edit": { "edit": {
"title": "Επεξεργασία βοηθού" "title": "Επεξεργασία βοηθού"
}, },
"error": {
"add": "Αποτυχία προσθήκης βοηθού"
},
"icon": { "icon": {
"type": "Εικόνα Βοηθού" "type": "Εικόνα Βοηθού"
}, },
@ -2688,7 +2691,7 @@
"title": "Αυτόματη ενημέρωση" "title": "Αυτόματη ενημέρωση"
}, },
"avatar": { "avatar": {
"builtin": "Ενσωματωμένο αναγνωριστικό προφίλ", "builtin": "ενσωματωμένο avatar",
"reset": "Επαναφορά εικονιδίου" "reset": "Επαναφορά εικονιδίου"
}, },
"backup": { "backup": {
@ -3803,6 +3806,10 @@
"title": "Ενημέρωση" "title": "Ενημέρωση"
}, },
"warning": { "warning": {
"fallback": {
"deafult_assistant": "Επαναφέρθηκε στον προεπιλεγμένο βοηθό, γεγονός που ενδέχεται να προκαλέσει προβλήματα"
},
"missing_assistant": "Ο βοηθός δεν υπάρχει",
"missing_provider": "Ο προμηθευτής δεν υπάρχει, έγινε επαναφορά στον προεπιλεγμένο προμηθευτή {{provider}}. Αυτό μπορεί να προκαλέσει προβλήματα." "missing_provider": "Ο προμηθευτής δεν υπάρχει, έγινε επαναφορά στον προεπιλεγμένο προμηθευτή {{provider}}. Αυτό μπορεί να προκαλέσει προβλήματα."
}, },
"words": { "words": {

View File

@ -148,6 +148,9 @@
"edit": { "edit": {
"title": "Editar Asistente" "title": "Editar Asistente"
}, },
"error": {
"add": "Error al agregar asistente"
},
"icon": { "icon": {
"type": "Ícono del Asistente" "type": "Ícono del Asistente"
}, },
@ -3803,6 +3806,10 @@
"title": "Actualización" "title": "Actualización"
}, },
"warning": { "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." "missing_provider": "El proveedor no existe, se ha revertido al proveedor predeterminado {{provider}}. Esto podría causar problemas."
}, },
"words": { "words": {

View File

@ -148,6 +148,9 @@
"edit": { "edit": {
"title": "Modifier l'Aide" "title": "Modifier l'Aide"
}, },
"error": {
"add": "Échec de l'ajout de l'assistant"
},
"icon": { "icon": {
"type": "Icône de l'assistant" "type": "Icône de l'assistant"
}, },
@ -3803,6 +3806,10 @@
"title": "Mise à jour" "title": "Mise à jour"
}, },
"warning": { "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 nexiste pas, retour au fournisseur par défaut {{provider}}. Cela peut entraîner des problèmes." "missing_provider": "Le fournisseur nexiste pas, retour au fournisseur par défaut {{provider}}. Cela peut entraîner des problèmes."
}, },
"words": { "words": {

View File

@ -148,6 +148,9 @@
"edit": { "edit": {
"title": "Editar Assistente" "title": "Editar Assistente"
}, },
"error": {
"add": "Falha ao adicionar assistente"
},
"icon": { "icon": {
"type": "Ícone do Assistente" "type": "Ícone do Assistente"
}, },
@ -3803,6 +3806,10 @@
"title": "Atualização" "title": "Atualização"
}, },
"warning": { "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." "missing_provider": "O fornecedor não existe; foi revertido para o fornecedor predefinido {{provider}}. Isto pode causar problemas."
}, },
"words": { "words": {

View File

@ -22,7 +22,7 @@ let _stack: Route[] = ['topics']
let _topic: Topic | undefined let _topic: Topic | undefined
let _message: Message | undefined let _message: Message | undefined
const TopicsPage: FC = () => { const HistoryPage: FC = () => {
const { t } = useTranslation() const { t } = useTranslation()
const [search, setSearch] = useState(_search) const [search, setSearch] = useState(_search)
const [searchKeywords, setSearchKeywords] = useState(_search) const [searchKeywords, setSearchKeywords] = useState(_search)
@ -52,7 +52,12 @@ const TopicsPage: FC = () => {
setTopic(undefined) 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']) setStack((prev) => [...prev, 'topic'])
setTopic(topic) setTopic(topic)
} }
@ -86,7 +91,7 @@ const TopicsPage: FC = () => {
</SearchIcon> </SearchIcon>
) )
} }
suffix={search.length >= 2 ? <CornerDownLeft size={16} /> : null} suffix={search.length ? <CornerDownLeft size={16} /> : null}
ref={inputRef} ref={inputRef}
placeholder={t('history.search.placeholder')} placeholder={t('history.search.placeholder')}
value={search} value={search}
@ -146,4 +151,4 @@ const SearchIcon = styled.div`
} }
` `
export default TopicsPage export default HistoryPage

View File

@ -1,16 +1,23 @@
import { LoadingIcon } from '@renderer/components/Icons'
import db from '@renderer/databases' import db from '@renderer/databases'
import useScrollPosition from '@renderer/hooks/useScrollPosition' import useScrollPosition from '@renderer/hooks/useScrollPosition'
import { useTimer } from '@renderer/hooks/useTimer' import { selectTopicsMap } from '@renderer/store/assistants'
import { getTopicById } from '@renderer/hooks/useTopic'
import { Topic } from '@renderer/types' import { Topic } from '@renderer/types'
import { type Message, MessageBlockType } from '@renderer/types/newMessage' 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 { 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' import styled from 'styled-components'
const { Text, Title } = Typography const { Text, Title } = Typography
type SearchResult = {
message: Message
topic: Topic
content: string
}
interface Props extends React.HTMLAttributes<HTMLDivElement> { interface Props extends React.HTMLAttributes<HTMLDivElement> {
keywords: string keywords: string
onMessageClick: (message: Message) => void onMessageClick: (message: Message) => void
@ -19,7 +26,7 @@ interface Props extends React.HTMLAttributes<HTMLDivElement> {
const SearchResults: FC<Props> = ({ keywords, onMessageClick, onTopicClick, ...props }) => { const SearchResults: FC<Props> = ({ keywords, onMessageClick, onTopicClick, ...props }) => {
const { handleScroll, containerRef } = useScrollPosition('SearchResults') const { handleScroll, containerRef } = useScrollPosition('SearchResults')
const { setTimeoutTimer } = useTimer() const observerRef = useRef<MutationObserver | null>(null)
const [searchTerms, setSearchTerms] = useState<string[]>( const [searchTerms, setSearchTerms] = useState<string[]>(
keywords keywords
@ -29,9 +36,12 @@ const SearchResults: FC<Props> = ({ keywords, onMessageClick, onTopicClick, ...p
) )
const topics = useLiveQuery(() => db.topics.toArray(), []) 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 [searchStats, setSearchStats] = useState({ count: 0, time: 0 })
const [isLoading, setIsLoading] = useState(false)
const removeMarkdown = (text: string) => { const removeMarkdown = (text: string) => {
return text return text
@ -46,33 +56,40 @@ const SearchResults: FC<Props> = ({ keywords, onMessageClick, onTopicClick, ...p
const onSearch = useCallback(async () => { const onSearch = useCallback(async () => {
setSearchResults([]) setSearchResults([])
setIsLoading(true)
if (keywords.length === 0) { if (keywords.length === 0) {
setSearchStats({ count: 0, time: 0 }) setSearchStats({ count: 0, time: 0 })
setSearchTerms([]) setSearchTerms([])
setIsLoading(false)
return return
} }
const startTime = performance.now() const startTime = performance.now()
const results: { message: Message; topic: Topic; content: string }[] = []
const newSearchTerms = keywords const newSearchTerms = keywords
.toLowerCase() .toLowerCase()
.split(' ') .split(' ')
.filter((term) => term.length > 0) .filter((term) => term.length > 0)
const searchRegexes = newSearchTerms.map((term) => new RegExp(term, 'i'))
const blocksArray = await db.message_blocks.toArray() const blocks = (await db.message_blocks.toArray())
const blocks = blocksArray
.filter((block) => block.type === MessageBlockType.MAIN_TEXT) .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 results = await Promise.all(
const message = messages?.find((message) => message.id === block.messageId) blocks.map(async (block) => {
if (message) { const message = messages?.find((message) => message.id === block.messageId)
results.push({ message, topic: await getTopicById(message.topicId)!, content: block.content }) 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() const endTime = performance.now()
setSearchResults(results) setSearchResults(results)
@ -81,7 +98,8 @@ const SearchResults: FC<Props> = ({ keywords, onMessageClick, onTopicClick, ...p
time: (endTime - startTime) / 1000 time: (endTime - startTime) / 1000
}) })
setSearchTerms(newSearchTerms) setSearchTerms(newSearchTerms)
}, [keywords, topics]) setIsLoading(false)
}, [keywords, storeTopicsMap, topics])
const highlightText = (text: string) => { const highlightText = (text: string) => {
let highlightedText = removeMarkdown(text) let highlightedText = removeMarkdown(text)
@ -100,9 +118,24 @@ const SearchResults: FC<Props> = ({ keywords, onMessageClick, onTopicClick, ...p
onSearch() onSearch()
}, [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 ( return (
<Container ref={containerRef} {...props} onScroll={handleScroll}> <Container ref={containerRef} {...props} onScroll={handleScroll}>
<ContainerWrapper> <Spin spinning={isLoading} indicator={<LoadingIcon color="var(--color-text-2)" />}>
{searchResults.length > 0 && ( {searchResults.length > 0 && (
<SearchStats> <SearchStats>
Found {searchStats.count} results in {searchStats.time.toFixed(3)} seconds Found {searchStats.count} results in {searchStats.time.toFixed(3)} seconds
@ -113,19 +146,15 @@ const SearchResults: FC<Props> = ({ keywords, onMessageClick, onTopicClick, ...p
dataSource={searchResults} dataSource={searchResults}
pagination={{ pagination={{
pageSize: 10, pageSize: 10,
onChange: () => { hideOnSinglePage: true
setTimeoutTimer('scroll', () => containerRef.current?.scrollTo({ top: 0 }), 0)
}
}} }}
style={{ opacity: isLoading ? 0 : 1 }}
renderItem={({ message, topic, content }) => ( renderItem={({ message, topic, content }) => (
<List.Item> <List.Item>
<Title <Title
level={5} level={5}
style={{ color: 'var(--color-primary)', cursor: 'pointer' }} style={{ color: 'var(--color-primary)', cursor: 'pointer' }}
onClick={async () => { onClick={() => onTopicClick(topic)}>
const _topic = await getTopicById(topic.id)
onTopicClick(_topic)
}}>
{topic.name} {topic.name}
</Title> </Title>
<div style={{ cursor: 'pointer' }} onClick={() => onMessageClick(message)}> <div style={{ cursor: 'pointer' }} onClick={() => onMessageClick(message)}>
@ -138,24 +167,17 @@ const SearchResults: FC<Props> = ({ keywords, onMessageClick, onTopicClick, ...p
)} )}
/> />
<div style={{ minHeight: 30 }}></div> <div style={{ minHeight: 30 }}></div>
</ContainerWrapper> </Spin>
</Container> </Container>
) )
} }
const Container = styled.div` const Container = styled.div`
width: 100%; width: 100%;
padding: 20px; height: 100%;
padding: 20px 36px;
overflow-y: auto; overflow-y: auto;
display: flex; display: flex;
flex-direction: row;
justify-content: center;
`
const ContainerWrapper = styled.div`
width: 100%;
padding: 0 16px;
display: flex;
flex-direction: column; flex-direction: column;
` `
@ -166,6 +188,7 @@ const SearchStats = styled.div`
const SearchResultTime = styled.div` const SearchResultTime = styled.div`
margin-top: 10px; margin-top: 10px;
text-align: right;
` `
export default memo(SearchResults) export default memo(SearchResults)

View File

@ -5,18 +5,17 @@ import { MessageEditingProvider } from '@renderer/context/MessageEditingContext'
import useScrollPosition from '@renderer/hooks/useScrollPosition' import useScrollPosition from '@renderer/hooks/useScrollPosition'
import { useSettings } from '@renderer/hooks/useSettings' import { useSettings } from '@renderer/hooks/useSettings'
import { useTimer } from '@renderer/hooks/useTimer' import { useTimer } from '@renderer/hooks/useTimer'
import { getTopicById } from '@renderer/hooks/useTopic'
import { getAssistantById } from '@renderer/services/AssistantService' import { getAssistantById } from '@renderer/services/AssistantService'
import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService' import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService'
import { isGenerating, locateToMessage } from '@renderer/services/MessagesService' import { isGenerating, locateToMessage } from '@renderer/services/MessagesService'
import NavigationService from '@renderer/services/NavigationService' import NavigationService from '@renderer/services/NavigationService'
import { useAppDispatch } from '@renderer/store'
import { loadTopicMessagesThunk } from '@renderer/store/thunk/messageThunk'
import { Topic } from '@renderer/types' import { Topic } from '@renderer/types'
import { classNames } from '@renderer/utils' import { classNames, runAsyncFunction } from '@renderer/utils'
import { Button, Divider, Empty } from 'antd' import { Button, Divider, Empty } from 'antd'
import { t } from 'i18next' import { t } from 'i18next'
import { Forward } from 'lucide-react' import { Forward } from 'lucide-react'
import { FC, useEffect } from 'react' import { FC, useEffect, useState } from 'react'
import styled from 'styled-components' import styled from 'styled-components'
import { default as MessageItem } from '../../home/Messages/Message' import { default as MessageItem } from '../../home/Messages/Message'
@ -25,16 +24,22 @@ interface Props extends React.HTMLAttributes<HTMLDivElement> {
topic?: Topic topic?: Topic
} }
const TopicMessages: FC<Props> = ({ topic, ...props }) => { const TopicMessages: FC<Props> = ({ topic: _topic, ...props }) => {
const navigate = NavigationService.navigate! const navigate = NavigationService.navigate!
const { handleScroll, containerRef } = useScrollPosition('TopicMessages') const { handleScroll, containerRef } = useScrollPosition('TopicMessages')
const dispatch = useAppDispatch()
const { messageStyle } = useSettings() const { messageStyle } = useSettings()
const { setTimeoutTimer } = useTimer() const { setTimeoutTimer } = useTimer()
const [topic, setTopic] = useState<Topic | undefined>(_topic)
useEffect(() => { useEffect(() => {
topic && dispatch(loadTopicMessagesThunk(topic.id)) if (!_topic) return
}, [dispatch, topic])
runAsyncFunction(async () => {
const topic = await getTopicById(_topic.id)
setTopic(topic)
})
}, [_topic, topic])
const isEmpty = (topic?.messages || []).length === 0 const isEmpty = (topic?.messages || []).length === 0

View File

@ -1,14 +1,14 @@
import { SearchOutlined } from '@ant-design/icons' import { SearchOutlined } from '@ant-design/icons'
import { VStack } from '@renderer/components/Layout' import { VStack } from '@renderer/components/Layout'
import { useAssistants } from '@renderer/hooks/useAssistant'
import useScrollPosition from '@renderer/hooks/useScrollPosition' import useScrollPosition from '@renderer/hooks/useScrollPosition'
import { getTopicById } from '@renderer/hooks/useTopic' import { selectAllTopics } from '@renderer/store/assistants'
import { Topic } from '@renderer/types' import { Topic } from '@renderer/types'
import { Button, Divider, Empty, Segmented } from 'antd' import { Button, Divider, Empty, Segmented } from 'antd'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import { groupBy, isEmpty, orderBy } from 'lodash' import { groupBy, isEmpty, orderBy } from 'lodash'
import { useState } from 'react' import { useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { useSelector } from 'react-redux'
import styled from 'styled-components' import styled from 'styled-components'
type SortType = 'createdAt' | 'updatedAt' type SortType = 'createdAt' | 'updatedAt'
@ -20,18 +20,18 @@ type Props = {
} & React.HTMLAttributes<HTMLDivElement> } & React.HTMLAttributes<HTMLDivElement>
const TopicsHistory: React.FC<Props> = ({ keywords, onClick, onSearch, ...props }) => { const TopicsHistory: React.FC<Props> = ({ keywords, onClick, onSearch, ...props }) => {
const { assistants } = useAssistants()
const { t } = useTranslation() const { t } = useTranslation()
const { handleScroll, containerRef } = useScrollPosition('TopicsHistory') const { handleScroll, containerRef } = useScrollPosition('TopicsHistory')
const [sortType, setSortType] = useState<SortType>('createdAt') 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) => { const filteredTopics = topics.filter((topic) => {
return topic.name.toLowerCase().includes(keywords.toLowerCase()) 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') return dayjs(topic[sortType]).format('MM/DD')
}) })
@ -66,19 +66,14 @@ const TopicsHistory: React.FC<Props> = ({ keywords, onClick, onSearch, ...props
<Date>{date}</Date> <Date>{date}</Date>
<Divider style={{ margin: '5px 0' }} /> <Divider style={{ margin: '5px 0' }} />
{items.map((topic) => ( {items.map((topic) => (
<TopicItem <TopicItem key={topic.id} onClick={() => onClick(topic)}>
key={topic.id}
onClick={async () => {
const _topic = await getTopicById(topic.id)
onClick(_topic)
}}>
<TopicName>{topic.name.substring(0, 50)}</TopicName> <TopicName>{topic.name.substring(0, 50)}</TopicName>
<TopicDate>{dayjs(topic[sortType]).format('HH:mm')}</TopicDate> <TopicDate>{dayjs(topic[sortType]).format('HH:mm')}</TopicDate>
</TopicItem> </TopicItem>
))} ))}
</ListItem> </ListItem>
))} ))}
{keywords.length >= 2 && ( {keywords && (
<div style={{ display: 'flex', justifyContent: 'center', width: '100%' }}> <div style={{ display: 'flex', justifyContent: 'center', width: '100%' }}>
<Button style={{ width: 200, marginTop: 20 }} type="primary" onClick={onSearch} icon={<SearchOutlined />}> <Button style={{ width: 200, marginTop: 20 }} type="primary" onClick={onSearch} icon={<SearchOutlined />}>
{t('history.search.messages')} {t('history.search.messages')}

View File

@ -3,7 +3,7 @@ import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService'
import store from '@renderer/store' import store from '@renderer/store'
import { messageBlocksSelectors } from '@renderer/store/messageBlock' import { messageBlocksSelectors } from '@renderer/store/messageBlock'
import { MessageBlockStatus } from '@renderer/types/newMessage' 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 type { Node } from 'mdast'
import React, { memo, useCallback, useMemo } from 'react' import React, { memo, useCallback, useMemo } from 'react'
@ -16,8 +16,9 @@ interface Props {
} }
const CodeBlock: React.FC<Props> = ({ children, className, node, blockId }) => { const CodeBlock: React.FC<Props> = ({ children, className, node, blockId }) => {
const match = /language-([\w-+]+)/.exec(className || '') || children?.includes('\n') const languageMatch = /language-([\w-+]+)/.exec(className || '')
const language = match?.[1] ?? 'text' const isMultiline = children?.includes('\n')
const language = languageMatch?.[1] ?? (isMultiline ? 'text' : null)
// 代码块 id // 代码块 id
const id = useMemo(() => getCodeBlockId(node?.position?.start), [node?.position?.start]) 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] [blockId, id]
) )
if (match) { if (language !== null) {
// HTML 代码块特殊处理 // HTML 代码块特殊处理
// FIXME: 感觉没有必要用 isHtmlCode 判断
if (language === 'html') { 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 ( return (

View File

@ -10,6 +10,7 @@ const mocks = vi.hoisted(() => ({
emit: vi.fn() emit: vi.fn()
}, },
getCodeBlockId: vi.fn(), getCodeBlockId: vi.fn(),
isOpenFenceBlock: vi.fn(),
selectById: vi.fn(), selectById: vi.fn(),
CodeBlockView: vi.fn(({ onSave, children }) => ( CodeBlockView: vi.fn(({ onSave, children }) => (
<div> <div>
@ -36,7 +37,8 @@ vi.mock('@renderer/services/EventService', () => ({
})) }))
vi.mock('@renderer/utils/markdown', () => ({ vi.mock('@renderer/utils/markdown', () => ({
getCodeBlockId: mocks.getCodeBlockId getCodeBlockId: mocks.getCodeBlockId,
isOpenFenceBlock: mocks.isOpenFenceBlock
})) }))
vi.mock('@renderer/store', () => ({ vi.mock('@renderer/store', () => ({
@ -74,6 +76,7 @@ describe('CodeBlock', () => {
vi.clearAllMocks() vi.clearAllMocks()
// Default mock return values // Default mock return values
mocks.getCodeBlockId.mockReturnValue('test-code-block-id') mocks.getCodeBlockId.mockReturnValue('test-code-block-id')
mocks.isOpenFenceBlock.mockReturnValue(false)
mocks.selectById.mockReturnValue({ mocks.selectById.mockReturnValue({
id: 'test-msg-block-id', id: 'test-msg-block-id',
status: MessageBlockStatus.SUCCESS status: MessageBlockStatus.SUCCESS

View File

@ -286,6 +286,7 @@ const NewApiPage: FC<{ Options: string[] }> = ({ Options }) => {
const formData = new FormData() const formData = new FormData()
formData.append('prompt', prompt) formData.append('prompt', prompt)
formData.append('model', painting.model)
if (painting.background && painting.background !== 'auto') { if (painting.background && painting.background !== 'auto') {
formData.append('background', painting.background) formData.append('background', painting.background)
} }

View File

@ -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 { DEFAULT_CONTEXTCOUNT, DEFAULT_TEMPERATURE } from '@renderer/config/constant'
import { TopicManager } from '@renderer/hooks/useTopic' import { TopicManager } from '@renderer/hooks/useTopic'
import { getDefaultAssistant, getDefaultTopic } from '@renderer/services/AssistantService' import { getDefaultAssistant, getDefaultTopic } from '@renderer/services/AssistantService'
import { Assistant, AssistantSettings, Model, Topic } from '@renderer/types' import { Assistant, AssistantSettings, Model, Topic } from '@renderer/types'
import { isEmpty, uniqBy } from 'lodash' import { isEmpty, uniqBy } from 'lodash'
import { RootState } from '.'
export interface AssistantsState { export interface AssistantsState {
defaultAssistant: Assistant defaultAssistant: Assistant
assistants: Assistant[] assistants: Assistant[]
@ -30,7 +32,13 @@ const assistantsSlice = createSlice({
state.assistants = action.payload state.assistants = action.payload
}, },
addAssistant: (state, action: PayloadAction<Assistant>) => { 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 }>) => { insertAssistant: (state, action: PayloadAction<{ index: number; assistant: Assistant }>) => {
const { index, assistant } = action.payload const { index, assistant } = action.payload
@ -194,4 +202,15 @@ export const {
updateTagCollapse updateTagCollapse
} = assistantsSlice.actions } = 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 export default assistantsSlice.reducer

View File

@ -2,6 +2,7 @@ import remarkParse from 'remark-parse'
import remarkStringify from 'remark-stringify' import remarkStringify from 'remark-stringify'
import removeMarkdown from 'remove-markdown' import removeMarkdown from 'remove-markdown'
import { unified } from 'unified' import { unified } from 'unified'
import type { Point, Position } from 'unist'
import { visit } from 'unist-util-visit' import { visit } from 'unist-util-visit'
/** /**
@ -189,7 +190,7 @@ export function removeTrailingDoubleSpaces(markdown: string): string {
* @param start * @param start
* @returns Markdown ID * @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 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) 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特征 * HTML特征
* @param code * @param code