mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2026-01-09 23:10:20 +08:00
feat: allowing notes to be renamed using LLM (#10487)
* feat: implement auto-renaming feature for notes * feat: motion effects for auto renaming in notes * feat: add i18n for zh-tw for auto renaming in notes * chore: lint
This commit is contained in:
parent
53e38ed1aa
commit
e7e5c0456f
@ -1697,6 +1697,12 @@
|
|||||||
"provider_settings": "Go to provider settings"
|
"provider_settings": "Go to provider settings"
|
||||||
},
|
},
|
||||||
"notes": {
|
"notes": {
|
||||||
|
"auto_rename": {
|
||||||
|
"empty_note": "Note is empty, cannot generate name",
|
||||||
|
"failed": "Failed to generate note name",
|
||||||
|
"label": "Generate Note Name",
|
||||||
|
"success": "Note name generated successfully"
|
||||||
|
},
|
||||||
"characters": "Characters",
|
"characters": "Characters",
|
||||||
"collapse": "Collapse",
|
"collapse": "Collapse",
|
||||||
"content_placeholder": "Please enter the note content...",
|
"content_placeholder": "Please enter the note content...",
|
||||||
|
|||||||
@ -1697,6 +1697,12 @@
|
|||||||
"provider_settings": "跳转到服务商设置界面"
|
"provider_settings": "跳转到服务商设置界面"
|
||||||
},
|
},
|
||||||
"notes": {
|
"notes": {
|
||||||
|
"auto_rename": {
|
||||||
|
"empty_note": "笔记为空,无法生成名称",
|
||||||
|
"failed": "生成笔记名称失败",
|
||||||
|
"label": "生成笔记名称",
|
||||||
|
"success": "笔记名称生成成功"
|
||||||
|
},
|
||||||
"characters": "字符",
|
"characters": "字符",
|
||||||
"collapse": "收起",
|
"collapse": "收起",
|
||||||
"content_placeholder": "请输入笔记内容...",
|
"content_placeholder": "请输入笔记内容...",
|
||||||
|
|||||||
@ -1697,6 +1697,12 @@
|
|||||||
"provider_settings": "跳轉到服務商設置界面"
|
"provider_settings": "跳轉到服務商設置界面"
|
||||||
},
|
},
|
||||||
"notes": {
|
"notes": {
|
||||||
|
"auto_rename": {
|
||||||
|
"empty_note": "筆記為空,無法生成名稱",
|
||||||
|
"failed": "生成筆記名稱失敗",
|
||||||
|
"label": "生成筆記名稱",
|
||||||
|
"success": "筆記名稱生成成功"
|
||||||
|
},
|
||||||
"characters": "字符",
|
"characters": "字符",
|
||||||
"collapse": "收起",
|
"collapse": "收起",
|
||||||
"content_placeholder": "請輸入筆記內容...",
|
"content_placeholder": "請輸入筆記內容...",
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import { useInPlaceEdit } from '@renderer/hooks/useInPlaceEdit'
|
|||||||
import { useKnowledgeBases } from '@renderer/hooks/useKnowledge'
|
import { useKnowledgeBases } from '@renderer/hooks/useKnowledge'
|
||||||
import { useActiveNode } from '@renderer/hooks/useNotesQuery'
|
import { useActiveNode } from '@renderer/hooks/useNotesQuery'
|
||||||
import NotesSidebarHeader from '@renderer/pages/notes/NotesSidebarHeader'
|
import NotesSidebarHeader from '@renderer/pages/notes/NotesSidebarHeader'
|
||||||
|
import { fetchNoteSummary } from '@renderer/services/ApiService'
|
||||||
import { RootState, useAppSelector } from '@renderer/store'
|
import { RootState, useAppSelector } from '@renderer/store'
|
||||||
import { selectSortType } from '@renderer/store/note'
|
import { selectSortType } from '@renderer/store/note'
|
||||||
import { NotesSortType, NotesTreeNode } from '@renderer/types/note'
|
import { NotesSortType, NotesTreeNode } from '@renderer/types/note'
|
||||||
@ -22,6 +23,7 @@ import {
|
|||||||
FileSearch,
|
FileSearch,
|
||||||
Folder,
|
Folder,
|
||||||
FolderOpen,
|
FolderOpen,
|
||||||
|
Sparkles,
|
||||||
Star,
|
Star,
|
||||||
StarOff,
|
StarOff,
|
||||||
UploadIcon
|
UploadIcon
|
||||||
@ -54,6 +56,8 @@ interface TreeNodeProps {
|
|||||||
selectedFolderId?: string | null
|
selectedFolderId?: string | null
|
||||||
activeNodeId?: string
|
activeNodeId?: string
|
||||||
editingNodeId: string | null
|
editingNodeId: string | null
|
||||||
|
renamingNodeIds: Set<string>
|
||||||
|
newlyRenamedNodeIds: Set<string>
|
||||||
draggedNodeId: string | null
|
draggedNodeId: string | null
|
||||||
dragOverNodeId: string | null
|
dragOverNodeId: string | null
|
||||||
dragPosition: 'before' | 'inside' | 'after'
|
dragPosition: 'before' | 'inside' | 'after'
|
||||||
@ -76,6 +80,8 @@ const TreeNode = memo<TreeNodeProps>(
|
|||||||
selectedFolderId,
|
selectedFolderId,
|
||||||
activeNodeId,
|
activeNodeId,
|
||||||
editingNodeId,
|
editingNodeId,
|
||||||
|
renamingNodeIds,
|
||||||
|
newlyRenamedNodeIds,
|
||||||
draggedNodeId,
|
draggedNodeId,
|
||||||
dragOverNodeId,
|
dragOverNodeId,
|
||||||
dragPosition,
|
dragPosition,
|
||||||
@ -96,6 +102,8 @@ const TreeNode = memo<TreeNodeProps>(
|
|||||||
? node.type === 'folder' && node.id === selectedFolderId
|
? node.type === 'folder' && node.id === selectedFolderId
|
||||||
: node.id === activeNodeId
|
: node.id === activeNodeId
|
||||||
const isEditing = editingNodeId === node.id && inPlaceEdit.isEditing
|
const isEditing = editingNodeId === node.id && inPlaceEdit.isEditing
|
||||||
|
const isRenaming = renamingNodeIds.has(node.id)
|
||||||
|
const isNewlyRenamed = newlyRenamedNodeIds.has(node.id)
|
||||||
const hasChildren = node.children && node.children.length > 0
|
const hasChildren = node.children && node.children.length > 0
|
||||||
const isDragging = draggedNodeId === node.id
|
const isDragging = draggedNodeId === node.id
|
||||||
const isDragOver = dragOverNodeId === node.id
|
const isDragOver = dragOverNodeId === node.id
|
||||||
@ -103,6 +111,12 @@ const TreeNode = memo<TreeNodeProps>(
|
|||||||
const isDragInside = isDragOver && dragPosition === 'inside'
|
const isDragInside = isDragOver && dragPosition === 'inside'
|
||||||
const isDragAfter = isDragOver && dragPosition === 'after'
|
const isDragAfter = isDragOver && dragPosition === 'after'
|
||||||
|
|
||||||
|
const getNodeNameClassName = () => {
|
||||||
|
if (isRenaming) return 'shimmer'
|
||||||
|
if (isNewlyRenamed) return 'typing'
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={node.id}>
|
<div key={node.id}>
|
||||||
<Dropdown menu={{ items: getMenuItems(node) }} trigger={['contextMenu']}>
|
<Dropdown menu={{ items: getMenuItems(node) }} trigger={['contextMenu']}>
|
||||||
@ -160,7 +174,7 @@ const TreeNode = memo<TreeNodeProps>(
|
|||||||
size="small"
|
size="small"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<NodeName>{node.name}</NodeName>
|
<NodeName className={getNodeNameClassName()}>{node.name}</NodeName>
|
||||||
)}
|
)}
|
||||||
</TreeNodeContent>
|
</TreeNodeContent>
|
||||||
</TreeNodeContainer>
|
</TreeNodeContainer>
|
||||||
@ -177,6 +191,8 @@ const TreeNode = memo<TreeNodeProps>(
|
|||||||
selectedFolderId={selectedFolderId}
|
selectedFolderId={selectedFolderId}
|
||||||
activeNodeId={activeNodeId}
|
activeNodeId={activeNodeId}
|
||||||
editingNodeId={editingNodeId}
|
editingNodeId={editingNodeId}
|
||||||
|
renamingNodeIds={renamingNodeIds}
|
||||||
|
newlyRenamedNodeIds={newlyRenamedNodeIds}
|
||||||
draggedNodeId={draggedNodeId}
|
draggedNodeId={draggedNodeId}
|
||||||
dragOverNodeId={dragOverNodeId}
|
dragOverNodeId={dragOverNodeId}
|
||||||
dragPosition={dragPosition}
|
dragPosition={dragPosition}
|
||||||
@ -219,6 +235,8 @@ const NotesSidebar: FC<NotesSidebarProps> = ({
|
|||||||
const sortType = useAppSelector(selectSortType)
|
const sortType = useAppSelector(selectSortType)
|
||||||
const exportMenuOptions = useSelector((state: RootState) => state.settings.exportMenuOptions)
|
const exportMenuOptions = useSelector((state: RootState) => state.settings.exportMenuOptions)
|
||||||
const [editingNodeId, setEditingNodeId] = useState<string | null>(null)
|
const [editingNodeId, setEditingNodeId] = useState<string | null>(null)
|
||||||
|
const [renamingNodeIds, setRenamingNodeIds] = useState<Set<string>>(new Set())
|
||||||
|
const [newlyRenamedNodeIds, setNewlyRenamedNodeIds] = useState<Set<string>>(new Set())
|
||||||
const [draggedNodeId, setDraggedNodeId] = useState<string | null>(null)
|
const [draggedNodeId, setDraggedNodeId] = useState<string | null>(null)
|
||||||
const [dragOverNodeId, setDragOverNodeId] = useState<string | null>(null)
|
const [dragOverNodeId, setDragOverNodeId] = useState<string | null>(null)
|
||||||
const [dragPosition, setDragPosition] = useState<'before' | 'inside' | 'after'>('inside')
|
const [dragPosition, setDragPosition] = useState<'before' | 'inside' | 'after'>('inside')
|
||||||
@ -341,6 +359,49 @@ const NotesSidebar: FC<NotesSidebarProps> = ({
|
|||||||
[bases.length, t]
|
[bases.length, t]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const handleAutoRename = useCallback(
|
||||||
|
async (note: NotesTreeNode) => {
|
||||||
|
if (note.type !== 'file') return
|
||||||
|
|
||||||
|
setRenamingNodeIds((prev) => new Set(prev).add(note.id))
|
||||||
|
try {
|
||||||
|
const content = await window.api.file.readExternal(note.externalPath)
|
||||||
|
if (!content || content.trim().length === 0) {
|
||||||
|
window.toast.warning(t('notes.auto_rename.empty_note'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const summaryText = await fetchNoteSummary({ content })
|
||||||
|
if (summaryText) {
|
||||||
|
onRenameNode(note.id, summaryText)
|
||||||
|
window.toast.success(t('notes.auto_rename.success'))
|
||||||
|
} else {
|
||||||
|
window.toast.error(t('notes.auto_rename.failed'))
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
window.toast.error(t('notes.auto_rename.failed'))
|
||||||
|
logger.error(`Failed to auto-rename note: ${error}`)
|
||||||
|
} finally {
|
||||||
|
setRenamingNodeIds((prev) => {
|
||||||
|
const next = new Set(prev)
|
||||||
|
next.delete(note.id)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
|
||||||
|
setNewlyRenamedNodeIds((prev) => new Set(prev).add(note.id))
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
setNewlyRenamedNodeIds((prev) => {
|
||||||
|
const next = new Set(prev)
|
||||||
|
next.delete(note.id)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}, 700)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[onRenameNode, t]
|
||||||
|
)
|
||||||
|
|
||||||
const handleDragStart = useCallback((e: React.DragEvent, node: NotesTreeNode) => {
|
const handleDragStart = useCallback((e: React.DragEvent, node: NotesTreeNode) => {
|
||||||
setDraggedNodeId(node.id)
|
setDraggedNodeId(node.id)
|
||||||
e.dataTransfer.effectAllowed = 'move'
|
e.dataTransfer.effectAllowed = 'move'
|
||||||
@ -495,7 +556,22 @@ const NotesSidebar: FC<NotesSidebarProps> = ({
|
|||||||
|
|
||||||
const getMenuItems = useCallback(
|
const getMenuItems = useCallback(
|
||||||
(node: NotesTreeNode) => {
|
(node: NotesTreeNode) => {
|
||||||
const baseMenuItems: MenuProps['items'] = [
|
const baseMenuItems: MenuProps['items'] = []
|
||||||
|
|
||||||
|
// only show auto rename for file for now
|
||||||
|
if (node.type !== 'folder') {
|
||||||
|
baseMenuItems.push({
|
||||||
|
label: t('notes.auto_rename.label'),
|
||||||
|
key: 'auto-rename',
|
||||||
|
icon: <Sparkles size={14} />,
|
||||||
|
disabled: renamingNodeIds.has(node.id),
|
||||||
|
onClick: () => {
|
||||||
|
handleAutoRename(node)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
baseMenuItems.push(
|
||||||
{
|
{
|
||||||
label: t('notes.rename'),
|
label: t('notes.rename'),
|
||||||
key: 'rename',
|
key: 'rename',
|
||||||
@ -512,7 +588,7 @@ const NotesSidebar: FC<NotesSidebarProps> = ({
|
|||||||
window.api.openPath(node.externalPath)
|
window.api.openPath(node.externalPath)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
)
|
||||||
if (node.type !== 'folder') {
|
if (node.type !== 'folder') {
|
||||||
baseMenuItems.push(
|
baseMenuItems.push(
|
||||||
{
|
{
|
||||||
@ -590,7 +666,16 @@ const NotesSidebar: FC<NotesSidebarProps> = ({
|
|||||||
|
|
||||||
return baseMenuItems
|
return baseMenuItems
|
||||||
},
|
},
|
||||||
[t, handleStartEdit, onToggleStar, handleExportKnowledge, handleDeleteNode, exportMenuOptions]
|
[
|
||||||
|
t,
|
||||||
|
handleStartEdit,
|
||||||
|
onToggleStar,
|
||||||
|
handleExportKnowledge,
|
||||||
|
handleDeleteNode,
|
||||||
|
renamingNodeIds,
|
||||||
|
handleAutoRename,
|
||||||
|
exportMenuOptions
|
||||||
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
const handleDropFiles = useCallback(
|
const handleDropFiles = useCallback(
|
||||||
@ -727,6 +812,8 @@ const NotesSidebar: FC<NotesSidebarProps> = ({
|
|||||||
selectedFolderId={selectedFolderId}
|
selectedFolderId={selectedFolderId}
|
||||||
activeNodeId={activeNode?.id}
|
activeNodeId={activeNode?.id}
|
||||||
editingNodeId={editingNodeId}
|
editingNodeId={editingNodeId}
|
||||||
|
renamingNodeIds={renamingNodeIds}
|
||||||
|
newlyRenamedNodeIds={newlyRenamedNodeIds}
|
||||||
draggedNodeId={draggedNodeId}
|
draggedNodeId={draggedNodeId}
|
||||||
dragOverNodeId={dragOverNodeId}
|
dragOverNodeId={dragOverNodeId}
|
||||||
dragPosition={dragPosition}
|
dragPosition={dragPosition}
|
||||||
@ -771,6 +858,8 @@ const NotesSidebar: FC<NotesSidebarProps> = ({
|
|||||||
selectedFolderId={selectedFolderId}
|
selectedFolderId={selectedFolderId}
|
||||||
activeNodeId={activeNode?.id}
|
activeNodeId={activeNode?.id}
|
||||||
editingNodeId={editingNodeId}
|
editingNodeId={editingNodeId}
|
||||||
|
renamingNodeIds={renamingNodeIds}
|
||||||
|
newlyRenamedNodeIds={newlyRenamedNodeIds}
|
||||||
draggedNodeId={draggedNodeId}
|
draggedNodeId={draggedNodeId}
|
||||||
dragOverNodeId={dragOverNodeId}
|
dragOverNodeId={dragOverNodeId}
|
||||||
dragPosition={dragPosition}
|
dragPosition={dragPosition}
|
||||||
@ -793,6 +882,8 @@ const NotesSidebar: FC<NotesSidebarProps> = ({
|
|||||||
selectedFolderId={selectedFolderId}
|
selectedFolderId={selectedFolderId}
|
||||||
activeNodeId={activeNode?.id}
|
activeNodeId={activeNode?.id}
|
||||||
editingNodeId={editingNodeId}
|
editingNodeId={editingNodeId}
|
||||||
|
renamingNodeIds={renamingNodeIds}
|
||||||
|
newlyRenamedNodeIds={newlyRenamedNodeIds}
|
||||||
draggedNodeId={draggedNodeId}
|
draggedNodeId={draggedNodeId}
|
||||||
dragOverNodeId={dragOverNodeId}
|
dragOverNodeId={dragOverNodeId}
|
||||||
dragPosition={dragPosition}
|
dragPosition={dragPosition}
|
||||||
@ -980,6 +1071,44 @@ const NodeName = styled.div`
|
|||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
|
position: relative;
|
||||||
|
will-change: background-position, width;
|
||||||
|
|
||||||
|
--color-shimmer-mid: var(--color-text-1);
|
||||||
|
--color-shimmer-end: color-mix(in srgb, var(--color-text-1) 25%, transparent);
|
||||||
|
|
||||||
|
&.shimmer {
|
||||||
|
background: linear-gradient(to left, var(--color-shimmer-end), var(--color-shimmer-mid), var(--color-shimmer-end));
|
||||||
|
background-size: 200% 100%;
|
||||||
|
background-clip: text;
|
||||||
|
color: transparent;
|
||||||
|
animation: shimmer 3s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.typing {
|
||||||
|
display: block;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
animation: typewriter 0.5s steps(40, end);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes shimmer {
|
||||||
|
0% {
|
||||||
|
background-position: 200% 0;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
background-position: -200% 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes typewriter {
|
||||||
|
from {
|
||||||
|
width: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
`
|
`
|
||||||
|
|
||||||
const EditInput = styled(Input)`
|
const EditInput = styled(Input)`
|
||||||
|
|||||||
@ -251,6 +251,68 @@ export async function fetchMessagesSummary({ messages, assistant }: { messages:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function fetchNoteSummary({ content, assistant }: { content: string; assistant?: Assistant }) {
|
||||||
|
let prompt = (getStoreSetting('topicNamingPrompt') as string) || i18n.t('prompts.title')
|
||||||
|
const resolvedAssistant = assistant || getDefaultAssistant()
|
||||||
|
const model = getQuickModel() || resolvedAssistant.model || getDefaultModel()
|
||||||
|
|
||||||
|
if (prompt && containsSupportedVariables(prompt)) {
|
||||||
|
prompt = await replacePromptVariables(prompt, model.name)
|
||||||
|
}
|
||||||
|
|
||||||
|
const provider = getProviderByModel(model)
|
||||||
|
|
||||||
|
if (!hasApiKey(provider)) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const AI = new AiProviderNew(model)
|
||||||
|
|
||||||
|
// only 2000 char and no images
|
||||||
|
const truncatedContent = content.substring(0, 2000)
|
||||||
|
const purifiedContent = purifyMarkdownImages(truncatedContent)
|
||||||
|
|
||||||
|
const summaryAssistant = {
|
||||||
|
...resolvedAssistant,
|
||||||
|
settings: {
|
||||||
|
...resolvedAssistant.settings,
|
||||||
|
reasoning_effort: undefined,
|
||||||
|
qwenThinkMode: false
|
||||||
|
},
|
||||||
|
prompt,
|
||||||
|
model
|
||||||
|
}
|
||||||
|
|
||||||
|
const llmMessages = {
|
||||||
|
system: prompt,
|
||||||
|
prompt: purifiedContent
|
||||||
|
}
|
||||||
|
|
||||||
|
const middlewareConfig: AiSdkMiddlewareConfig = {
|
||||||
|
streamOutput: false,
|
||||||
|
enableReasoning: false,
|
||||||
|
isPromptToolUse: false,
|
||||||
|
isSupportedToolUse: false,
|
||||||
|
isImageGenerationEndpoint: false,
|
||||||
|
enableWebSearch: false,
|
||||||
|
enableGenerateImage: false,
|
||||||
|
enableUrlContext: false,
|
||||||
|
mcpTools: []
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { getText } = await AI.completions(model.id, llmMessages, {
|
||||||
|
...middlewareConfig,
|
||||||
|
assistant: summaryAssistant,
|
||||||
|
callType: 'summary'
|
||||||
|
})
|
||||||
|
const text = getText()
|
||||||
|
return removeSpecialCharactersForTopicName(text) || null
|
||||||
|
} catch (error: any) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// export async function fetchSearchSummary({ messages, assistant }: { messages: Message[]; assistant: Assistant }) {
|
// export async function fetchSearchSummary({ messages, assistant }: { messages: Message[]; assistant: Assistant }) {
|
||||||
// const model = getQuickModel() || assistant.model || getDefaultModel()
|
// const model = getQuickModel() || assistant.model || getDefaultModel()
|
||||||
// const provider = getProviderByModel(model)
|
// const provider = getProviderByModel(model)
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user