diff --git a/apps/desktop/src/components/main/body/sessions/floating/index.tsx b/apps/desktop/src/components/main/body/sessions/floating/index.tsx index 17d28e3804..bf0fa09293 100644 --- a/apps/desktop/src/components/main/body/sessions/floating/index.tsx +++ b/apps/desktop/src/components/main/body/sessions/floating/index.tsx @@ -12,7 +12,7 @@ export function FloatingActionButton({ const currentTab = useCurrentNoteTab(tab); const hasTranscript = useHasTranscript(tab.id); - if (!(currentTab === "raw" && !hasTranscript)) { + if (!(currentTab.type === "raw" && !hasTranscript)) { return null; } diff --git a/apps/desktop/src/components/main/body/sessions/index.tsx b/apps/desktop/src/components/main/body/sessions/index.tsx index 43af75b6d8..fc614abf45 100644 --- a/apps/desktop/src/components/main/body/sessions/index.tsx +++ b/apps/desktop/src/components/main/body/sessions/index.tsx @@ -72,7 +72,7 @@ export function TabContentNote({ }); const showTimeline = - tab.state.editor === "transcript" && + tab.state.editor?.type === "transcript" && Boolean(audioUrl) && listenerStatus === "inactive"; diff --git a/apps/desktop/src/components/main/body/sessions/note-input/enhanced/editor.tsx b/apps/desktop/src/components/main/body/sessions/note-input/enhanced/editor.tsx index 7c3403eabc..85a32bb85e 100644 --- a/apps/desktop/src/components/main/body/sessions/note-input/enhanced/editor.tsx +++ b/apps/desktop/src/components/main/body/sessions/note-input/enhanced/editor.tsx @@ -8,15 +8,15 @@ import * as main from "../../../../../../store/tinybase/main"; export const EnhancedEditor = forwardRef< { editor: TiptapEditor | null }, - { sessionId: string } ->(({ sessionId }, ref) => { + { sessionId: string; enhancedNoteId: string } +>(({ enhancedNoteId }, ref) => { const store = main.UI.useStore(main.STORE_ID); const [initialContent, setInitialContent] = useState(EMPTY_TIPTAP_DOC); useEffect(() => { if (store) { - const value = store.getCell("sessions", sessionId, "enhanced_md"); + const value = store.getCell("enhanced_notes", enhancedNoteId, "content"); if (value && typeof value === "string" && value.trim()) { try { setInitialContent(JSON.parse(value)); @@ -27,12 +27,12 @@ export const EnhancedEditor = forwardRef< setInitialContent(EMPTY_TIPTAP_DOC); } } - }, [store, sessionId]); + }, [store, enhancedNoteId]); const handleChange = main.UI.useSetPartialRowCallback( - "sessions", - sessionId, - (input: JSONContent) => ({ enhanced_md: JSON.stringify(input) }), + "enhanced_notes", + enhancedNoteId, + (input: JSONContent) => ({ content: JSON.stringify(input) }), [], main.STORE_ID, ); @@ -51,7 +51,7 @@ export const EnhancedEditor = forwardRef<
(({ sessionId }, ref) => { - const taskId = createTaskId(sessionId, "enhance"); + { sessionId: string; enhancedNoteId: string } +>(({ sessionId, enhancedNoteId }, ref) => { + const taskId = createTaskId(enhancedNoteId, "enhance"); const { status } = useAITaskTask(taskId, "enhance"); @@ -20,8 +20,14 @@ export const Enhanced = forwardRef< } if (status === "generating") { - return ; + return ; } - return ; + return ( + + ); }); diff --git a/apps/desktop/src/components/main/body/sessions/note-input/enhanced/streaming.tsx b/apps/desktop/src/components/main/body/sessions/note-input/enhanced/streaming.tsx index 3382d932c3..47a1ba4981 100644 --- a/apps/desktop/src/components/main/body/sessions/note-input/enhanced/streaming.tsx +++ b/apps/desktop/src/components/main/body/sessions/note-input/enhanced/streaming.tsx @@ -12,8 +12,8 @@ import { } from "../../../../../../store/zustand/ai-task/task-configs"; import { type TaskStepInfo } from "../../../../../../store/zustand/ai-task/tasks"; -export function StreamingView({ sessionId }: { sessionId: string }) { - const taskId = createTaskId(sessionId, "enhance"); +export function StreamingView({ enhancedNoteId }: { enhancedNoteId: string }) { + const taskId = createTaskId(enhancedNoteId, "enhance"); const { streamedText, isGenerating } = useAITaskTask(taskId, "enhance"); const containerRef = useAutoScrollToBottom(streamedText); diff --git a/apps/desktop/src/components/main/body/sessions/note-input/header.tsx b/apps/desktop/src/components/main/body/sessions/note-input/header.tsx index 11d83860b4..5d3372aaef 100644 --- a/apps/desktop/src/components/main/body/sessions/note-input/header.tsx +++ b/apps/desktop/src/components/main/body/sessions/note-input/header.tsx @@ -1,7 +1,8 @@ -import { AlertCircleIcon, RefreshCcwIcon, SparklesIcon } from "lucide-react"; -import { useCallback, useEffect, useState } from "react"; +import { AlertCircleIcon, PlusIcon, RefreshCcwIcon } from "lucide-react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { commands as windowsCommands } from "@hypr/plugin-windows"; +import { md2json } from "@hypr/tiptap/shared"; import { Popover, PopoverContent, @@ -16,6 +17,7 @@ import { cn } from "@hypr/utils"; import { useListener } from "../../../../../contexts/listener"; import { useAITaskTask } from "../../../../../hooks/useAITaskTask"; +import { useCreateEnhancedNote } from "../../../../../hooks/useEnhancedNotes"; import { useLanguageModel } from "../../../../../hooks/useLLMConnection"; import * as main from "../../../../../store/tinybase/main"; import { createTaskId } from "../../../../../store/zustand/ai-task/task-configs"; @@ -52,113 +54,215 @@ function HeaderTab({ ); } +function TruncatedTitle({ + title, + isActive, +}: { + title: string; + isActive: boolean; +}) { + return ( + + {title} + + ); +} + function HeaderTabEnhanced({ isActive, onClick = () => {}, sessionId, + enhancedNoteId, }: { isActive: boolean; onClick?: () => void; sessionId: string; + enhancedNoteId: string; }) { - const [open, setOpen] = useState(false); - const { templates, isGenerating, isError, error, onRegenerate } = - useEnhanceLogic(sessionId); - - const handleTabClick = useCallback(() => { - if (!isActive) { - onClick(); - } else { - setOpen(true); - } - }, [isActive, onClick, onRegenerate, setOpen]); + const { isGenerating, isError, error, onRegenerate } = useEnhanceLogic( + sessionId, + enhancedNoteId, + ); - const handleTemplateClick = useCallback( - (templateId: string | null) => { - setOpen(false); - onRegenerate(templateId); + const title = + main.UI.useCell("enhanced_notes", enhancedNoteId, "title", main.STORE_ID) || + "Summary"; + + const handleRegenerateClick = useCallback( + (e: React.MouseEvent) => { + e.stopPropagation(); + onRegenerate(null); }, [onRegenerate], ); if (isGenerating) { return ( - + - Summary + ); } - const regenerateTrigger = ( - - + {isError && ( + + )} + - {isError && ( - + /> + + ); + + return ( + + ); +} + +function CreateOtherFormatButton({ + sessionId, + handleTabChange, +}: { + sessionId: string; + handleTabChange: (view: EditorView) => void; +}) { + const [open, setOpen] = useState(false); + const [pendingNote, setPendingNote] = useState<{ + id: string; + templateId: string; + } | null>(null); + const startedTasksRef = useRef(new Set()); + const templates = main.UI.useResultTable( + main.QUERIES.visibleTemplates, + main.STORE_ID, + ); + const createEnhancedNote = useCreateEnhancedNote(); + const model = useLanguageModel(); + + const store = main.UI.useStore(main.STORE_ID); + const taskId = createTaskId(pendingNote?.id || "placeholder", "enhance"); + const enhanceTask = useAITaskTask(taskId, "enhance", { + onSuccess: ({ text }) => { + if (text && pendingNote && store) { + try { + const jsonContent = md2json(text); + store.setPartialRow("enhanced_notes", pendingNote.id, { + content: JSON.stringify(jsonContent), + }); + } catch (error) { + console.error("Failed to convert markdown to JSON:", error); + } + } + }, + }); + + useEffect(() => { + if (pendingNote && model && !startedTasksRef.current.has(pendingNote.id)) { + startedTasksRef.current.add(pendingNote.id); + void enhanceTask.start({ + model, + args: { + sessionId, + enhancedNoteId: pendingNote.id, + templateId: pendingNote.templateId, + }, + }); + } + }, [pendingNote, model, sessionId, enhanceTask.start]); + + const handleTemplateClick = useCallback( + (templateId: string) => { + setOpen(false); + + if (!model) { + console.error("No language model available"); + return; + } + + const enhancedNoteId = createEnhancedNote(sessionId, templateId); + if (!enhancedNoteId) { + console.error("Failed to create enhanced note"); + return; + } + + handleTabChange({ type: "enhanced", id: enhancedNoteId }); + setPendingNote({ id: enhancedNoteId, templateId }); + }, + [sessionId, createEnhancedNote, model, handleTabChange], ); return ( - + + +
{Object.entries(templates).length > 0 ? ( @@ -181,23 +285,6 @@ function HeaderTabEnhanced({ Create templates )} - -
-
- or -
-
- - handleTemplateClick(null)} - > - - Auto -
@@ -221,28 +308,31 @@ export function Header({ isEditing: boolean; setIsEditing: (isEditing: boolean) => void; }) { - if (editorTabs.length === 1 && editorTabs[0] === "raw") { + const isBatchProcessing = useListener((state) => sessionId in state.batch); + + if (editorTabs.length === 1 && editorTabs[0].type === "raw") { return null; } - const isBatchProcessing = useListener((state) => sessionId in state.batch); - const showProgress = - currentTab === "transcript" && (isInactive || isBatchProcessing); + currentTab.type === "transcript" && (isInactive || isBatchProcessing); const showEditingControls = - currentTab === "transcript" && isInactive && !isBatchProcessing; + currentTab.type === "transcript" && isInactive && !isBatchProcessing; return (
-
+
{editorTabs.map((view) => { - if (view === "enhanced") { + if (view.type === "enhanced") { return ( handleTabChange(view)} /> ); @@ -250,14 +340,18 @@ export function Header({ return ( handleTabChange(view)} > {labelForEditorView(view)} ); })} +
{showProgress && } {showEditingControls && ( @@ -279,59 +373,64 @@ export function useEditorTabs({ }): EditorView[] { const sessionMode = useListener((state) => state.getSessionMode(sessionId)); const hasTranscript = useHasTranscript(sessionId); + const enhancedNoteIds = main.UI.useSliceRowIds( + main.INDEXES.enhancedNotesBySession, + sessionId, + main.STORE_ID, + ); if (sessionMode === "running_active" || sessionMode === "running_batch") { - return ["raw", "transcript"]; + return [{ type: "raw" }, { type: "transcript" }]; } if (hasTranscript) { - return ["enhanced", "raw", "transcript"]; + const enhancedTabs: EditorView[] = (enhancedNoteIds || []).map((id) => ({ + type: "enhanced", + id, + })); + return [...enhancedTabs, { type: "raw" }, { type: "transcript" }]; } - return ["raw"]; + return [{ type: "raw" }]; } function labelForEditorView(view: EditorView): string { - if (view === "enhanced") { + if (view.type === "enhanced") { return "Summary"; } - if (view === "raw") { + if (view.type === "raw") { return "Memos"; } - if (view === "transcript") { + if (view.type === "transcript") { return "Transcript"; } return ""; } -function useEnhanceLogic(sessionId: string) { +function useEnhanceLogic(sessionId: string, enhancedNoteId: string) { const model = useLanguageModel(); - const taskId = createTaskId(sessionId, "enhance"); + const taskId = createTaskId(enhancedNoteId, "enhance"); const [missingModelError, setMissingModelError] = useState( null, ); - const updateEnhancedMd = main.UI.useSetPartialRowCallback( - "sessions", - sessionId, - (input: string) => ({ enhanced_md: input }), - [], - main.STORE_ID, - ); + const store = main.UI.useStore(main.STORE_ID); const enhanceTask = useAITaskTask(taskId, "enhance", { onSuccess: ({ text }) => { - if (text) { - updateEnhancedMd(text); + if (text && store) { + try { + const jsonContent = md2json(text); + store.setPartialRow("enhanced_notes", enhancedNoteId, { + content: JSON.stringify(jsonContent), + }); + } catch (error) { + console.error("Failed to convert markdown to JSON:", error); + } } }, }); - const templates = main.UI.useResultTable( - main.QUERIES.visibleTemplates, - main.STORE_ID, - ); - const onRegenerate = useCallback( async (templateId: string | null) => { if (!model) { @@ -345,10 +444,14 @@ function useEnhanceLogic(sessionId: string) { await enhanceTask.start({ model, - args: { sessionId, templateId: templateId ?? undefined }, + args: { + sessionId, + enhancedNoteId, + templateId: templateId ?? undefined, + }, }); }, - [model, enhanceTask.start, sessionId], + [model, enhanceTask.start, sessionId, enhancedNoteId], ); useEffect(() => { @@ -361,8 +464,6 @@ function useEnhanceLogic(sessionId: string) { const isError = !!missingModelError || enhanceTask.isError; return { - model, - templates, isGenerating: enhanceTask.isGenerating, isError, error, diff --git a/apps/desktop/src/components/main/body/sessions/note-input/index.tsx b/apps/desktop/src/components/main/body/sessions/note-input/index.tsx index c3b007aa62..7ec5e0d1ad 100644 --- a/apps/desktop/src/components/main/body/sessions/note-input/index.tsx +++ b/apps/desktop/src/components/main/body/sessions/note-input/index.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useRef, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { useHotkeys } from "react-hotkeys-hook"; import type { TiptapEditor } from "@hypr/tiptap/editor"; @@ -43,13 +43,13 @@ export function NoteInput({ }); useEffect(() => { - if (currentTab === "transcript" && editorRef.current) { + if (currentTab.type === "transcript" && editorRef.current) { editorRef.current = { editor: null }; } }, [currentTab]); const handleContainerClick = () => { - if (currentTab !== "transcript") { + if (currentTab.type !== "transcript") { editorRef.current?.editor?.commands.focus(); } }; @@ -72,18 +72,22 @@ export function NoteInput({ onClick={handleContainerClick} className={cn([ "flex-1 mt-2 px-3", - currentTab === "transcript" + currentTab.type === "transcript" ? "overflow-hidden" : ["overflow-auto", "pb-6"], ])} > - {currentTab === "enhanced" && ( - + {currentTab.type === "enhanced" && ( + )} - {currentTab === "raw" && ( + {currentTab.type === "raw" && ( )} - {currentTab === "transcript" && ( + {currentTab.type === "transcript" && ( )}
@@ -100,51 +104,59 @@ function useTabShortcuts({ currentTab: EditorView; handleTabChange: (view: EditorView) => void; }) { - const switchToTab = useCallback( - (targetTab: EditorView) => { - if (editorTabs.includes(targetTab) && currentTab !== targetTab) { - handleTabChange(targetTab); - } - }, - [currentTab, editorTabs, handleTabChange], - ); - useHotkeys( "alt+s", () => { - switchToTab("enhanced"); + const enhancedTabs = editorTabs.filter((t) => t.type === "enhanced"); + if (enhancedTabs.length === 0) return; + + if (currentTab.type === "enhanced") { + const currentIndex = enhancedTabs.findIndex( + (t) => t.type === "enhanced" && t.id === currentTab.id, + ); + const nextIndex = (currentIndex + 1) % enhancedTabs.length; + handleTabChange(enhancedTabs[nextIndex]); + } else { + handleTabChange(enhancedTabs[0]); + } }, { preventDefault: true, enableOnFormTags: true, enableOnContentEditable: true, }, - [switchToTab], + [currentTab, editorTabs, handleTabChange], ); useHotkeys( "alt+m", () => { - switchToTab("raw"); + const rawTab = editorTabs.find((t) => t.type === "raw"); + if (rawTab && currentTab.type !== "raw") { + handleTabChange(rawTab); + } }, { preventDefault: true, enableOnFormTags: true, enableOnContentEditable: true, }, - [switchToTab], + [currentTab, editorTabs, handleTabChange], ); useHotkeys( "alt+t", () => { - switchToTab("transcript"); + const transcriptTab = editorTabs.find((t) => t.type === "transcript"); + if (transcriptTab && currentTab.type !== "transcript") { + handleTabChange(transcriptTab); + } }, { preventDefault: true, enableOnFormTags: true, enableOnContentEditable: true, }, - [switchToTab], + [currentTab, editorTabs, handleTabChange], ); } diff --git a/apps/desktop/src/components/main/body/sessions/shared.tsx b/apps/desktop/src/components/main/body/sessions/shared.tsx index c221d32a73..fa9f910c29 100644 --- a/apps/desktop/src/components/main/body/sessions/shared.tsx +++ b/apps/desktop/src/components/main/body/sessions/shared.tsx @@ -24,22 +24,32 @@ export function useHasTranscript(sessionId: string): boolean { export function useCurrentNoteTab( tab: Extract, ): EditorView { - const hasTranscript = useHasTranscript(tab.id); const sessionMode = useListener((state) => state.getSessionMode(tab.id)); const isListenerActive = sessionMode === "running_active" || sessionMode === "finalizing"; + const enhancedNoteIds = main.UI.useSliceRowIds( + main.INDEXES.enhancedNotesBySession, + tab.id, + main.STORE_ID, + ); + const firstEnhancedNoteId = enhancedNoteIds?.[0]; + return useMemo(() => { if (tab.state.editor) { - return tab.state.editor as EditorView; + return tab.state.editor; } if (isListenerActive) { - return "raw"; + return { type: "raw" }; + } + + if (firstEnhancedNoteId) { + return { type: "enhanced", id: firstEnhancedNoteId }; } - return hasTranscript ? "enhanced" : "raw"; - }, [tab.state.editor, isListenerActive, hasTranscript]); + return { type: "raw" }; + }, [tab.state.editor, isListenerActive, firstEnhancedNoteId]); } export function RecordingIcon({ disabled }: { disabled?: boolean }) { diff --git a/apps/desktop/src/devtool/seed/data/curated.json b/apps/desktop/src/devtool/seed/data/curated.json index 253871619e..6565dccbcc 100644 --- a/apps/desktop/src/devtool/seed/data/curated.json +++ b/apps/desktop/src/devtool/seed/data/curated.json @@ -2855,5 +2855,28 @@ "type": "vocab", "text": "Slack integration" } + ], + "enhanced_notes": [ + { + "session": "Q4 Strategy Meeting", + "content": "## Technical Architecture\n\nSlack integration will use webhook-based architecture for real-time notifications. Michael to provide detailed technical spec by November 5th including API endpoints and authentication flow.", + "position": 0, + "template": "Meeting Notes", + "title": "Technical Specification Follow-up" + }, + { + "session": "Q4 Strategy Meeting", + "content": "## Resource Allocation\n\n- 2 senior engineers + 1 junior engineer for Salesforce integration (6-7 weeks)\n- 1 senior engineer for Slack integration (3-4 weeks)\n- Performance team: TBD after profiling results\n\nBoard approval needed by end of week.", + "position": 1, + "template": null, + "title": "Team Resource Planning" + }, + { + "session": "TechStart Partnership Call", + "content": "## Revenue Model Details\n\n- TechStart receives 30% on referrals from their customer base\n- Acme retains 70%\n- Revenue sharing model creates win-win alignment\n- Legal review timeline: 2 weeks", + "position": 0, + "template": null, + "title": "Partnership Economics" + } ] } diff --git a/apps/desktop/src/devtool/seed/data/loader.ts b/apps/desktop/src/devtool/seed/data/loader.ts index b51f73348e..aec371ca97 100644 --- a/apps/desktop/src/devtool/seed/data/loader.ts +++ b/apps/desktop/src/devtool/seed/data/loader.ts @@ -27,6 +27,7 @@ export const loadCuratedData = (data: CuratedData): Tables => { const chat_groups: Tables["chat_groups"] = {}; const chat_messages: Tables["chat_messages"] = {}; const memories: Tables["memories"] = {}; + const enhanced_notes: Tables["enhanced_notes"] = {}; const orgNameToId = new Map(); const folderNameToId = new Map(); @@ -34,6 +35,8 @@ export const loadCuratedData = (data: CuratedData): Tables => { const personNameToId = new Map(); const calendarNameToId = new Map(); const eventNameToId = new Map(); + const sessionTitleToId = new Map(); + const templateTitleToId = new Map(); data.organizations.forEach((org) => { const orgId = id(); @@ -96,6 +99,7 @@ export const loadCuratedData = (data: CuratedData): Tables => { data.templates.forEach((template) => { const templateId = id(); + templateTitleToId.set(template.title, templateId); templates[templateId] = { user_id: DEFAULT_USER_ID, title: template.title, @@ -125,6 +129,7 @@ export const loadCuratedData = (data: CuratedData): Tables => { data.sessions.forEach((session) => { const sessionId = id(); + sessionTitleToId.set(session.title, sessionId); const folderId = session.folder ? folderNameToId.get(session.folder) : undefined; @@ -238,6 +243,26 @@ export const loadCuratedData = (data: CuratedData): Tables => { }; }); + data.enhanced_notes.forEach((note) => { + const enhancedNoteId = id(); + const sessionId = sessionTitleToId.get(note.session); + const templateId = note.template + ? templateTitleToId.get(note.template) + : undefined; + + if (sessionId) { + enhanced_notes[enhancedNoteId] = { + user_id: DEFAULT_USER_ID, + session_id: sessionId, + content: JSON.stringify(md2json(note.content)), + position: note.position, + template_id: templateId, + title: note.title, + created_at: new Date().toISOString(), + }; + } + }); + return { organizations, humans, @@ -254,5 +279,6 @@ export const loadCuratedData = (data: CuratedData): Tables => { chat_groups, chat_messages, memories, + enhanced_notes, }; }; diff --git a/apps/desktop/src/devtool/seed/data/schema.gen.json b/apps/desktop/src/devtool/seed/data/schema.gen.json index fd51efcbfe..2d205cef7c 100644 --- a/apps/desktop/src/devtool/seed/data/schema.gen.json +++ b/apps/desktop/src/devtool/seed/data/schema.gen.json @@ -366,6 +366,43 @@ ], "additionalProperties": false } + }, + "enhanced_notes": { + "type": "array", + "items": { + "type": "object", + "properties": { + "session": { + "type": "string" + }, + "content": { + "type": "string" + }, + "position": { + "type": "number" + }, + "template": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "title": { + "type": "string" + } + }, + "required": [ + "session", + "content", + "position", + "template" + ], + "additionalProperties": false + } } }, "required": [ @@ -378,7 +415,8 @@ "events", "sessions", "chat_groups", - "memories" + "memories", + "enhanced_notes" ], "additionalProperties": false } \ No newline at end of file diff --git a/apps/desktop/src/devtool/seed/data/schema.ts b/apps/desktop/src/devtool/seed/data/schema.ts index b76e18bacf..6585d43e31 100644 --- a/apps/desktop/src/devtool/seed/data/schema.ts +++ b/apps/desktop/src/devtool/seed/data/schema.ts @@ -92,6 +92,14 @@ const CuratedMemorySchema = z.object({ text: z.string(), }); +const CuratedEnhancedNoteSchema = z.object({ + session: z.string(), + content: z.string(), + position: z.number(), + template: z.string().nullable(), + title: z.string().optional(), +}); + export const CuratedDataSchema = z.object({ $schema: z.string().optional(), organizations: z.array(CuratedOrganizationSchema), @@ -104,6 +112,7 @@ export const CuratedDataSchema = z.object({ sessions: z.array(CuratedSessionSchema), chat_groups: z.array(CuratedChatGroupSchema), memories: z.array(CuratedMemorySchema), + enhanced_notes: z.array(CuratedEnhancedNoteSchema), }); export type CuratedData = z.infer; diff --git a/apps/desktop/src/devtool/seed/shared/builders.ts b/apps/desktop/src/devtool/seed/shared/builders.ts index e785cd426e..0a44ebd1fe 100644 --- a/apps/desktop/src/devtool/seed/shared/builders.ts +++ b/apps/desktop/src/devtool/seed/shared/builders.ts @@ -4,6 +4,7 @@ import type { Calendar, ChatGroup, ChatMessageStorage, + EnhancedNoteStorage, Event, Folder, Human, @@ -20,6 +21,7 @@ import type { import { DEFAULT_USER_ID, id } from "../../../utils"; import { createCalendar } from "./calendar"; import { createChatGroup, createChatMessage } from "./chat"; +import { createEnhancedNote } from "./enhanced-note"; import { createEvent } from "./event"; import { createFolder } from "./folder"; import { createHuman } from "./human"; @@ -402,3 +404,32 @@ export const buildMemories = ( return memories; }; + +export const buildEnhancedNotesForSessions = ( + sessionIds: string[], + templateIds: string[], + options: { + notesPerSession?: { min: number; max: number }; + templateProbability?: number; + } = {}, +): Record => { + const enhanced_notes: Record = {}; + const { notesPerSession = { min: 0, max: 3 }, templateProbability = 0.3 } = + options; + + sessionIds.forEach((sessionId) => { + const noteCount = faker.number.int(notesPerSession); + for (let i = 0; i < noteCount; i++) { + const shouldUseTemplate = + templateIds.length > 0 && + faker.datatype.boolean({ probability: templateProbability }); + const templateId = shouldUseTemplate + ? faker.helpers.arrayElement(templateIds) + : undefined; + const note = createEnhancedNote(sessionId, i, templateId); + enhanced_notes[note.id] = note.data; + } + }); + + return enhanced_notes; +}; diff --git a/apps/desktop/src/devtool/seed/shared/enhanced-note.ts b/apps/desktop/src/devtool/seed/shared/enhanced-note.ts new file mode 100644 index 0000000000..81e4f95f5d --- /dev/null +++ b/apps/desktop/src/devtool/seed/shared/enhanced-note.ts @@ -0,0 +1,31 @@ +import { faker } from "@faker-js/faker"; + +import { md2json } from "@hypr/tiptap/shared"; + +import type { EnhancedNoteStorage } from "../../../store/tinybase/main"; +import { DEFAULT_USER_ID, id } from "../../../utils"; + +export const createEnhancedNote = ( + sessionId: string, + position: number, + templateId?: string, +): { id: string; data: EnhancedNoteStorage } => { + const title = faker.lorem.sentence({ min: 2, max: 5 }); + const contentMarkdown = faker.lorem.paragraphs( + faker.number.int({ min: 1, max: 3 }), + "\n\n", + ); + + return { + id: id(), + data: { + user_id: DEFAULT_USER_ID, + session_id: sessionId, + content: JSON.stringify(md2json(contentMarkdown)), + position, + template_id: templateId, + title, + created_at: faker.date.recent({ days: 30 }).toISOString(), + }, + }; +}; diff --git a/apps/desktop/src/devtool/seed/shared/index.ts b/apps/desktop/src/devtool/seed/shared/index.ts index 02d904315a..190e3b1fa4 100644 --- a/apps/desktop/src/devtool/seed/shared/index.ts +++ b/apps/desktop/src/devtool/seed/shared/index.ts @@ -3,6 +3,7 @@ import type { Store as PersistedStore } from "../../../store/tinybase/main"; export * from "./builders"; export { createCalendar } from "./calendar"; export { createChatGroup, createChatMessage } from "./chat"; +export { createEnhancedNote } from "./enhanced-note"; export { createEvent } from "./event"; export { createFolder } from "./folder"; export { createHuman } from "./human"; diff --git a/apps/desktop/src/devtool/seed/versions/debug.ts b/apps/desktop/src/devtool/seed/versions/debug.ts index 62564eca6d..00b7b57123 100644 --- a/apps/desktop/src/devtool/seed/versions/debug.ts +++ b/apps/desktop/src/devtool/seed/versions/debug.ts @@ -275,6 +275,7 @@ const DEBUG_DATA = (() => { chat_groups: {}, chat_messages: {}, memories: {}, + enhanced_notes: {}, } satisfies Tables; })(); diff --git a/apps/desktop/src/devtool/seed/versions/random.ts b/apps/desktop/src/devtool/seed/versions/random.ts index dae29520dc..33ae2bea50 100644 --- a/apps/desktop/src/devtool/seed/versions/random.ts +++ b/apps/desktop/src/devtool/seed/versions/random.ts @@ -8,6 +8,7 @@ import { buildCalendars, buildChatGroups, buildChatMessages, + buildEnhancedNotesForSessions, buildEventsByHuman, buildFolders, buildHumans, @@ -48,6 +49,7 @@ const RANDOM_DATA = (() => { const tagIds = Object.keys(tags); const templates = buildTemplates(5); + const templateIds = Object.keys(templates); const sessions = buildSessionsPerHuman( humanIds, @@ -84,6 +86,15 @@ const RANDOM_DATA = (() => { const memories = buildMemories("vocab", 8); + const enhanced_notes = buildEnhancedNotesForSessions( + sessionIds, + templateIds, + { + notesPerSession: { min: 0, max: 3 }, + templateProbability: 0.3, + }, + ); + return { organizations, humans, @@ -100,6 +111,7 @@ const RANDOM_DATA = (() => { chat_groups, chat_messages, memories, + enhanced_notes, } satisfies Tables; })(); diff --git a/apps/desktop/src/hooks/useAutoEnhance.ts b/apps/desktop/src/hooks/useAutoEnhance.ts index dc4bd79760..d6fc5772e0 100644 --- a/apps/desktop/src/hooks/useAutoEnhance.ts +++ b/apps/desktop/src/hooks/useAutoEnhance.ts @@ -1,5 +1,7 @@ import { usePrevious } from "@uidotdev/usehooks"; -import { useCallback, useEffect } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; + +import { md2json } from "@hypr/tiptap/shared"; import { useListener } from "../contexts/listener"; import * as main from "../store/tinybase/main"; @@ -7,12 +9,14 @@ import { createTaskId } from "../store/zustand/ai-task/task-configs"; import { useTabs } from "../store/zustand/tabs"; import type { Tab } from "../store/zustand/tabs/schema"; import { useAITaskTask } from "./useAITaskTask"; +import { useCreateEnhancedNote } from "./useEnhancedNotes"; import { useLanguageModel } from "./useLLMConnection"; export function useAutoEnhance(tab: Extract) { const sessionId = tab.id; const model = useLanguageModel(); const { updateSessionTabState } = useTabs(); + const createEnhancedNote = useCreateEnhancedNote(); const listenerStatus = useListener((state) => state.live.status); const prevListenerStatus = usePrevious(listenerStatus); @@ -24,34 +28,66 @@ export function useAutoEnhance(tab: Extract) { ); const hasTranscript = !!transcriptIds && transcriptIds.length > 0; - const enhanceTaskId = createTaskId(sessionId, "enhance"); - - const updateEnhancedMd = main.UI.useSetPartialRowCallback( - "sessions", - sessionId, - (input: string) => ({ enhanced_md: input }), - [], - main.STORE_ID, + const [autoEnhancedNoteId, setAutoEnhancedNoteId] = useState( + null, ); + const startedTasksRef = useRef>(new Set()); + + const enhanceTaskId = autoEnhancedNoteId + ? createTaskId(autoEnhancedNoteId, "enhance") + : createTaskId("placeholder", "enhance"); + const store = main.UI.useStore(main.STORE_ID); const enhanceTask = useAITaskTask(enhanceTaskId, "enhance", { onSuccess: ({ text }) => { - if (text) { - updateEnhancedMd(text); + if (text && autoEnhancedNoteId && store) { + try { + const jsonContent = md2json(text); + store.setPartialRow("enhanced_notes", autoEnhancedNoteId, { + content: JSON.stringify(jsonContent), + }); + } catch (error) { + console.error("Failed to convert markdown to JSON:", error); + } } }, }); - const startEnhance = useCallback(() => { - if (!model || !hasTranscript || enhanceTask.status === "generating") { + const createAndStartEnhance = useCallback(() => { + if (!model || !hasTranscript) { return; } - void enhanceTask.start({ - model, - args: { sessionId }, + const enhancedNoteId = createEnhancedNote(sessionId); + if (!enhancedNoteId) return; + + setAutoEnhancedNoteId(enhancedNoteId); + + updateSessionTabState(tab, { + editor: { type: "enhanced", id: enhancedNoteId }, }); - }, [hasTranscript, model, enhanceTask.status, enhanceTask.start, sessionId]); + }, [ + hasTranscript, + model, + sessionId, + tab, + updateSessionTabState, + createEnhancedNote, + ]); + + useEffect(() => { + if ( + autoEnhancedNoteId && + model && + !startedTasksRef.current.has(autoEnhancedNoteId) + ) { + startedTasksRef.current.add(autoEnhancedNoteId); + void enhanceTask.start({ + model, + args: { sessionId, enhancedNoteId: autoEnhancedNoteId }, + }); + } + }, [autoEnhancedNoteId, model, sessionId, enhanceTask.start]); useEffect(() => { const listenerJustStopped = @@ -59,8 +95,7 @@ export function useAutoEnhance(tab: Extract) { listenerStatus !== "running_active"; if (listenerJustStopped) { - startEnhance(); - updateSessionTabState(tab, { editor: "enhanced" }); + createAndStartEnhance(); } - }, [listenerStatus, prevListenerStatus, startEnhance]); + }, [listenerStatus, prevListenerStatus, createAndStartEnhance]); } diff --git a/apps/desktop/src/hooks/useEnhancedNotes.ts b/apps/desktop/src/hooks/useEnhancedNotes.ts new file mode 100644 index 0000000000..11a075f271 --- /dev/null +++ b/apps/desktop/src/hooks/useEnhancedNotes.ts @@ -0,0 +1,110 @@ +import { useCallback } from "react"; + +import * as main from "../store/tinybase/main"; + +export function useCreateEnhancedNote() { + const store = main.UI.useStore(main.STORE_ID) as main.Store | undefined; + const indexes = main.UI.useIndexes(main.STORE_ID); + + return useCallback( + (sessionId: string, templateId?: string) => { + if (!store || !indexes) return null; + + const enhancedNoteId = crypto.randomUUID(); + const now = new Date().toISOString(); + const userId = store.getValue("user_id"); + + const existingNoteIds = indexes.getSliceRowIds( + main.INDEXES.enhancedNotesBySession, + sessionId, + ); + const nextPosition = existingNoteIds.length + 1; + + let title = "Summary"; + if (templateId) { + const templateTitle = store.getCell("templates", templateId, "title"); + if (typeof templateTitle === "string") { + title = templateTitle; + } + } + + store.setRow("enhanced_notes", enhancedNoteId, { + user_id: userId || "", + created_at: now, + session_id: sessionId, + content: "", + position: nextPosition, + title, + template_id: templateId, + }); + + return enhancedNoteId; + }, + [store, indexes], + ); +} + +export function useDeleteEnhancedNote() { + const store = main.UI.useStore(main.STORE_ID); + + return useCallback( + (enhancedNoteId: string) => { + if (!store) return; + + store.delRow("enhanced_notes", enhancedNoteId); + }, + [store], + ); +} + +export function useRenameEnhancedNote() { + const store = main.UI.useStore(main.STORE_ID); + + return useCallback( + (enhancedNoteId: string, newTitle: string) => { + if (!store) return; + + store.setPartialRow("enhanced_notes", enhancedNoteId, { + title: newTitle, + }); + }, + [store], + ); +} + +export function useEnhancedNotes(sessionId: string) { + return main.UI.useSliceRowIds( + main.INDEXES.enhancedNotesBySession, + sessionId, + main.STORE_ID, + ); +} + +export function useEnhancedNote(enhancedNoteId: string) { + const title = main.UI.useCell( + "enhanced_notes", + enhancedNoteId, + "title", + main.STORE_ID, + ); + const content = main.UI.useCell( + "enhanced_notes", + enhancedNoteId, + "content", + main.STORE_ID, + ); + const position = main.UI.useCell( + "enhanced_notes", + enhancedNoteId, + "position", + main.STORE_ID, + ); + const templateId = main.UI.useCell( + "enhanced_notes", + enhancedNoteId, + "template_id", + main.STORE_ID, + ); + + return { title, content, position, templateId }; +} diff --git a/apps/desktop/src/hooks/useRunBatch.ts b/apps/desktop/src/hooks/useRunBatch.ts index 1277ae0d4b..e97237f044 100644 --- a/apps/desktop/src/hooks/useRunBatch.ts +++ b/apps/desktop/src/hooks/useRunBatch.ts @@ -63,7 +63,7 @@ export const useRunBatch = (sessionId: string) => { } if (sessionTab) { - updateSessionTabState(sessionTab, { editor: "transcript" }); + updateSessionTabState(sessionTab, { editor: { type: "transcript" } }); } const transcriptId = id(); diff --git a/apps/desktop/src/store/tinybase/main.ts b/apps/desktop/src/store/tinybase/main.ts index 80aae41e30..9e6fa2f343 100644 --- a/apps/desktop/src/store/tinybase/main.ts +++ b/apps/desktop/src/store/tinybase/main.ts @@ -328,6 +328,12 @@ export const StoreComponent = ({ persist = true }: { persist?: boolean }) => { "chat_messages", "chat_groups", "chat_group_id", + ) + .setRelationshipDefinition( + RELATIONSHIPS.enhancedNoteToSession, + "enhanced_notes", + "sessions", + "session_id", ), [], )!; @@ -588,6 +594,12 @@ export const StoreComponent = ({ persist = true }: { persist?: boolean }) => { "chat_messages", "chat_group_id", "created_at", + ) + .setIndexDefinition( + INDEXES.enhancedNotesBySession, + "enhanced_notes", + "session_id", + "position", ), ); @@ -665,6 +677,7 @@ export const INDEXES = { tagSessionsByTag: "tagSessionsByTag", chatMessagesByGroup: "chatMessagesByGroup", sessionsByHuman: "sessionsByHuman", + enhancedNotesBySession: "enhancedNotesBySession", }; export const RELATIONSHIPS = { @@ -682,4 +695,5 @@ export const RELATIONSHIPS = { tagSessionToTag: "tagSessionToTag", tagSessionToSession: "tagSessionToSession", chatMessageToGroup: "chatMessageToGroup", + enhancedNoteToSession: "enhancedNoteToSession", }; diff --git a/apps/desktop/src/store/tinybase/schema-external.ts b/apps/desktop/src/store/tinybase/schema-external.ts index a0d8a70c14..0c33e1ec95 100644 --- a/apps/desktop/src/store/tinybase/schema-external.ts +++ b/apps/desktop/src/store/tinybase/schema-external.ts @@ -116,6 +116,16 @@ export const memorySchema = baseMemorySchema.omit({ id: true }).extend({ created_at: z.string(), }); +export const enhancedNoteSchema = z.object({ + user_id: z.string(), + created_at: z.string(), + session_id: z.string(), + content: z.string(), + template_id: z.preprocess((val) => val ?? undefined, z.string().optional()), + position: z.number(), + title: z.preprocess((val) => val ?? undefined, z.string().optional()), +}); + export const wordSchemaOverride = wordSchema.omit({ id: true }).extend({ created_at: z.string(), speaker: z.preprocess((val) => val ?? undefined, z.string().optional()), @@ -155,6 +165,7 @@ export type TemplateSection = z.infer; export type ChatGroup = z.infer; export type ChatMessage = z.infer; export type Memory = z.infer; +export type EnhancedNote = z.infer; export type SessionStorage = ToStorageType; export type TranscriptStorage = ToStorageType; @@ -165,6 +176,7 @@ export type SpeakerHintStorage = ToStorageType< export type TemplateStorage = ToStorageType; export type ChatMessageStorage = ToStorageType; export type MemoryStorage = ToStorageType; +export type EnhancedNoteStorage = ToStorageType; export const externalTableSchemaForTinybase = { folders: { @@ -284,4 +296,13 @@ export const externalTableSchemaForTinybase = { type: { type: "string" }, text: { type: "string" }, } satisfies InferTinyBaseSchema, + enhanced_notes: { + user_id: { type: "string" }, + created_at: { type: "string" }, + session_id: { type: "string" }, + content: { type: "string" }, + template_id: { type: "string" }, + position: { type: "number" }, + title: { type: "string" }, + } satisfies InferTinyBaseSchema, } as const satisfies TablesSchema; diff --git a/apps/desktop/src/store/zustand/ai-task/task-configs/enhance-transform.ts b/apps/desktop/src/store/zustand/ai-task/task-configs/enhance-transform.ts index 89b15c2376..ce9f0500c4 100644 --- a/apps/desktop/src/store/zustand/ai-task/task-configs/enhance-transform.ts +++ b/apps/desktop/src/store/zustand/ai-task/task-configs/enhance-transform.ts @@ -42,13 +42,14 @@ async function transformArgs( args: TaskArgsMap["enhance"], store: MainStore, ): Promise { - const { sessionId, templateId } = args; + const { sessionId, enhancedNoteId, templateId } = args; const sessionContext = getSessionContext(sessionId, store); const template = templateId ? getTemplateData(templateId, store) : undefined; return { sessionId, + enhancedNoteId, rawMd: sessionContext.rawMd, sessionData: sessionContext.sessionData, participants: sessionContext.participants, diff --git a/apps/desktop/src/store/zustand/ai-task/task-configs/index.ts b/apps/desktop/src/store/zustand/ai-task/task-configs/index.ts index 016f0e1f40..a987b2fb12 100644 --- a/apps/desktop/src/store/zustand/ai-task/task-configs/index.ts +++ b/apps/desktop/src/store/zustand/ai-task/task-configs/index.ts @@ -11,13 +11,14 @@ import { titleWorkflow } from "./title-workflow"; export type TaskType = "enhance" | "title"; export interface TaskArgsMap { - enhance: { sessionId: string; templateId?: string }; + enhance: { sessionId: string; enhancedNoteId: string; templateId?: string }; title: { sessionId: string }; } export interface TaskArgsMapTransformed { enhance: { sessionId: string; + enhancedNoteId: string; rawMd: string; sessionData: { title: string; diff --git a/apps/desktop/src/store/zustand/tabs/basic.test.ts b/apps/desktop/src/store/zustand/tabs/basic.test.ts index 5e42dc8e4b..876b26ba4a 100644 --- a/apps/desktop/src/store/zustand/tabs/basic.test.ts +++ b/apps/desktop/src/store/zustand/tabs/basic.test.ts @@ -20,7 +20,7 @@ describe("Basic Tab Actions", () => { const session1 = createSessionTab({ active: false }); const session2 = createSessionTab({ active: false, - state: { editor: "enhanced" }, + state: { editor: { type: "enhanced", id: "note-1" } }, }); const contacts = createContactsTab({ active: false }); diff --git a/apps/desktop/src/store/zustand/tabs/schema.ts b/apps/desktop/src/store/zustand/tabs/schema.ts index 44be10ec2a..597c947b88 100644 --- a/apps/desktop/src/store/zustand/tabs/schema.ts +++ b/apps/desktop/src/store/zustand/tabs/schema.ts @@ -7,9 +7,25 @@ const baseTabSchema = z.object({ slotId: z.string(), }); -export const editorViewSchema = z.enum(["raw", "enhanced", "transcript"]); +export const editorViewSchema = z.discriminatedUnion("type", [ + z.object({ type: z.literal("raw") }), + z.object({ type: z.literal("transcript") }), + z.object({ + type: z.literal("enhanced"), + id: z.string(), + }), +]); export type EditorView = z.infer; +export const isEnhancedView = ( + view: EditorView, +): view is { type: "enhanced"; id: string } => view.type === "enhanced"; +export const isRawView = (view: EditorView): view is { type: "raw" } => + view.type === "raw"; +export const isTranscriptView = ( + view: EditorView, +): view is { type: "transcript" } => view.type === "transcript"; + export const tabSchema = z.discriminatedUnion("type", [ baseTabSchema.extend({ type: z.literal("sessions" satisfies (typeof TABLES)[number]), @@ -64,7 +80,7 @@ export type TabInput = | { type: "sessions"; id: string; - state?: { editor?: "raw" | "enhanced" | "transcript" }; + state?: { editor?: EditorView }; } | { type: "contacts"; diff --git a/apps/desktop/src/store/zustand/tabs/state.test.ts b/apps/desktop/src/store/zustand/tabs/state.test.ts index e812754774..226eec0302 100644 --- a/apps/desktop/src/store/zustand/tabs/state.test.ts +++ b/apps/desktop/src/store/zustand/tabs/state.test.ts @@ -18,19 +18,21 @@ describe("State Updater Actions", () => { const tab = createSessionTab({ active: true }); useTabs.getState().openNew(tab); - useTabs.getState().updateSessionTabState(tab, { editor: "enhanced" }); + useTabs.getState().updateSessionTabState(tab, { + editor: { type: "enhanced", id: "note-1" }, + }); const state = useTabs.getState(); expect(state.tabs[0]).toMatchObject({ id: tab.id, - state: { editor: "enhanced" }, + state: { editor: { type: "enhanced", id: "note-1" } }, }); expect(useTabs.getState()).toHaveCurrentTab({ id: tab.id, - state: { editor: "enhanced" }, + state: { editor: { type: "enhanced", id: "note-1" } }, }); expect(useTabs.getState()).toHaveLastHistoryEntry({ - state: { editor: "enhanced" }, + state: { editor: { type: "enhanced", id: "note-1" } }, }); }); @@ -40,20 +42,22 @@ describe("State Updater Actions", () => { useTabs.getState().openNew(tab); useTabs.getState().openNew(active); - useTabs.getState().updateSessionTabState(tab, { editor: "enhanced" }); + useTabs.getState().updateSessionTabState(tab, { + editor: { type: "enhanced", id: "note-1" }, + }); const state = useTabs.getState(); expect(state.tabs[0]).toMatchObject({ id: tab.id, - state: { editor: "enhanced" }, + state: { editor: { type: "enhanced", id: "note-1" } }, }); expect(state.tabs[1]).toMatchObject({ id: active.id, - state: { editor: "raw" }, + state: {}, }); expect(useTabs.getState()).toHaveLastHistoryEntry({ id: active.id, - state: { editor: "raw" }, + state: {}, }); }); @@ -70,7 +74,7 @@ describe("State Updater Actions", () => { const state = useTabs.getState(); expect(state.tabs[0]).toMatchObject({ id: session.id, - state: { editor: "raw" }, + state: {}, }); expect(state.tabs[1]).toMatchObject({ type: "contacts" }); }); @@ -106,7 +110,7 @@ describe("State Updater Actions", () => { const state = useTabs.getState(); expect(state.tabs[0]).toMatchObject({ state: newContactsState }); - expect(state.tabs[1]).toMatchObject({ state: { editor: "raw" } }); + expect(state.tabs[1]).toMatchObject({ state: {} }); expect(useTabs.getState()).toHaveLastHistoryEntry({ id: session.id }); }); diff --git a/apps/desktop/src/store/zustand/tabs/test-utils.ts b/apps/desktop/src/store/zustand/tabs/test-utils.ts index dd1727c718..aed64fd780 100644 --- a/apps/desktop/src/store/zustand/tabs/test-utils.ts +++ b/apps/desktop/src/store/zustand/tabs/test-utils.ts @@ -21,7 +21,6 @@ export const createSessionTab = ( active: overrides.active ?? false, slotId: id(), state: { - editor: "raw", ...overrides.state, }, }); diff --git a/packages/tiptap/package.json b/packages/tiptap/package.json index 640ab7f379..030c11bb0c 100644 --- a/packages/tiptap/package.json +++ b/packages/tiptap/package.json @@ -54,6 +54,6 @@ "vitest": "^3.2.4" }, "scripts": { - "test": "vitest run" + "test": "vitest run --passWithNoTests" } }