diff --git a/packages/api/ai/generate.mts b/packages/api/ai/generate.mts index e0a3c011..2f02c2d2 100644 --- a/packages/api/ai/generate.mts +++ b/packages/api/ai/generate.mts @@ -6,6 +6,7 @@ import { type CodeCellType, randomid, type CellWithPlaceholderType, + type MarkdownCellType, } from '@srcbook/shared'; import { type SessionType } from '../types.mjs'; import { readFileSync } from 'node:fs'; @@ -85,11 +86,18 @@ const makeGenerateCellEditSystemPrompt = (language: CodeLanguageType) => { const makeGenerateCellEditUserPrompt = ( query: string, session: SessionType, - cell: CodeCellType, + cell: CodeCellType | MarkdownCellType, ) => { + const cellLanguage = cell.type === 'markdown' ? 'markdown' : session.language; + const filteredCells = + cellLanguage === 'markdown' + ? session.cells.filter((cell) => !(cell.type === 'package.json')) + : session.cells; + // Intentionally not passing in tsconfig.json here as that doesn't need to be in the prompt. + const inlineSrcbook = encode( - { cells: session.cells, language: session.language }, + { cells: filteredCells, language: cellLanguage as CodeLanguageType }, { inline: true }, ); @@ -97,9 +105,15 @@ const makeGenerateCellEditUserPrompt = ( ${inlineSrcbook} ==== END SRCBOOK ==== -==== BEGIN CODE CELL ==== +${ + cell.type === 'code' + ? `==== BEGIN CODE CELL ==== ${cell.source} -==== END CODE CELL ==== +==== END CODE CELL ====` + : `==== BEGIN MARKDOWN CELL ==== +${cell.text} +==== END MARKDOWN CELL ====` +} ==== BEGIN USER REQUEST ==== ${query} @@ -180,10 +194,14 @@ export async function generateCells( } } -export async function generateCellEdit(query: string, session: SessionType, cell: CodeCellType) { +export async function generateCellEdit( + query: string, + session: SessionType, + cell: CodeCellType | MarkdownCellType, +) { const model = await getModel(); - - const systemPrompt = makeGenerateCellEditSystemPrompt(session.language); + const cellLanguage = cell.type === 'markdown' ? 'markdown' : session.language; + const systemPrompt = makeGenerateCellEditSystemPrompt(cellLanguage as CodeLanguageType); const userPrompt = makeGenerateCellEditUserPrompt(query, session, cell); const result = await generateText({ model, diff --git a/packages/api/prompts/code-updater-markdown.txt b/packages/api/prompts/code-updater-markdown.txt new file mode 100644 index 00000000..976440af --- /dev/null +++ b/packages/api/prompts/code-updater-markdown.txt @@ -0,0 +1,85 @@ + + +## Instructions Context + +You are tasked with editing a **Markdown cell** in a Srcbook. + +A Srcbook is a **Markdown-compatible notebook**, used for documentation or text-based content. + +### Srcbook Spec + +The structure of a Srcbook: +0. The language comment: `` +1. Title cell (heading 1) +2. N more cells, which are either: + - **Markdown cells** (GitHub flavored Markdown) + - Markdown cells, which have a filename and source content. + + +# Important Note: +Markdown cells cannot use h1 or h6 headings, as these are reserved for Srcbook. **Do not use h1 (#) or h6 (######) headings in the content.** + +The user is already working on an existing Srcbook and is asking you to edit a specific Markdown cell. +The Srcbook contents will be passed to you as context, as well as the user's request about the intended edits for the Markdown cell. + +--- + +## Example Srcbook + + + +### Getting Started + +#### What are Srcbooks? + +Srcbooks are an interactive way of organizing and presenting information. They are similar to other notebooks but unique in their flexibility and format. + +#### Dependencies + +You can include any necessary information, resources, or links to external content. + +##### Introduction + +This is a Markdown cell showcasing various Markdown features. + +#### Features Overview + +##### Text Formatting + +- **Bold text** +- *Italic text* +- ~~Strikethrough text~~ + +##### Lists + +- **Unordered List:** + - Item 1 + - Item 2 + +- **Ordered List:** + 1. First item + 2. Second item + +##### Code Blocks + +Inline code: `console.log("Hello, Markdown!")` + +##### Links + +[Click here to visit Google](https://www.google.com) + +##### Images + +![Alt text](image.png) + +--- + +## Final Instructions + +The user's Srcbook will be passed to you, surrounded with `==== BEGIN SRCBOOK ====` and `==== END SRCBOOK ====`. +The specific **Markdown cell** they want updated will also be passed to you, surrounded with `==== BEGIN MARKDOWN CELL ====` and `==== END MARKDOWN CELL ====`. +The user's intent will be passed to you between `==== BEGIN USER REQUEST ====` and `==== END USER REQUEST ====`. + +Your job is to edit the cell based on the contents of the Srcbook and the user's intent. +Act as a **Markdown expert**, writing the best possible content you can. Focus on being **elegant, concise, and clear**. +**ONLY RETURN THE MARKDOWN TEXT , NO PREAMBULE, NO SUFFIX, NO CODE FENCES (LIKE TRIPLE BACKTICKS) ONLY THE MARKDOWN**. diff --git a/packages/api/srcmd/encoding.mts b/packages/api/srcmd/encoding.mts index 332b7ac5..b6602171 100644 --- a/packages/api/srcmd/encoding.mts +++ b/packages/api/srcmd/encoding.mts @@ -22,7 +22,9 @@ export function encode(srcbook: SrcbookWithPlacebolderType, options: { inline: b const encoded = [ encodeMetdata(srcbook), encodeTitleCell(titleCell), - encodePackageJsonCell(packageJsonCell, options), + ...((srcbook.language as string) !== 'markdown' + ? [encodePackageJsonCell(packageJsonCell, options)] + : []), ...cells.map((cell) => { switch (cell.type) { case 'code': @@ -34,7 +36,6 @@ export function encode(srcbook: SrcbookWithPlacebolderType, options: { inline: b } }), ]; - // End every file with exactly one newline. return encoded.join('\n\n').trimEnd() + '\n'; } diff --git a/packages/web/src/components/ai-prompt-input.tsx b/packages/web/src/components/ai-prompt-input.tsx new file mode 100644 index 00000000..b885e483 --- /dev/null +++ b/packages/web/src/components/ai-prompt-input.tsx @@ -0,0 +1,53 @@ +import { Sparkles, MessageCircleWarning, X } from 'lucide-react'; +import TextareaAutosize from 'react-textarea-autosize'; +import { Button } from '@/components/ui/button'; +import AiGenerateTipsDialog from '@/components/ai-generate-tips-dialog'; +import { useNavigate } from 'react-router-dom'; + +interface AiPromptInputProps { + prompt: string; + setPrompt: (prompt: string) => void; + onClose: () => void; + aiEnabled: boolean; +} + +export function AiPromptInput({ prompt, setPrompt, onClose, aiEnabled }: AiPromptInputProps) { + const navigate = useNavigate(); + return ( +
+
+
+ + setPrompt(e.target.value)} + /> +
+
+ + + + +
+
+ {!aiEnabled && ( +
+

AI provider not configured.

+ +
+ )} +
+ ); +} diff --git a/packages/web/src/components/cells/code.tsx b/packages/web/src/components/cells/code.tsx index d3a156b5..367f563e 100644 --- a/packages/web/src/components/cells/code.tsx +++ b/packages/web/src/components/cells/code.tsx @@ -4,24 +4,13 @@ import { useEffect, useRef, useState } from 'react'; import { Dialog, DialogContent } from '@/components/ui/dialog'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; import Shortcut from '@/components/keyboard-shortcut'; -import { useNavigate } from 'react-router-dom'; + import { useHotkeys } from 'react-hotkeys-hook'; import CodeMirror, { keymap, Prec } from '@uiw/react-codemirror'; import { javascript } from '@codemirror/lang-javascript'; import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from '@/components/ui/resizable'; -import { - Info, - Play, - Trash2, - Sparkles, - X, - MessageCircleWarning, - LoaderCircle, - Maximize, - Minimize, -} from 'lucide-react'; -import TextareaAutosize from 'react-textarea-autosize'; -import AiGenerateTipsDialog from '@/components/ai-generate-tips-dialog'; +import { Info, Play, Trash2, Sparkles, LoaderCircle, Maximize, Minimize } from 'lucide-react'; + import { CellType, CodeCellType, @@ -46,6 +35,7 @@ import { EditorView } from 'codemirror'; import { EditorState } from '@codemirror/state'; import { unifiedMergeView } from '@codemirror/merge'; import { type Diagnostic, linter } from '@codemirror/lint'; +import { AiPromptInput } from '@/components/ai-prompt-input'; import { tsHover } from './hover'; import { mapTsServerLocationToCM } from './util'; import { toast } from 'sonner'; @@ -395,7 +385,6 @@ function Header(props: { } = props; const { aiEnabled } = useSettings(); - const navigate = useNavigate(); return ( <> @@ -554,50 +543,12 @@ function Header(props: { {['prompting', 'generating'].includes(cellMode) && ( -
-
-
- - setPrompt(e.target.value)} - /> -
-
- - - - -
-
- - {!aiEnabled && ( -
-

AI provider not configured.

- -
- )} -
+ setCellMode('off')} + aiEnabled={aiEnabled} + /> )} ); diff --git a/packages/web/src/components/cells/markdown.tsx b/packages/web/src/components/cells/markdown.tsx index 450f51d3..1bbc7fdd 100644 --- a/packages/web/src/components/cells/markdown.tsx +++ b/packages/web/src/components/cells/markdown.tsx @@ -4,13 +4,32 @@ import mermaid from 'mermaid'; import Markdown from 'marked-react'; import CodeMirror, { keymap, Prec, EditorView } from '@uiw/react-codemirror'; import { markdown } from '@codemirror/lang-markdown'; -import { CircleAlert, Trash2, Pencil } from 'lucide-react'; -import { CellType, MarkdownCellType, MarkdownCellUpdateAttrsType } from '@srcbook/shared'; + +import { CircleAlert, Trash2, Pencil, Sparkles, LoaderCircle } from 'lucide-react'; +import { + AiGeneratedCellPayloadType, + CellType, + MarkdownCellType, + MarkdownCellUpdateAttrsType, +} from '@srcbook/shared'; import { cn } from '@/lib/utils'; +import { EditorState } from '@codemirror/state'; import { Button } from '@/components/ui/button'; +import { unifiedMergeView } from '@codemirror/merge'; import DeleteCellWithConfirmation from '@/components/delete-cell-dialog'; import useTheme from '@/components/use-theme'; import { useCells } from '../use-cell'; +import { useSettings } from '@/components/use-settings'; + +import { useHotkeys } from 'react-hotkeys-hook'; +import { useDebouncedCallback } from 'use-debounce'; +import { AiPromptInput } from '../ai-prompt-input'; +import { SessionChannel } from '../../clients/websocket'; +import { SessionType } from '../../types'; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../ui/tooltip'; + +const DEBOUNCE_DELAY = 500; +type CellModeType = 'off' | 'generating' | 'reviewing' | 'prompting'; marked.use({ gfm: true }); @@ -59,18 +78,45 @@ function getValidationError(text: string) { } export default function MarkdownCell(props: { + session: SessionType; + channel: SessionChannel; cell: MarkdownCellType; updateCellOnServer: (cell: MarkdownCellType, attrs: MarkdownCellUpdateAttrsType) => void; onDeleteCell: (cell: CellType) => void; }) { const { codeTheme, theme } = useTheme(); const { updateCell: updateCellOnClient } = useCells(); - const { cell, updateCellOnServer, onDeleteCell } = props; + const { cell, updateCellOnServer, onDeleteCell, channel, session } = props; const defaultState = cell.text ? 'view' : 'edit'; const [status, setStatus] = useState<'edit' | 'view'>(defaultState); const [text, setText] = useState(cell.text); const [error, setError] = useState(null); + const [cellMode, setCellMode] = useState('off'); + const [prompt, setPrompt] = useState(''); + const [newText, setNewText] = useState(''); + const { aiEnabled } = useSettings(); + useHotkeys( + 'mod+enter', + () => { + if (!prompt) return; + if (cellMode !== 'prompting') return; + if (!aiEnabled) return; + generate(); + }, + { enableOnFormTags: ['textarea'] }, + ); + + useHotkeys( + 'escape', + () => { + if (cellMode === 'prompting') { + setCellMode('off'); + setPrompt(''); + } + }, + { enableOnFormTags: ['textarea'] }, + ); useEffect(() => { if (status === 'edit') { setText(cell.text); @@ -114,19 +160,77 @@ export default function MarkdownCell(props: { ]), ); - function onSave() { - const error = getValidationError(text); + useEffect(() => { + function callback(payload: AiGeneratedCellPayloadType) { + if (payload.cellId !== cell.id) return; + setNewText(payload.output); + setCellMode('reviewing'); + } + channel.on('ai:generated', callback); + return () => channel.off('ai:generated', callback); + }, [cell.id, channel]); + const updateCellOnServerDebounced = useDebouncedCallback(updateCellOnServer, DEBOUNCE_DELAY); + + function onSave() { + const error = getValidationError(newText); setError(error); if (error === null) { - updateCellOnClient({ ...cell, text }); - updateCellOnServer(cell, { text }); + updateCellOnClient({ ...cell, text: newText }); + updateCellOnServerDebounced(cell, { text: newText }); setStatus('view'); return true; } } + function generate() { + channel.push('ai:generate', { + sessionId: session.id, + cellId: cell.id, + prompt, + }); + setCellMode('generating'); + } + + function onAcceptDiff() { + onSave(); + setPrompt(''); + setCellMode('off'); + } + + function onRevertDiff() { + setCellMode('prompting'); + setNewText(''); + } + + function onCancel() { + setStatus('view'); + setError(null); + } + function DiffEditor({ original, modified }: { original: string; modified: string }) { + const { codeTheme } = useTheme(); + + return ( +
+ +
+ ); + } + return (
- {status === 'view' ? ( -
-
+
+
+
Markdown
+ onDeleteCell(cell)}> + + +
+
+ {status === 'view' && ( + + )} + {status === 'edit' && cellMode === 'off' && ( + <> + + + + + + Edit cell using AI + + + + + + )} + {cellMode === 'prompting' && ( + + )} + {cellMode === 'generating' && ( + + )} + {cellMode === 'reviewing' && (
-
Markdown
- onDeleteCell(cell)}> - - -
-
- +
-
+ )} +
+
+ {cellMode === 'reviewing' && } + + {['prompting', 'generating'].includes(cellMode) && ( + setCellMode('off')} + aiEnabled={aiEnabled} + /> + )} + + {status === 'view' ? ( +
{cell.text}
@@ -166,40 +329,22 @@ export default function MarkdownCell(props: { ) : ( <> {error && ( -
+

{error}

)} -
-
-
-
Markdown
- onDeleteCell(cell)}> - - -
-
- - - -
-
- -
- -
+
+ { + setNewText(newText); + }} + />
)} diff --git a/packages/web/src/routes/session.tsx b/packages/web/src/routes/session.tsx index 3754ff46..554777bb 100644 --- a/packages/web/src/routes/session.tsx +++ b/packages/web/src/routes/session.tsx @@ -349,6 +349,8 @@ function Session(props: { {cell.type === 'markdown' && (