diff --git a/apps/desktop/src/components/main/body/ai.tsx b/apps/desktop/src/components/main/body/ai.tsx index d64189fd60..a9f1477a55 100644 --- a/apps/desktop/src/components/main/body/ai.tsx +++ b/apps/desktop/src/components/main/body/ai.tsx @@ -1,6 +1,7 @@ import { AudioLinesIcon, BookText, + Brain, MessageSquare, Plus, Search, @@ -22,6 +23,7 @@ import * as main from "../../../store/tinybase/store/main"; import { type Tab, useTabs } from "../../../store/zustand/tabs"; import { LLM } from "../../settings/ai/llm"; import { STT } from "../../settings/ai/stt"; +import { SettingsMemory } from "../../settings/memory"; import { StandardTabWrapper } from "./index"; import { useWebResources } from "./resource-list"; import { type TabItem, TabItemBase } from "./shared"; @@ -32,7 +34,8 @@ type AITabKey = | "intelligence" | "templates" | "shortcuts" - | "prompts"; + | "prompts" + | "memory"; export const TabItemAI: TabItem> = ({ tab, @@ -50,6 +53,7 @@ export const TabItemAI: TabItem> = ({ templates: "Templates", shortcuts: "Shortcuts", prompts: "Prompts", + memory: "Memory", }; const suffix = labelMap[(tab.state.tab as AITabKey) ?? "transcription"] ?? "STT"; @@ -113,6 +117,11 @@ function AIView({ tab }: { tab: Extract }) { label: "Intelligence", icon: , }, + { + key: "memory", + label: "Memory", + icon: , + }, { key: "templates", label: "Templates", @@ -163,6 +172,7 @@ function AIView({ tab }: { tab: Extract }) { {activeTab === "templates" && } {activeTab === "shortcuts" && } {activeTab === "prompts" && } + {activeTab === "memory" && } {!atStart && } {!atEnd && } diff --git a/apps/desktop/src/components/settings/memory/custom-vocabulary.tsx b/apps/desktop/src/components/settings/memory/custom-vocabulary.tsx new file mode 100644 index 0000000000..9a397f691a --- /dev/null +++ b/apps/desktop/src/components/settings/memory/custom-vocabulary.tsx @@ -0,0 +1,361 @@ +import { useForm } from "@tanstack/react-form"; +import { + Check, + CornerDownLeft, + MinusCircle, + Pencil, + Plus, + Search, + X, +} from "lucide-react"; +import { useMemo, useState } from "react"; + +import { Button } from "@hypr/ui/components/ui/button"; +import { cn } from "@hypr/utils"; + +import * as main from "../../../store/tinybase/store/main"; + +interface VocabItem { + text: string; + rowId: string; +} + +export function CustomVocabularyView() { + const vocabItems = useVocabs(); + const mutations = useVocabMutations(); + const [editingId, setEditingId] = useState(null); + const [searchValue, setSearchValue] = useState(""); + + const form = useForm({ + defaultValues: { + search: "", + }, + onSubmit: ({ value }) => { + const text = value.search.trim(); + if (text) { + const allTexts = vocabItems.map((item) => item.text.toLowerCase()); + if (allTexts.includes(text.toLowerCase())) { + return; + } + mutations.create(text); + form.reset(); + setSearchValue(""); + } + }, + }); + + const filteredItems = useMemo(() => { + if (!searchValue.trim()) { + return vocabItems; + } + const query = searchValue.toLowerCase(); + return vocabItems.filter((item) => item.text.toLowerCase().includes(query)); + }, [vocabItems, searchValue]); + + const itemIndexMap = useMemo(() => { + return new Map(vocabItems.map((item, index) => [item.rowId, index + 1])); + }, [vocabItems]); + + const allTexts = vocabItems.map((item) => item.text.toLowerCase()); + const exactMatch = allTexts.includes(searchValue.trim().toLowerCase()); + const showAddEntry = searchValue.trim() && !exactMatch; + + return ( +
+

Custom vocabulary

+ +
+
{ + e.preventDefault(); + e.stopPropagation(); + form.handleSubmit(); + }} + className="flex items-center gap-2 px-3 h-9 border-b border-neutral-200 bg-stone-50" + > + + + {(field) => ( + { + field.handleChange(e.target.value); + setSearchValue(e.target.value); + }} + placeholder="Search or add custom vocabulary" + className="flex-1 text-sm text-neutral-900 placeholder:text-neutral-500 focus:outline-none bg-transparent" + /> + )} + + + +
+ {showAddEntry && ( + + )} + {filteredItems.length === 0 && !showAddEntry ? ( +
+ No custom vocabulary added +
+ ) : ( + filteredItems.map((item: VocabItem) => ( + 0} + onStartEdit={() => setEditingId(item.rowId)} + onCancelEdit={() => setEditingId(null)} + onUpdate={mutations.update} + onRemove={() => mutations.delete(item.rowId)} + /> + )) + )} +
+
+
+ ); +} + +interface VocabularyItemProps { + item: VocabItem; + itemNumber: number; + vocabItems: VocabItem[]; + isEditing: boolean; + isSearching: boolean; + onStartEdit: () => void; + onCancelEdit: () => void; + onUpdate: (rowId: string, text: string) => void; + onRemove: () => void; +} + +function VocabularyItem({ + item, + itemNumber, + vocabItems, + isEditing, + isSearching, + onStartEdit, + onCancelEdit, + onUpdate, + onRemove, +}: VocabularyItemProps) { + const [hoveredItem, setHoveredItem] = useState(false); + + const form = useForm({ + defaultValues: { + text: item.text, + }, + onSubmit: ({ value }) => { + const text = value.text.trim(); + if (text && text !== item.text) { + onUpdate(item.rowId, text); + } + onCancelEdit(); + }, + validators: { + onChange: ({ value }) => { + const text = value.text.trim(); + if (!text) { + return { + fields: { + text: "Vocabulary term cannot be empty", + }, + }; + } + const isDuplicate = vocabItems.some( + (v) => + v.rowId !== item.rowId && + v.text.toLowerCase() === text.toLowerCase(), + ); + if (isDuplicate) { + return { + fields: { + text: "This term already exists", + }, + }; + } + return undefined; + }, + }, + }); + + return ( +
setHoveredItem(true)} + onMouseLeave={() => setHoveredItem(false)} + > +
+ + {itemNumber} + + {isEditing ? ( + + {(field) => ( + field.handleChange(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + form.handleSubmit(); + } else if (e.key === "Escape") { + e.preventDefault(); + onCancelEdit(); + } + }} + className="flex-1 text-sm text-neutral-900 focus:outline-none bg-transparent" + autoFocus + /> + )} + + ) : ( + {item.text} + )} +
+
+ {isEditing ? ( + [state.canSubmit]}> + {([canSubmit]) => ( + <> + + + + )} + + ) : ( + hoveredItem && ( + <> + + + + ) + )} +
+
+ ); +} + +function useVocabs() { + const table = main.UI.useResultTable( + main.QUERIES.visibleVocabs, + main.STORE_ID, + ); + + return Object.entries(table ?? {}).map( + ([rowId, { text }]) => + ({ + rowId, + text, + }) as VocabItem, + ); +} + +function useVocabMutations() { + const { user_id } = main.UI.useValues(main.STORE_ID); + + const createRow = main.UI.useSetRowCallback( + "memories", + () => crypto.randomUUID(), + (text: string) => ({ + user_id: user_id!, + type: "vocab", + text, + created_at: new Date().toISOString(), + }), + [user_id], + main.STORE_ID, + ); + + const updateRow = main.UI.useSetPartialRowCallback( + "memories", + ({ rowId }: { rowId: string; text: string }) => rowId, + ({ text }: { rowId: string; text: string }) => ({ text }), + [], + main.STORE_ID, + ) as (args: { rowId: string; text: string }) => void; + + const deleteRow = main.UI.useDelRowCallback( + "memories", + (rowId: string) => rowId, + main.STORE_ID, + ); + + return { + create: (text: string) => { + if (!user_id) return; + createRow(text); + }, + update: (rowId: string, text: string) => { + updateRow({ rowId, text }); + }, + delete: (rowId: string) => { + deleteRow(rowId); + }, + }; +} diff --git a/apps/desktop/src/components/settings/memory/index.tsx b/apps/desktop/src/components/settings/memory/index.tsx new file mode 100644 index 0000000000..b044be9ac6 --- /dev/null +++ b/apps/desktop/src/components/settings/memory/index.tsx @@ -0,0 +1,9 @@ +import { CustomVocabularyView } from "./custom-vocabulary"; + +export function SettingsMemory() { + return ( +
+ +
+ ); +} diff --git a/apps/desktop/src/store/tinybase/persister/memory/index.ts b/apps/desktop/src/store/tinybase/persister/memory/index.ts new file mode 100644 index 0000000000..0205a9f152 --- /dev/null +++ b/apps/desktop/src/store/tinybase/persister/memory/index.ts @@ -0,0 +1,25 @@ +import * as _UI from "tinybase/ui-react/with-schemas"; + +import { getCurrentWebviewWindowLabel } from "@hypr/plugin-windows"; +import { type Schemas } from "@hypr/store"; + +import type { Store } from "../../store/main"; +import { createMemoryPersister } from "./persister"; + +const { useCreatePersister } = _UI as _UI.WithSchemas; + +export function useMemoryPersister(store: Store) { + return useCreatePersister( + store, + async (store) => { + const persister = createMemoryPersister(store as Store); + if (getCurrentWebviewWindowLabel() === "main") { + await persister.startAutoPersisting(); + } else { + await persister.startAutoLoad(); + } + return persister; + }, + [], + ); +} diff --git a/apps/desktop/src/store/tinybase/persister/memory/persister.ts b/apps/desktop/src/store/tinybase/persister/memory/persister.ts new file mode 100644 index 0000000000..329b9ed932 --- /dev/null +++ b/apps/desktop/src/store/tinybase/persister/memory/persister.ts @@ -0,0 +1,10 @@ +import type { Store } from "../../store/main"; +import { createJsonFilePersister } from "../factories"; + +export function createMemoryPersister(store: Store) { + return createJsonFilePersister(store, { + tableName: "memories", + filename: "memories.json", + label: "MemoryPersister", + }); +} diff --git a/apps/desktop/src/store/tinybase/store/main.ts b/apps/desktop/src/store/tinybase/store/main.ts index 795c6e716d..a08f4a5b9e 100644 --- a/apps/desktop/src/store/tinybase/store/main.ts +++ b/apps/desktop/src/store/tinybase/store/main.ts @@ -205,6 +205,16 @@ export const StoreComponent = () => { where("user_id", (param("user_id") as string) ?? ""); }, { user_id: "" }, + ) + .setQueryDefinition( + QUERIES.visibleVocabs, + "memories", + ({ select, where }) => { + select("text"); + select("type"); + select("created_at"); + where("type", "vocab"); + }, ), [], )!; @@ -355,6 +365,7 @@ export const QUERIES = { visibleHumans: "visibleHumans", visibleTemplates: "visibleTemplates", visibleChatShortcuts: "visibleChatShortcuts", + visibleVocabs: "visibleVocabs", sessionParticipantsWithDetails: "sessionParticipantsWithDetails", sessionRecordingTimes: "sessionRecordingTimes", enabledAppleCalendars: "enabledAppleCalendars", @@ -454,6 +465,11 @@ interface _QueryResultRows { sections: string; user_id: string; }; + visibleVocabs: { + text: string; + type: string; + created_at: string; + }; } export type QueryResultRowMap = { [K in QueryId]: _QueryResultRows[K] }; diff --git a/apps/desktop/src/store/tinybase/store/persisters.ts b/apps/desktop/src/store/tinybase/store/persisters.ts index 9a09d7585c..f8feda6836 100644 --- a/apps/desktop/src/store/tinybase/store/persisters.ts +++ b/apps/desktop/src/store/tinybase/store/persisters.ts @@ -7,6 +7,7 @@ import { useChatPersister } from "../persister/chat"; import { useChatShortcutPersister } from "../persister/chat-shortcuts"; import { useEventsPersister } from "../persister/events"; import { useHumanPersister } from "../persister/human"; +import { useMemoryPersister } from "../persister/memory"; import { useOrganizationPersister } from "../persister/organization"; import { usePromptPersister } from "../persister/prompts"; import { useSessionPersister } from "../persister/session"; @@ -27,6 +28,7 @@ export function useMainPersisters(store: Store) { const promptPersister = usePromptPersister(store); const templatePersister = useTemplatePersister(store); const calendarPersister = useCalendarPersister(store); + const memoryPersister = useMemoryPersister(store); useEffect(() => { if (getCurrentWebviewWindowLabel() !== "main") { @@ -44,6 +46,7 @@ export function useMainPersisters(store: Store) { { id: "prompt", persister: promptPersister }, { id: "template", persister: templatePersister }, { id: "calendar", persister: calendarPersister }, + { id: "memory", persister: memoryPersister }, ]; const unsubscribes = persisters @@ -68,6 +71,7 @@ export function useMainPersisters(store: Store) { promptPersister, templatePersister, calendarPersister, + memoryPersister, ]); useInitializeStore(store, { @@ -87,5 +91,6 @@ export function useMainPersisters(store: Store) { promptPersister, templatePersister, calendarPersister, + memoryPersister, }; } diff --git a/packages/store/src/tinybase.ts b/packages/store/src/tinybase.ts index a3ba1788c0..48974e1903 100644 --- a/packages/store/src/tinybase.ts +++ b/packages/store/src/tinybase.ts @@ -13,6 +13,7 @@ import { mappingMentionSchema, mappingSessionParticipantSchema, mappingTagSessionSchema, + memorySchema, organizationSchema, promptSchema, sessionSchema, @@ -143,6 +144,12 @@ export const tableSchemaForTinybase = { title: { type: "string" }, content: { type: "string" }, } as const satisfies InferTinyBaseSchema, + memories: { + user_id: { type: "string" }, + type: { type: "string" }, + text: { type: "string" }, + created_at: { type: "string" }, + } as const satisfies InferTinyBaseSchema, } as const satisfies TablesSchema; export const valueSchemaForTinybase = { diff --git a/packages/store/src/zod.ts b/packages/store/src/zod.ts index 75097c0c4c..b6c31065f6 100644 --- a/packages/store/src/zod.ts +++ b/packages/store/src/zod.ts @@ -160,6 +160,13 @@ export const chatShortcutSchema = z.object({ content: z.string(), }); +export const memorySchema = z.object({ + user_id: z.string(), + type: z.string(), + text: z.string(), + created_at: z.string(), +}); + export const enhancedNoteSchema = z.object({ user_id: z.string(), session_id: z.string(), @@ -258,6 +265,7 @@ export type TemplateSection = z.infer; export type ChatGroup = z.infer; export type ChatMessage = z.infer; export type ChatShortcut = z.infer; +export type Memory = z.infer; export type EnhancedNote = z.infer; export type Prompt = z.infer; export type AIProvider = z.infer; @@ -274,6 +282,7 @@ export type HumanStorage = ToStorageType; export type OrganizationStorage = ToStorageType; export type PromptStorage = ToStorageType; export type ChatShortcutStorage = ToStorageType; +export type MemoryStorage = ToStorageType; export type EventStorage = ToStorageType; export type MappingSessionParticipantStorage = ToStorageType< typeof mappingSessionParticipantSchema diff --git a/plugins/windows/js/bindings.gen.ts b/plugins/windows/js/bindings.gen.ts index f42494643f..eb562f6bd7 100644 --- a/plugins/windows/js/bindings.gen.ts +++ b/plugins/windows/js/bindings.gen.ts @@ -70,7 +70,7 @@ windowDestroyed: "plugin:windows:window-destroyed" /** user-defined types **/ export type AiState = { tab: AiTab | null } -export type AiTab = "transcription" | "intelligence" | "templates" | "shortcuts" | "prompts" +export type AiTab = "transcription" | "intelligence" | "templates" | "shortcuts" | "prompts" | "memory" export type AppWindow = { type: "onboarding" } | { type: "main" } | { type: "control" } export type ChangelogState = { previous: string | null; current: string } export type ChatShortcutsState = { isWebMode: boolean | null; selectedMineId: string | null; selectedWebIndex: number | null } diff --git a/plugins/windows/src/tab/state.rs b/plugins/windows/src/tab/state.rs index 0b56331ba0..b36f946944 100644 --- a/plugins/windows/src/tab/state.rs +++ b/plugins/windows/src/tab/state.rs @@ -76,6 +76,8 @@ crate::common_derives! { Shortcuts, #[serde(rename = "prompts")] Prompts, + #[serde(rename = "memory")] + Memory, } }