From e7e5c0456f7fab4d289cd23c53cf4bc0d61c420d Mon Sep 17 00:00:00 2001 From: Tristan Zhang <82869104+ABucket@users.noreply.github.com> Date: Thu, 2 Oct 2025 20:45:46 +0800 Subject: [PATCH] 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 --- src/renderer/src/i18n/locales/en-us.json | 6 + src/renderer/src/i18n/locales/zh-cn.json | 6 + src/renderer/src/i18n/locales/zh-tw.json | 6 + src/renderer/src/pages/notes/NotesSidebar.tsx | 137 +++++++++++++++++- src/renderer/src/services/ApiService.ts | 62 ++++++++ 5 files changed, 213 insertions(+), 4 deletions(-) diff --git a/src/renderer/src/i18n/locales/en-us.json b/src/renderer/src/i18n/locales/en-us.json index 94abc7667d..0f3d2a3f24 100644 --- a/src/renderer/src/i18n/locales/en-us.json +++ b/src/renderer/src/i18n/locales/en-us.json @@ -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...", diff --git a/src/renderer/src/i18n/locales/zh-cn.json b/src/renderer/src/i18n/locales/zh-cn.json index c09c94cbbf..ce2dc5c222 100644 --- a/src/renderer/src/i18n/locales/zh-cn.json +++ b/src/renderer/src/i18n/locales/zh-cn.json @@ -1697,6 +1697,12 @@ "provider_settings": "跳转到服务商设置界面" }, "notes": { + "auto_rename": { + "empty_note": "笔记为空,无法生成名称", + "failed": "生成笔记名称失败", + "label": "生成笔记名称", + "success": "笔记名称生成成功" + }, "characters": "字符", "collapse": "收起", "content_placeholder": "请输入笔记内容...", diff --git a/src/renderer/src/i18n/locales/zh-tw.json b/src/renderer/src/i18n/locales/zh-tw.json index a1aa1cc7fa..e4f45288ee 100644 --- a/src/renderer/src/i18n/locales/zh-tw.json +++ b/src/renderer/src/i18n/locales/zh-tw.json @@ -1697,6 +1697,12 @@ "provider_settings": "跳轉到服務商設置界面" }, "notes": { + "auto_rename": { + "empty_note": "筆記為空,無法生成名稱", + "failed": "生成筆記名稱失敗", + "label": "生成筆記名稱", + "success": "筆記名稱生成成功" + }, "characters": "字符", "collapse": "收起", "content_placeholder": "請輸入筆記內容...", diff --git a/src/renderer/src/pages/notes/NotesSidebar.tsx b/src/renderer/src/pages/notes/NotesSidebar.tsx index 54a3f80cdb..a53fc5b5f7 100644 --- a/src/renderer/src/pages/notes/NotesSidebar.tsx +++ b/src/renderer/src/pages/notes/NotesSidebar.tsx @@ -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 + newlyRenamedNodeIds: Set draggedNodeId: string | null dragOverNodeId: string | null dragPosition: 'before' | 'inside' | 'after' @@ -76,6 +80,8 @@ const TreeNode = memo( selectedFolderId, activeNodeId, editingNodeId, + renamingNodeIds, + newlyRenamedNodeIds, draggedNodeId, dragOverNodeId, dragPosition, @@ -96,6 +102,8 @@ const TreeNode = memo( ? 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( const isDragInside = isDragOver && dragPosition === 'inside' const isDragAfter = isDragOver && dragPosition === 'after' + const getNodeNameClassName = () => { + if (isRenaming) return 'shimmer' + if (isNewlyRenamed) return 'typing' + return '' + } + return (
@@ -160,7 +174,7 @@ const TreeNode = memo( size="small" /> ) : ( - {node.name} + {node.name} )} @@ -177,6 +191,8 @@ const TreeNode = memo( selectedFolderId={selectedFolderId} activeNodeId={activeNodeId} editingNodeId={editingNodeId} + renamingNodeIds={renamingNodeIds} + newlyRenamedNodeIds={newlyRenamedNodeIds} draggedNodeId={draggedNodeId} dragOverNodeId={dragOverNodeId} dragPosition={dragPosition} @@ -219,6 +235,8 @@ const NotesSidebar: FC = ({ const sortType = useAppSelector(selectSortType) const exportMenuOptions = useSelector((state: RootState) => state.settings.exportMenuOptions) const [editingNodeId, setEditingNodeId] = useState(null) + const [renamingNodeIds, setRenamingNodeIds] = useState>(new Set()) + const [newlyRenamedNodeIds, setNewlyRenamedNodeIds] = useState>(new Set()) const [draggedNodeId, setDraggedNodeId] = useState(null) const [dragOverNodeId, setDragOverNodeId] = useState(null) const [dragPosition, setDragPosition] = useState<'before' | 'inside' | 'after'>('inside') @@ -341,6 +359,49 @@ const NotesSidebar: FC = ({ [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 = ({ 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: , + disabled: renamingNodeIds.has(node.id), + onClick: () => { + handleAutoRename(node) + } + }) + } + + baseMenuItems.push( { label: t('notes.rename'), key: 'rename', @@ -512,7 +588,7 @@ const NotesSidebar: FC = ({ window.api.openPath(node.externalPath) } } - ] + ) if (node.type !== 'folder') { baseMenuItems.push( { @@ -590,7 +666,16 @@ const NotesSidebar: FC = ({ 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 = ({ selectedFolderId={selectedFolderId} activeNodeId={activeNode?.id} editingNodeId={editingNodeId} + renamingNodeIds={renamingNodeIds} + newlyRenamedNodeIds={newlyRenamedNodeIds} draggedNodeId={draggedNodeId} dragOverNodeId={dragOverNodeId} dragPosition={dragPosition} @@ -771,6 +858,8 @@ const NotesSidebar: FC = ({ selectedFolderId={selectedFolderId} activeNodeId={activeNode?.id} editingNodeId={editingNodeId} + renamingNodeIds={renamingNodeIds} + newlyRenamedNodeIds={newlyRenamedNodeIds} draggedNodeId={draggedNodeId} dragOverNodeId={dragOverNodeId} dragPosition={dragPosition} @@ -793,6 +882,8 @@ const NotesSidebar: FC = ({ 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)` diff --git a/src/renderer/src/services/ApiService.ts b/src/renderer/src/services/ApiService.ts index 64e2c1ae31..6ab07662cb 100644 --- a/src/renderer/src/services/ApiService.ts +++ b/src/renderer/src/services/ApiService.ts @@ -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)