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
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

View File

@ -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')

View File

@ -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)

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 { 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()

View File

@ -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;

View File

@ -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>

View File

@ -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 {

View File

@ -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(

View File

@ -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": {

View File

@ -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": {

View File

@ -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": {

View File

@ -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": {

View File

@ -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": {

View File

@ -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": {

View File

@ -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": {

View File

@ -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 nexiste pas, retour au fournisseur par défaut {{provider}}. Cela peut entraîner des problèmes."
},
"words": {

View File

@ -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": {

View File

@ -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

View File

@ -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)

View File

@ -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

View File

@ -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')}

View File

@ -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 (

View File

@ -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

View File

@ -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)
}

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 { 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

View File

@ -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