/// import { computed, type Default, derive, generateText, handler, NAME, navigateTo, pattern, SELF, Stream, UI, type VNode, wish, Writable, } from "commontools"; // Type for backlinks (inline to work around CLI path resolution bug) type MentionablePiece = { [NAME]?: string; isHidden?: boolean; content?: string; mentioned: MentionablePiece[]; backlinks: MentionablePiece[]; }; type MinimalPiece = { [NAME]?: string; }; // Default system prompt - based on leaked/inferred Claude system prompt style const DEFAULT_SYSTEM_PROMPT = `You are Claude, a helpful AI assistant created by Anthropic. You are direct, helpful, and thoughtful in your responses. You aim to be truthful and you acknowledge uncertainty when relevant. You engage naturally with the human while maintaining appropriate boundaries.`; // Available models for selection const MODELS = [ { id: "anthropic:claude-sonnet-4-5", label: "Sonnet 4.5" }, { id: "anthropic:claude-haiku-4-5", label: "Haiku 4.5" }, { id: "anthropic:claude-opus-4-1", label: "Opus 4.1" }, ] as const; type Input = { title?: Writable>; content?: Writable>; isHidden?: Default; noteId?: Default; /** Pattern JSON for [[wiki-links]]. Defaults to creating new ChatNotes. */ linkPattern?: Writable>; /** Parent notebook reference (passed via SELF from notebook.tsx) */ parentNotebook?: any; /** Selected model for generation. Defaults to Sonnet 4.5 */ model?: Writable>; }; type LLMMessage = { role: "user" | "assistant"; content: string; }; /** Represents a chat-enabled note with inline LLM conversations. */ type Output = { [NAME]?: string; [UI]: VNode; mentioned: Default, []>; backlinks: MentionablePiece[]; parentNotebook: any; content: Default; isHidden: Default; noteId: Default; isGenerating: boolean; editContent: Stream<{ detail: { value: string } }>; }; // Parse document content into LLM messages // Format: // - First section starting with "## System" becomes the system prompt // - Sections separated by --- horizontal rules // - Sections with ## AI or ## Assistant header are assistant messages // - Other sections are user messages function parseContentToMessages( content: string, mentionable: MentionablePiece[], ): { system: string; messages: LLMMessage[] } { if (!content.trim()) { return { system: DEFAULT_SYSTEM_PROMPT, messages: [] }; } // Expand wiki links before parsing const expandedContent = expandWikiLinks(content, mentionable); // Split by horizontal rules (--- on its own line) const sections = expandedContent.split(/\n---+\n/).map((s) => s.trim()); let system = DEFAULT_SYSTEM_PROMPT; const messages: LLMMessage[] = []; let startIndex = 0; // Check if first section is a system prompt if (sections.length > 0 && sections[0].match(/^##\s*[Ss]ystem\b/)) { // Extract content after the header const systemContent = sections[0] .replace(/^##\s*[Ss]ystem\b\s*\n?/, "") .trim(); if (systemContent) { system = systemContent; } startIndex = 1; } // Process remaining sections for (let i = startIndex; i < sections.length; i++) { const section = sections[i]; if (!section) continue; // Check if this is an assistant message (## AI or ## Assistant) const isAssistant = section.match(/^##\s*(?:AI|Assistant)\b/i); if (isAssistant) { // Remove the header and get the content const assistantContent = section .replace(/^##\s*(?:AI|Assistant)\b\s*\n?/i, "") .trim(); if (assistantContent) { messages.push({ role: "assistant", content: assistantContent }); } } else { // User message - use the whole section if (section) { messages.push({ role: "user", content: section }); } } } // Coalesce adjacent same-role messages const coalescedMessages: LLMMessage[] = []; for (const msg of messages) { const last = coalescedMessages[coalescedMessages.length - 1]; if (last && last.role === msg.role) { last.content += "\n\n" + msg.content; } else { coalescedMessages.push({ ...msg }); } } return { system, messages: coalescedMessages }; } // Expand [[wiki links]] with the content of referenced notes // Format: [[Name (id)]] -> ## [Name]\n[content of linked note] function expandWikiLinks( text: string, mentionable: MentionablePiece[], ): string { // Match [[Name (id)]] pattern const wikiLinkRegex = /\[\[([^\]]*?)\s*\(([^)]+)\)\]\]/g; return text.replace(wikiLinkRegex, (match, name, id) => { // Find the piece by ID const piece = mentionable?.find((c: any) => { // Check various ways the ID might be stored const pieceId = c?.id || c?.noteId || (c as any)?.$id || (c as any)?.["$ID"]; return pieceId === id; }); if (piece && piece.content) { return `## ${name.trim()}\n${piece.content}`; } // If we can't find the content, keep the original link return match; }); } const _updateContent = handler< { detail: { value: string } }, { content: Writable } >((event, state) => { state.content.set(event.detail?.value ?? ""); }); const handlePieceLinkClick = handler< { detail: { piece: Writable; }; }, Record >(({ detail }, _) => { return navigateTo(detail.piece); }); const handleNewBacklink = handler< { detail: { text: string; pieceId: any; piece: Writable; navigate: boolean; }; }, { mentionable: Writable; allPieces: Writable; } >(({ detail }, { mentionable, allPieces }) => { allPieces.push(detail.piece); if (detail.navigate) { return navigateTo(detail.piece); } else { mentionable.push(detail.piece); } }); const handleEditContent = handler< { detail: { value: string }; result?: Writable }, { content: Writable } >(({ detail, result }, { content }) => { content.set(detail.value); result?.set("updated"); }); const handlePieceLinkClicked = handler< void, { piece: Writable } >((_, { piece }) => { return navigateTo(piece); }); // Handler to start editing title const startEditingTitle = handler< Record, { isEditingTitle: Writable } >((_, { isEditingTitle }) => { isEditingTitle.set(true); }); // Handler to stop editing title const stopEditingTitle = handler< Record, { isEditingTitle: Writable } >((_, { isEditingTitle }) => { isEditingTitle.set(false); }); // Handler for keydown on title input (Enter to save) const handleTitleKeydown = handler< { key?: string }, { isEditingTitle: Writable } >((event, { isEditingTitle }) => { if (event?.key === "Enter") { isEditingTitle.set(false); } }); // Handler for Generate button - triggers LLM generation // Event type matches both onClick (unknown) and onct-submit ({ value: string }) const handleGenerate = handler< { value?: string }, { content: Writable; llmSystem: Writable; llmMessages: Writable; isGenerating: Writable; mentionable: any; beforeAIInsert: Writable; } >((_event, state) => { const currentContent = state.content.get(); // Parse entire content into messages - get raw array from mentionable const mentionableArray = Array.isArray(state.mentionable) ? state.mentionable : state.mentionable?.get?.() ?? []; const { system, messages } = parseContentToMessages( currentContent, mentionableArray, ); // If there are no messages, don't generate if (messages.length === 0) { return; } // Ensure last message is from user const lastMessage = messages[messages.length - 1]; if (lastMessage && lastMessage.role === "assistant") { // Don't generate if last message is already from assistant return; } // Add the AI response separator to content const separator = currentContent.endsWith("\n") ? "---\n## AI\n" : "\n---\n## AI\n"; const newContent = currentContent + separator; state.content.set(newContent); // Save the prefix for streaming display state.beforeAIInsert.set(newContent); // Set up the LLM call state.llmSystem.set(system); state.llmMessages.set(messages); state.isGenerating.set(true); }); // Handler to cancel generation const handleCancelGeneration = handler< void, { isGenerating: Writable; llmMessages: Writable; } >((_, state) => { state.isGenerating.set(false); // Clear messages to stop the LLM state.llmMessages.set([]); }); // Navigate to parent notebook const goToParent = handler, { self: any }>( (_, { self }) => { const p = (self as any).parentNotebook; if (p) navigateTo(p); }, ); // Handler for model selection change const handleModelChange = handler< { target: { value: string } }, { model: Writable } >(({ target }, { model }) => { model.set(target.value); }); const ChatNote = pattern( ({ title, content, isHidden, noteId, linkPattern, parentNotebook: parentNotebookProp, model, [SELF]: self, }) => { const { allPieces } = wish<{ allPieces: Default }>({ query: "/" }).result; const { result: mentionable } = wish>({ query: "#mentionable", }); const mentioned = Writable.of([]); const backlinks = Writable.of([]); // State for inline title editing const isEditingTitle = Writable.of(false); // LLM state const isGenerating = Writable.of(false); const llmSystem = Writable.of(""); const llmMessages = Writable.of([]); // LLM call - reactive based on llmMessages const llmResponse = generateText({ system: llmSystem, messages: llmMessages, model: model, }); // Track content before AI insertion point for streaming display const beforeAIInsert = Writable.of(""); // Watch for LLM streaming partial updates // Use explicit dependency array for proper reactive tracking derive( [isGenerating, llmResponse.partial, beforeAIInsert], () => { const generating = isGenerating.get(); const partial = llmResponse.partial; const prefix = beforeAIInsert.get(); if (generating && partial && prefix) { content.set(prefix + partial); } }, ); // Watch for LLM completion derive( [isGenerating, llmResponse.pending, llmResponse.result], ([generating, pending, result]) => { // When complete, finalize with result and closing separator if (!pending && result && generating) { const prefix = beforeAIInsert.get(); if (prefix) { content.set(prefix + result + "\n---\n"); } isGenerating.set(false); llmMessages.set([]); beforeAIInsert.set(""); } }, ); // Compute parent notebook const parentNotebook = computed(() => { const selfParent = (self as any)?.parentNotebook; if (selfParent) return selfParent; if (parentNotebookProp) return parentNotebookProp; return null; }); // Use provided linkPattern or default to creating new ChatNotes const patternJson = computed(() => { const lpValue = (linkPattern as any)?.get?.() ?? linkPattern; const custom = typeof lpValue === "string" ? lpValue.trim() : ""; return custom || JSON.stringify(ChatNote); }); // Computed for generation state display const showGenerating = computed( () => isGenerating.get() && llmResponse.pending, ); // Can generate when there's content and not already generating // Optimized to avoid splitting entire content on every keystroke const canGenerate = computed(() => { if (isGenerating.get()) return false; const currentContent = content.get(); if (!currentContent.trim()) return false; // Find the last section separator efficiently using lastIndexOf const lastSepIdx = currentContent.lastIndexOf("\n---"); let lastSection: string; if (lastSepIdx === -1) { lastSection = currentContent; } else { // Find the end of the separator line (skip past the ---\n) const afterSep = currentContent.indexOf("\n", lastSepIdx + 4); lastSection = afterSep === -1 ? currentContent.substring(lastSepIdx + 4) : currentContent.substring(afterSep + 1); } const trimmedLast = lastSection.trim(); if (!trimmedLast) return false; // If last section starts with ## AI, we can't generate if (trimmedLast.match(/^##\s*(?:AI|Assistant)\b/i)) return false; return true; }); // Model change handler const modelChangeHandler = handleModelChange({ model }); return { [NAME]: computed(() => `💬 ${title.get()}`), [UI]: ( {/* Parent notebook chip */} { const p = (self as any).parentNotebook; return p ? "flex" : "none"; }), marginBottom: "4px", }} > In: { const p = (self as any).parentNotebook; return p?.[NAME] ?? p?.title ?? "Notebook"; })} interactive onct-click={goToParent({ self })} /> {/* Editable Title - click to edit */}
isEditingTitle.get() ? "none" : "flex" ), alignItems: "center", gap: "8px", cursor: "pointer", flex: 1, }} onClick={startEditingTitle({ isEditingTitle })} > {title}
isEditingTitle.get() ? "flex" : "none" ), flex: 1, marginRight: "12px", }} >
{/* Model selector */} {/* Generate button - shown when not generating */} !canGenerate)} style={{ display: computed(() => (showGenerating ? "none" : "flex")), }} title="Generate (Cmd+Enter)" > Generate {/* Generation status / Cancel button - shown when generating */} (showGenerating ? "flex" : "none")), flexShrink: 0, }} > Cancel
{/* Keyboard shortcut: Cmd+Enter to generate */} {/* Keyboard shortcut: Ctrl+Enter to generate (Windows/Linux) */} {/* Editor */} {backlinks?.map((piece) => ( {piece?.[NAME]} ))}
), title, content, mentioned, backlinks, parentNotebook, isHidden, noteId, isGenerating, editContent: handleEditContent({ content }), }; }, ); export default ChatNote;