diff --git a/apps/desktop/src/components/chat/session.tsx b/apps/desktop/src/components/chat/session.tsx index d3a8397972..05866b231c 100644 --- a/apps/desktop/src/components/chat/session.tsx +++ b/apps/desktop/src/components/chat/session.tsx @@ -4,8 +4,8 @@ import { type ReactNode, useEffect, useMemo, useRef } from "react"; import { CustomChatTransport } from "../../chat/transport"; import type { HyprUIMessage } from "../../chat/types"; -import { useToolRegistry } from "../../contexts/tool"; import { useLanguageModel } from "../../hooks/useLLMConnection"; +import { useToolRegistry } from "../../contexts/tool"; import * as main from "../../store/tinybase/main"; import { id } from "../../utils"; diff --git a/apps/desktop/src/components/chat/view.tsx b/apps/desktop/src/components/chat/view.tsx index f9a10d6a15..b0af70929a 100644 --- a/apps/desktop/src/components/chat/view.tsx +++ b/apps/desktop/src/components/chat/view.tsx @@ -1,8 +1,8 @@ import { useCallback, useRef } from "react"; import type { HyprUIMessage } from "../../chat/types"; -import { useShell } from "../../contexts/shell"; import { useLanguageModel } from "../../hooks/useLLMConnection"; +import { useShell } from "../../contexts/shell"; import * as main from "../../store/tinybase/main"; import { id } from "../../utils"; import { ChatBody } from "./body"; 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 271a85e46a..f96b5d001f 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 09c742cedf..b1579e0330 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 @@ -7,8 +7,8 @@ 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(""); @@ -41,7 +41,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..22f903070a 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,5 +1,5 @@ -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 { @@ -56,109 +56,195 @@ 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 + {title} ); } - 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 = generateJSON(text, markdownExtensions); + 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 +267,6 @@ function HeaderTabEnhanced({ Create templates )} - -
-
- or -
-
- - handleTemplateClick(null)} - > - - Auto -
@@ -221,28 +290,31 @@ export function Header({ isEditing: boolean; setIsEditing: (isEditing: boolean) => void; }) { - if (editorTabs.length === 1 && editorTabs[0] === "raw") { + 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 +322,18 @@ export function Header({ return ( handleTabChange(view)} > {labelForEditorView(view)} ); })} +
{showProgress && } {showEditingControls && ( @@ -322,16 +398,11 @@ function useEnhanceLogic(sessionId: string) { const enhanceTask = useAITaskTask(taskId, "enhance", { onSuccess: ({ text }) => { if (text) { - updateEnhancedMd(text); + updateEnhancedNote(text); } }, }); - const templates = main.UI.useResultTable( - main.QUERIES.visibleTemplates, - main.STORE_ID, - ); - const onRegenerate = useCallback( async (templateId: string | null) => { if (!model) { @@ -345,10 +416,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 +436,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..9870bffa0d 100644 --- a/apps/desktop/src/components/main/body/sessions/shared.tsx +++ b/apps/desktop/src/components/main/body/sessions/shared.tsx @@ -24,22 +24,36 @@ 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"; + // Get first enhanced note ID (if any) as default + const enhancedNoteIds = main.UI.useSliceRowIds( + main.INDEXES.enhancedNotesBySession, + tab.id, + main.STORE_ID, + ); + const firstEnhancedNoteId = enhancedNoteIds?.[0]; + return useMemo(() => { + // User explicitly set a tab if (tab.state.editor) { - return tab.state.editor as EditorView; + return tab.state.editor; } + // Recording is active, show raw if (isListenerActive) { - return "raw"; + return { type: "raw" }; + } + + // Show first enhanced note if available, else raw + if (firstEnhancedNoteId) { + return { type: "enhanced", enhancedNoteId: 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 f98205cc11..73642eba6e 100644 --- a/apps/desktop/src/devtool/seed/data/loader.ts +++ b/apps/desktop/src/devtool/seed/data/loader.ts @@ -25,6 +25,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(); @@ -32,6 +33,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(); @@ -94,6 +97,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, @@ -123,6 +127,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; @@ -236,6 +241,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: markdownToJsonString(note.content), + position: note.position, + template_id: templateId, + title: note.title, + created_at: new Date().toISOString(), + }; + } + }); + return { organizations, humans, @@ -252,5 +277,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/script.ts b/apps/desktop/src/devtool/seed/script.ts index b657302ef5..2b40380384 100644 --- a/apps/desktop/src/devtool/seed/script.ts +++ b/apps/desktop/src/devtool/seed/script.ts @@ -13,5 +13,3 @@ const jsonSchema = z.toJSONSchema(CuratedDataSchema); const outputPath = path.join(__dirname, "data", "schema.gen.json"); fs.writeFileSync(outputPath, JSON.stringify(jsonSchema, null, 2), "utf-8"); - -console.log(`JSON Schema generated at: ${outputPath}`); 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..f23daafed9 --- /dev/null +++ b/apps/desktop/src/devtool/seed/shared/enhanced-note.ts @@ -0,0 +1,41 @@ +import { faker } from "@faker-js/faker"; + +import { EMPTY_TIPTAP_DOC_STRING, md2json } from "@hypr/tiptap/shared"; + +import type { EnhancedNoteStorage } from "../../../store/tinybase/main"; +import { DEFAULT_USER_ID, id } from "../../../utils"; + +const markdownToJsonString = (markdown: string): string => { + try { + const json = md2json(markdown); + return JSON.stringify(json); + } catch (error) { + console.error("Failed to convert markdown to JSON:", error); + return EMPTY_TIPTAP_DOC_STRING; + } +}; + +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: markdownToJsonString(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/shared/template.ts b/apps/desktop/src/devtool/seed/shared/template.ts index 4dc5f493cb..d14cc07386 100644 --- a/apps/desktop/src/devtool/seed/shared/template.ts +++ b/apps/desktop/src/devtool/seed/shared/template.ts @@ -16,14 +16,16 @@ export const createTemplate = (): { id: string; data: TemplateStorage } => { }), ); + const data: TemplateStorage = { + user_id: DEFAULT_USER_ID, + title: faker.lorem.words({ min: 2, max: 5 }), + description: faker.lorem.sentence(), + sections: JSON.stringify(sections), + created_at: faker.date.past({ years: 1 }).toISOString(), + }; + return { id: id(), - data: { - user_id: DEFAULT_USER_ID, - title: faker.lorem.words({ min: 2, max: 5 }), - description: faker.lorem.sentence(), - sections: JSON.stringify(sections), - created_at: faker.date.past({ years: 1 }).toISOString(), - }, + data, }; }; 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..bfa47233ca 100644 --- a/apps/desktop/src/hooks/useAutoEnhance.ts +++ b/apps/desktop/src/hooks/useAutoEnhance.ts @@ -1,5 +1,5 @@ import { usePrevious } from "@uidotdev/usehooks"; -import { useCallback, useEffect } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { useListener } from "../contexts/listener"; import * as main from "../store/tinybase/main"; @@ -7,12 +7,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 +26,59 @@ 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, + // Track the enhanced note ID we create for auto-enhancement + const [autoEnhancedNoteId, setAutoEnhancedNoteId] = useState( + null, ); - const enhanceTask = useAITaskTask(enhanceTaskId, "enhance", { - onSuccess: ({ text }) => { - if (text) { - updateEnhancedMd(text); - } - }, - }); + // Track which IDs we've already started tasks for (prevents infinite retries) + const startedTasksRef = useRef>(new Set()); - const startEnhance = useCallback(() => { - if (!model || !hasTranscript || enhanceTask.status === "generating") { + // Set up the task for the auto-enhanced note (only if we have an ID) + const enhanceTaskId = autoEnhancedNoteId + ? createTaskId(autoEnhancedNoteId, "enhance") + : createTaskId("placeholder", "enhance"); // Placeholder to satisfy hook + const enhanceTask = useAITaskTask(enhanceTaskId, "enhance"); + + const createAndStartEnhance = useCallback(() => { + if (!model || !hasTranscript) { return; } - void enhanceTask.start({ - model, - args: { sessionId }, + // Create new enhanced note using the hook (avoids duplication) + const enhancedNoteId = createEnhancedNote(sessionId); + if (!enhancedNoteId) return; + + // Set the ID so the task hook will pick it up + setAutoEnhancedNoteId(enhancedNoteId); + + // Switch to the new enhanced tab + updateSessionTabState(tab, { + editor: { type: "enhanced", enhancedNoteId }, }); - }, [hasTranscript, model, enhanceTask.status, enhanceTask.start, sessionId]); + }, [ + hasTranscript, + model, + sessionId, + tab, + updateSessionTabState, + createEnhancedNote, + ]); + + // Start the task once the enhanced note ID is set (only once per ID) + 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 +86,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..e497a585bf --- /dev/null +++ b/apps/desktop/src/hooks/useEnhancedNotes.ts @@ -0,0 +1,138 @@ +import { useCallback } from "react"; + +import * as main from "../store/tinybase/main"; + +/** + * Hook to create a new enhanced note for a session + * Returns the ID of the created note + * Note: This only creates the row - starting the enhancement task + * should be handled separately by the caller + */ +export function useCreateEnhancedNote() { + const store = main.UI.useStore(main.STORE_ID) as main.Store | undefined; + + return useCallback( + (sessionId: string, templateId?: string) => { + if (!store) return null; + + const enhancedNoteId = crypto.randomUUID(); + const now = new Date().toISOString(); + const userId = store.getValue("user_id"); + + // Get next position number by counting existing rows + let existingCount = 0; + store.forEachRow("enhanced_notes", (rowId, _forEachCell) => { + const rowSessionId = store.getCell( + "enhanced_notes", + rowId, + "session_id", + ); + if (rowSessionId === sessionId) { + existingCount++; + } + }); + const nextPosition = existingCount + 1; + + let title = "Summary"; + if (templateId) { + const templateTitle = store.getCell("templates", templateId, "title"); + title = templateTitle || "Summary"; + } + + // Create the enhanced note row + store.setRow("enhanced_notes", enhancedNoteId, { + user_id: userId || "", + created_at: now, + session_id: sessionId, + content: "", + position: nextPosition, + title, + template_id: templateId, + }); + + return enhancedNoteId; + }, + [store], + ); +} + +/** + * Hook to delete an enhanced note + */ +export function useDeleteEnhancedNote() { + const store = main.UI.useStore(main.STORE_ID); + + return useCallback( + (enhancedNoteId: string) => { + if (!store) return; + + // Delete the row + store.delRow("enhanced_notes", enhancedNoteId); + + // Note: We don't renumber positions - they stay as-is + // This prevents unnecessary updates to unrelated notes + }, + [store], + ); +} + +/** + * Hook to rename an enhanced note + */ +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], + ); +} + +/** + * Hook to get all enhanced notes for a session + */ +export function useEnhancedNotes(sessionId: string) { + return main.UI.useSliceRowIds( + main.INDEXES.enhancedNotesBySession, + sessionId, + main.STORE_ID, + ); +} + +/** + * Hook to get a specific enhanced note's data + */ +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/routes/app/settings/_layout.tsx b/apps/desktop/src/routes/app/settings/_layout.tsx index 8acd6d19e8..70daa04517 100644 --- a/apps/desktop/src/routes/app/settings/_layout.tsx +++ b/apps/desktop/src/routes/app/settings/_layout.tsx @@ -310,18 +310,44 @@ function InnerHeader({ } function useDeleteTemplate(templateId: string) { + const store = main.UI.useStore(main.STORE_ID); const handleDeleteRow = main.UI.useDelRowCallback( "templates", templateId, main.STORE_ID, ); - + const handleArchive = main.UI.useSetPartialRowCallback( + "templates", + templateId, + () => ({ archived: true }), + [], + main.STORE_ID, + ); const navigate = Route.useNavigate(); const handleDelete = useCallback(() => { - handleDeleteRow(); + if (!store) return; + + let hasRelatedNotes = false; + store.forEachRow("enhanced_notes", (rowId, _forEachCell) => { + const noteTemplateId = store.getCell( + "enhanced_notes", + rowId, + "template_id", + ); + if (noteTemplateId === templateId) { + hasRelatedNotes = true; + } + }); + + if (hasRelatedNotes) { + handleArchive(); + } else { + handleDeleteRow(); + } + navigate({ search: { tab: "templates" } }); - }, [handleDeleteRow, navigate]); + }, [store, templateId, handleArchive, handleDeleteRow, navigate]); return handleDelete; } diff --git a/apps/desktop/src/store/tinybase/main.ts b/apps/desktop/src/store/tinybase/main.ts index 80aae41e30..b635f2d16d 100644 --- a/apps/desktop/src/store/tinybase/main.ts +++ b/apps/desktop/src/store/tinybase/main.ts @@ -22,6 +22,7 @@ import { import { TABLE_HUMANS, TABLE_SESSIONS } from "@hypr/db"; import { getCurrentWebviewWindowLabel } from "@hypr/plugin-windows"; +import { EMPTY_TIPTAP_DOC_STRING } from "@hypr/tiptap/shared"; import { format } from "@hypr/utils"; import { DEFAULT_USER_ID } from "../../utils"; @@ -328,6 +329,12 @@ export const StoreComponent = ({ persist = true }: { persist?: boolean }) => { "chat_messages", "chat_groups", "chat_group_id", + ) + .setRelationshipDefinition( + RELATIONSHIPS.enhancedNoteToSession, + "enhanced_notes", + "sessions", + "session_id", ), [], )!; @@ -392,11 +399,12 @@ export const StoreComponent = ({ persist = true }: { persist?: boolean }) => { .setQueryDefinition( QUERIES.visibleTemplates, "templates", - ({ select }) => { + ({ select, where }) => { select("title"); select("description"); select("sections"); select("created_at"); + where((getCell) => !getCell("archived")); }, ) .setQueryDefinition(QUERIES.visibleFolders, "folders", ({ select }) => { @@ -588,6 +596,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 +679,7 @@ export const INDEXES = { tagSessionsByTag: "tagSessionsByTag", chatMessagesByGroup: "chatMessagesByGroup", sessionsByHuman: "sessionsByHuman", + enhancedNotesBySession: "enhancedNotesBySession", }; export const RELATIONSHIPS = { @@ -682,4 +697,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..c294048f24 100644 --- a/apps/desktop/src/store/tinybase/schema-external.ts +++ b/apps/desktop/src/store/tinybase/schema-external.ts @@ -99,6 +99,7 @@ export const templateSchema = baseTemplateSchema.omit({ id: true }).extend({ jsonObject(z.array(z.string())).optional(), ), sections: jsonObject(z.array(templateSectionSchema)), + archived: z.boolean().optional(), }); export const chatGroupSchema = baseChatGroupSchema @@ -116,6 +117,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 +166,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 +177,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: { @@ -263,6 +276,7 @@ export const externalTableSchemaForTinybase = { title: { type: "string" }, description: { type: "string" }, sections: { type: "string" }, + archived: { type: "boolean" }, } satisfies InferTinyBaseSchema, chat_groups: { user_id: { type: "string" }, @@ -284,4 +298,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/schema.ts b/apps/desktop/src/store/zustand/tabs/schema.ts index 44be10ec2a..14316a1f68 100644 --- a/apps/desktop/src/store/zustand/tabs/schema.ts +++ b/apps/desktop/src/store/zustand/tabs/schema.ts @@ -7,9 +7,23 @@ 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; +// prettier-ignore +export const isEnhancedView = (view: EditorView): view is { type: "enhanced"; id: string } => view.type === "enhanced"; +// prettier-ignore +export const isRawView = (view: EditorView): view is { type: "raw" } => view.type === "raw"; +// prettier-ignore +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 +78,7 @@ export type TabInput = | { type: "sessions"; id: string; - state?: { editor?: "raw" | "enhanced" | "transcript" }; + state?: { editor?: EditorView }; } | { type: "contacts";