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:
Tristan Zhang 2025-10-02 20:45:46 +08:00 committed by GitHub
parent 53e38ed1aa
commit e7e5c0456f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 213 additions and 4 deletions

View File

@ -1697,6 +1697,12 @@
"provider_settings": "Go to provider settings"
},
"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",
"collapse": "Collapse",
"content_placeholder": "Please enter the note content...",

View File

@ -1697,6 +1697,12 @@
"provider_settings": "跳转到服务商设置界面"
},
"notes": {
"auto_rename": {
"empty_note": "笔记为空,无法生成名称",
"failed": "生成笔记名称失败",
"label": "生成笔记名称",
"success": "笔记名称生成成功"
},
"characters": "字符",
"collapse": "收起",
"content_placeholder": "请输入笔记内容...",

View File

@ -1697,6 +1697,12 @@
"provider_settings": "跳轉到服務商設置界面"
},
"notes": {
"auto_rename": {
"empty_note": "筆記為空,無法生成名稱",
"failed": "生成筆記名稱失敗",
"label": "生成筆記名稱",
"success": "筆記名稱生成成功"
},
"characters": "字符",
"collapse": "收起",
"content_placeholder": "請輸入筆記內容...",

View File

@ -6,6 +6,7 @@ import { useInPlaceEdit } from '@renderer/hooks/useInPlaceEdit'
import { useKnowledgeBases } from '@renderer/hooks/useKnowledge'
import { useActiveNode } from '@renderer/hooks/useNotesQuery'
import NotesSidebarHeader from '@renderer/pages/notes/NotesSidebarHeader'
import { fetchNoteSummary } from '@renderer/services/ApiService'
import { RootState, useAppSelector } from '@renderer/store'
import { selectSortType } from '@renderer/store/note'
import { NotesSortType, NotesTreeNode } from '@renderer/types/note'
@ -22,6 +23,7 @@ import {
FileSearch,
Folder,
FolderOpen,
Sparkles,
Star,
StarOff,
UploadIcon
@ -54,6 +56,8 @@ interface TreeNodeProps {
selectedFolderId?: string | null
activeNodeId?: string
editingNodeId: string | null
renamingNodeIds: Set<string>
newlyRenamedNodeIds: Set<string>
draggedNodeId: string | null
dragOverNodeId: string | null
dragPosition: 'before' | 'inside' | 'after'
@ -76,6 +80,8 @@ const TreeNode = memo<TreeNodeProps>(
selectedFolderId,
activeNodeId,
editingNodeId,
renamingNodeIds,
newlyRenamedNodeIds,
draggedNodeId,
dragOverNodeId,
dragPosition,
@ -96,6 +102,8 @@ const TreeNode = memo<TreeNodeProps>(
? node.type === 'folder' && node.id === selectedFolderId
: node.id === activeNodeId
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 isDragging = draggedNodeId === node.id
const isDragOver = dragOverNodeId === node.id
@ -103,6 +111,12 @@ const TreeNode = memo<TreeNodeProps>(
const isDragInside = isDragOver && dragPosition === 'inside'
const isDragAfter = isDragOver && dragPosition === 'after'
const getNodeNameClassName = () => {
if (isRenaming) return 'shimmer'
if (isNewlyRenamed) return 'typing'
return ''
}
return (
<div key={node.id}>
<Dropdown menu={{ items: getMenuItems(node) }} trigger={['contextMenu']}>
@ -160,7 +174,7 @@ const TreeNode = memo<TreeNodeProps>(
size="small"
/>
) : (
<NodeName>{node.name}</NodeName>
<NodeName className={getNodeNameClassName()}>{node.name}</NodeName>
)}
</TreeNodeContent>
</TreeNodeContainer>
@ -177,6 +191,8 @@ const TreeNode = memo<TreeNodeProps>(
selectedFolderId={selectedFolderId}
activeNodeId={activeNodeId}
editingNodeId={editingNodeId}
renamingNodeIds={renamingNodeIds}
newlyRenamedNodeIds={newlyRenamedNodeIds}
draggedNodeId={draggedNodeId}
dragOverNodeId={dragOverNodeId}
dragPosition={dragPosition}
@ -219,6 +235,8 @@ const NotesSidebar: FC<NotesSidebarProps> = ({
const sortType = useAppSelector(selectSortType)
const exportMenuOptions = useSelector((state: RootState) => state.settings.exportMenuOptions)
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 [dragOverNodeId, setDragOverNodeId] = useState<string | null>(null)
const [dragPosition, setDragPosition] = useState<'before' | 'inside' | 'after'>('inside')
@ -341,6 +359,49 @@ const NotesSidebar: FC<NotesSidebarProps> = ({
[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) => {
setDraggedNodeId(node.id)
e.dataTransfer.effectAllowed = 'move'
@ -495,7 +556,22 @@ const NotesSidebar: FC<NotesSidebarProps> = ({
const getMenuItems = useCallback(
(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'),
key: 'rename',
@ -512,7 +588,7 @@ const NotesSidebar: FC<NotesSidebarProps> = ({
window.api.openPath(node.externalPath)
}
}
]
)
if (node.type !== 'folder') {
baseMenuItems.push(
{
@ -590,7 +666,16 @@ const NotesSidebar: FC<NotesSidebarProps> = ({
return baseMenuItems
},
[t, handleStartEdit, onToggleStar, handleExportKnowledge, handleDeleteNode, exportMenuOptions]
[
t,
handleStartEdit,
onToggleStar,
handleExportKnowledge,
handleDeleteNode,
renamingNodeIds,
handleAutoRename,
exportMenuOptions
]
)
const handleDropFiles = useCallback(
@ -727,6 +812,8 @@ const NotesSidebar: FC<NotesSidebarProps> = ({
selectedFolderId={selectedFolderId}
activeNodeId={activeNode?.id}
editingNodeId={editingNodeId}
renamingNodeIds={renamingNodeIds}
newlyRenamedNodeIds={newlyRenamedNodeIds}
draggedNodeId={draggedNodeId}
dragOverNodeId={dragOverNodeId}
dragPosition={dragPosition}
@ -771,6 +858,8 @@ const NotesSidebar: FC<NotesSidebarProps> = ({
selectedFolderId={selectedFolderId}
activeNodeId={activeNode?.id}
editingNodeId={editingNodeId}
renamingNodeIds={renamingNodeIds}
newlyRenamedNodeIds={newlyRenamedNodeIds}
draggedNodeId={draggedNodeId}
dragOverNodeId={dragOverNodeId}
dragPosition={dragPosition}
@ -793,6 +882,8 @@ const NotesSidebar: FC<NotesSidebarProps> = ({
selectedFolderId={selectedFolderId}
activeNodeId={activeNode?.id}
editingNodeId={editingNodeId}
renamingNodeIds={renamingNodeIds}
newlyRenamedNodeIds={newlyRenamedNodeIds}
draggedNodeId={draggedNodeId}
dragOverNodeId={dragOverNodeId}
dragPosition={dragPosition}
@ -980,6 +1071,44 @@ const NodeName = styled.div`
text-overflow: ellipsis;
font-size: 13px;
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)`

View File

@ -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 }) {
// const model = getQuickModel() || assistant.model || getDefaultModel()
// const provider = getProviderByModel(model)