-
Notifications
You must be signed in to change notification settings - Fork 445
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Writing assistant v2 #487
base: main
Are you sure you want to change the base?
Writing assistant v2 #487
Changes from all commits
7617aad
c9863e8
868e425
cd761f3
1258003
fa837e7
5690753
f919081
5a861f8
c11f17e
f68dc51
03ce49a
a84c70e
94320ca
62c6ecb
4dc0047
7f2966a
a761a22
fb7f0a8
2c71e4f
79b5523
8b693cb
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,105 @@ | ||
/* eslint-disable @typescript-eslint/no-unused-vars */ | ||
import React, { useState } from 'react' | ||
import { streamText } from 'ai' | ||
import { ArrowUp } from 'lucide-react' | ||
import { Button } from '../ui/button' | ||
import resolveLLMClient from '@/lib/llm/client' | ||
import MarkdownRenderer from '../Common/MarkdownRenderer' | ||
|
||
interface AiEditMenuProps { | ||
selectedText: string | ||
onEdit: (newText: string) => void | ||
} | ||
|
||
const AiEditMenu = ({ selectedText, onEdit }: AiEditMenuProps) => { | ||
const [response, setResponse] = useState<string>('') | ||
const [isLoading, setIsLoading] = useState(false) | ||
const [instruction, setInstruction] = useState('') | ||
|
||
const handleEdit = async () => { | ||
try { | ||
setIsLoading(true) | ||
setResponse('') | ||
const defaultLLMName = await window.llm.getDefaultLLMName() | ||
const llmClient = await resolveLLMClient(defaultLLMName) | ||
const { textStream } = await streamText({ | ||
model: llmClient, | ||
messages: [ | ||
{ | ||
role: 'system', | ||
content: | ||
"Edit the user provided text following the user's instruction. You must respond in the same language as the user's instruction and only return the edited text.", | ||
}, | ||
{ | ||
role: 'user', | ||
content: `Instruction: ${instruction}\nText: ${selectedText}`, | ||
}, | ||
], | ||
}) | ||
|
||
// eslint-disable-next-line no-restricted-syntax | ||
for await (const textPart of textStream) { | ||
setResponse((prev) => prev + textPart) | ||
} | ||
} finally { | ||
setIsLoading(false) | ||
} | ||
} | ||
|
||
return ( | ||
<div className="flex flex-col gap-2"> | ||
{(response || isLoading) && ( | ||
<div className="relative rounded-md border border-border bg-background/95 p-3 text-sm text-foreground shadow-lg backdrop-blur"> | ||
{isLoading && !response && <div className="animate-pulse text-muted-foreground">Generating response...</div>} | ||
{response && ( | ||
<> | ||
<div className="prose prose-invert max-h-[400px] max-w-none overflow-y-auto"> | ||
<MarkdownRenderer content={response} /> | ||
</div> | ||
<div className="mt-2 flex justify-end"> | ||
<Button | ||
onClick={() => onEdit(response)} | ||
size="sm" | ||
variant="default" | ||
className="bg-purple-500 hover:bg-purple-600" | ||
> | ||
Apply Edit | ||
</Button> | ||
</div> | ||
</> | ||
)} | ||
</div> | ||
)} | ||
|
||
<div className="flex items-center gap-2 bg-background"> | ||
<textarea | ||
value={instruction} | ||
onChange={(e) => setInstruction(e.target.value)} | ||
placeholder="Enter your editing instruction..." | ||
onClick={(e) => e.stopPropagation()} | ||
onMouseDown={(e) => e.stopPropagation()} | ||
onKeyDown={(e) => { | ||
e.stopPropagation() | ||
if (e.key === 'Enter' && !e.shiftKey && !isLoading) { | ||
e.preventDefault() | ||
handleEdit() | ||
} | ||
}} | ||
rows={1} | ||
className="z-50 flex w-full flex-col overflow-hidden rounded border-2 border-solid border-border bg-background p-2 text-white outline-none focus-within:ring-1 focus-within:ring-ring" | ||
/> | ||
<Button | ||
onClick={handleEdit} | ||
size="icon" | ||
variant="ghost" | ||
className="text-purple-500 hover:bg-purple-500/10" | ||
disabled={isLoading} | ||
> | ||
<ArrowUp className="size-5" /> | ||
</Button> | ||
</div> | ||
</div> | ||
) | ||
} | ||
|
||
export default AiEditMenu |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,36 @@ | ||
import React, { useEffect, useState } from 'react' | ||
import { Editor } from '@tiptap/react' | ||
|
||
interface DocumentStatsProps { | ||
editor: Editor | null | ||
} | ||
|
||
const DocumentStats: React.FC<DocumentStatsProps> = ({ editor }) => { | ||
const [show, setShow] = useState(false) | ||
|
||
useEffect(() => { | ||
const initDocumentStats = async () => { | ||
const showStats = await window.electronStore.getDocumentStats() | ||
setShow(showStats) | ||
} | ||
|
||
initDocumentStats() | ||
|
||
const handleDocStatsChange = (event: Electron.IpcRendererEvent, value: boolean) => { | ||
setShow(value) | ||
} | ||
|
||
window.ipcRenderer.on('show-doc-stats-changed', handleDocStatsChange) | ||
}, []) | ||
|
||
if (!editor || !show) return null | ||
|
||
return ( | ||
<div className="absolute bottom-2 right-2 flex gap-4 text-sm text-gray-500"> | ||
<div>Characters: {editor.storage.characterCount.characters()}</div> | ||
<div>Words: {editor.storage.characterCount.words()}</div> | ||
</div> | ||
) | ||
} | ||
|
||
export default DocumentStats |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,20 +1,23 @@ | ||
/* eslint-disable react/button-has-type */ | ||
import React, { useEffect, useState } from 'react' | ||
import { EditorContent } from '@tiptap/react' | ||
import InEditorBacklinkSuggestionsDisplay from './BacklinkSuggestionsDisplay' | ||
import { EditorContent, BubbleMenu } from '@tiptap/react' | ||
import { getHTMLFromFragment, Range } from '@tiptap/core' | ||
import TurndownService from 'turndown' | ||
import EditorContextMenu from './EditorContextMenu' | ||
import SearchBar from './Search/SearchBar' | ||
import { useFileContext } from '@/contexts/FileContext' | ||
import { useContentContext } from '@/contexts/ContentContext' | ||
import DocumentStats from './DocumentStats' | ||
import AiEditMenu from './AIEdit' | ||
|
||
const EditorManager: React.FC = () => { | ||
const [showSearchBar, setShowSearchBar] = useState(false) | ||
const [contextMenuVisible, setContextMenuVisible] = useState(false) | ||
const [menuPosition, setMenuPosition] = useState({ x: 0, y: 0 }) | ||
const [editorFlex, setEditorFlex] = useState(true) | ||
|
||
const { editor, suggestionsState, vaultFilesFlattened } = useFileContext() | ||
const [showDocumentStats, setShowDocumentStats] = useState(false) | ||
const { openContent } = useContentContext() | ||
const [showAIPopup, setShowAIPopup] = useState(false) | ||
const { editor } = useFileContext() | ||
const turndownService = new TurndownService() | ||
const [selectedRange, setSelectedRange] = useState<Range | null>(null) | ||
|
||
const handleContextMenu = (event: React.MouseEvent<HTMLDivElement>) => { | ||
event.preventDefault() | ||
|
@@ -29,15 +32,6 @@ | |
if (contextMenuVisible) setContextMenuVisible(false) | ||
} | ||
|
||
const handleClick = (event: React.MouseEvent<HTMLDivElement>) => { | ||
const { target } = event | ||
if (target instanceof HTMLElement && target.getAttribute('data-backlink') === 'true') { | ||
event.preventDefault() | ||
const backlinkPath = target.textContent | ||
if (backlinkPath) openContent(backlinkPath) | ||
} | ||
} | ||
|
||
useEffect(() => { | ||
const initEditorContentCenter = async () => { | ||
const isCenter = await window.electronStore.getEditorFlexCenter() | ||
|
@@ -53,25 +47,109 @@ | |
}, []) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. logic: missing cleanup for ipcRenderer event listener |
||
|
||
useEffect(() => { | ||
const initDocumentStats = async () => { | ||
const showStats = await window.electronStore.getDocumentStats() | ||
setShowDocumentStats(showStats) | ||
if (!editor) return | ||
|
||
if (showAIPopup && selectedRange) { | ||
editor.chain().focus().setMark('highlight').run() | ||
} | ||
}, [showAIPopup, selectedRange, editor]) | ||
|
||
initDocumentStats() | ||
useEffect(() => { | ||
if (!editor) return | ||
|
||
const handleDocStatsChange = (event: Electron.IpcRendererEvent, value: boolean) => { | ||
setShowDocumentStats(value) | ||
if (!showAIPopup) { | ||
editor?.commands.clearFormatting() | ||
} | ||
}, [showAIPopup, editor]) | ||
|
||
window.ipcRenderer.on('show-doc-stats-changed', handleDocStatsChange) | ||
}, []) | ||
useEffect(() => { | ||
if (showAIPopup) { | ||
setTimeout(() => { | ||
const textarea = document.querySelector('.ai-edit-menu textarea') | ||
if (textarea instanceof HTMLTextAreaElement) { | ||
textarea.focus() | ||
} | ||
}, 50) | ||
} | ||
}, [showAIPopup]) | ||
|
||
return ( | ||
<div | ||
className="relative size-full cursor-text overflow-hidden bg-dark-gray-c-eleven py-4 text-slate-400 opacity-80" | ||
onClick={() => editor?.commands.focus()} | ||
> | ||
{editor && ( | ||
<BubbleMenu | ||
className="flex gap-2 rounded-lg bg-transparent px-2" | ||
editor={editor} | ||
tippyOptions={{ | ||
placement: 'auto', | ||
offset: [0, 10], | ||
interactive: true, | ||
interactiveBorder: 20, | ||
onHidden: () => { | ||
setShowAIPopup(false) | ||
setSelectedRange(null) | ||
}, | ||
maxWidth: 'none', | ||
}} | ||
> | ||
<div | ||
className="w-[300px]" | ||
onMouseDown={(e) => { | ||
e.preventDefault() | ||
e.stopPropagation() | ||
}} | ||
onMouseUp={(e) => { | ||
e.preventDefault() | ||
e.stopPropagation() | ||
|
||
if (!showAIPopup) { | ||
setSelectedRange({ | ||
from: editor.state.selection.from, | ||
to: editor.state.selection.to, | ||
}) | ||
} | ||
}} | ||
onClick={(e) => { | ||
e.preventDefault() | ||
e.stopPropagation() | ||
}} | ||
> | ||
{showAIPopup ? ( | ||
<div className="ai-edit-menu"> | ||
Check warning on line 120 in src/components/Editor/EditorManager.tsx GitHub Actions / build_and_package (macos-13)
Check warning on line 120 in src/components/Editor/EditorManager.tsx GitHub Actions / build_and_package (macos-latest)
Check warning on line 120 in src/components/Editor/EditorManager.tsx GitHub Actions / build_and_package (windows-latest)
|
||
<AiEditMenu | ||
selectedText={turndownService.turndown( | ||
getHTMLFromFragment( | ||
editor.state.doc.slice( | ||
selectedRange?.from || editor.state.selection.from, | ||
selectedRange?.to || editor.state.selection.to, | ||
).content, | ||
editor.schema, | ||
), | ||
)} | ||
onEdit={(newText: string) => { | ||
editor | ||
.chain() | ||
.focus() | ||
.deleteRange({ | ||
from: selectedRange?.from || editor.state.selection.from, | ||
to: selectedRange?.to || editor.state.selection.to, | ||
}) | ||
.insertContent(newText) | ||
.run() | ||
setShowAIPopup(false) | ||
}} | ||
/> | ||
</div> | ||
) : ( | ||
<button onClick={() => setShowAIPopup(true)} className="rounded p-2 hover:bg-gray-700"> | ||
AI Edit | ||
</button> | ||
)} | ||
</div> | ||
</BubbleMenu> | ||
)} | ||
<SearchBar editor={editor} showSearch={showSearchBar} setShowSearch={setShowSearchBar} /> | ||
{contextMenuVisible && ( | ||
<EditorContextMenu | ||
|
@@ -82,33 +160,19 @@ | |
/> | ||
)} | ||
|
||
<div | ||
className={`relative h-full ${editorFlex ? 'flex justify-center py-4 pl-4' : ''} ${showDocumentStats ? 'pb-3' : ''}`} | ||
> | ||
<div className={`relative h-full ${editorFlex ? 'flex justify-center py-4 pl-4' : ''}`}> | ||
<div className="relative size-full overflow-y-auto"> | ||
<EditorContent | ||
className={`relative size-full bg-dark-gray-c-eleven ${editorFlex ? 'max-w-xl' : ''}`} | ||
style={{ | ||
wordBreak: 'break-word', | ||
}} | ||
onContextMenu={handleContextMenu} | ||
onClick={handleClick} | ||
editor={editor} | ||
/> | ||
</div> | ||
</div> | ||
{suggestionsState && ( | ||
<InEditorBacklinkSuggestionsDisplay | ||
suggestionsState={suggestionsState} | ||
suggestions={vaultFilesFlattened.map((file) => file.relativePath)} | ||
/> | ||
)} | ||
{editor && showDocumentStats && ( | ||
<div className="absolute bottom-2 right-2 flex gap-4 text-sm text-gray-500"> | ||
<div>Characters: {editor.storage.characterCount.characters()}</div> | ||
<div>Words: {editor.storage.characterCount.words()}</div> | ||
</div> | ||
)} | ||
<DocumentStats editor={editor} /> | ||
</div> | ||
) | ||
} | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,25 @@ | ||
// ... existing imports ... | ||
import { Extension } from '@tiptap/core' | ||
|
||
// Add this custom extension configuration before the EditorManager component | ||
const CustomHighlight = Extension.create({ | ||
name: 'customHighlight', | ||
addGlobalAttributes() { | ||
return [ | ||
{ | ||
types: ['highlight'], | ||
attributes: { | ||
class: { | ||
default: 'selection-highlight', | ||
parseHTML: () => 'selection-highlight', | ||
renderHTML: () => ({ | ||
class: 'selection-highlight', | ||
}), | ||
}, | ||
}, | ||
}, | ||
] | ||
}, | ||
}) | ||
|
||
export default CustomHighlight |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
logic: missing cleanup function to remove IPC event listener on unmount