diff options
| -rw-r--r-- | packages/web/src/app/dashboard/components/index.ts | 5 | ||||
| -rw-r--r-- | packages/web/src/app/dashboard/components/memory-card.tsx | 66 | ||||
| -rw-r--r-- | packages/web/src/app/dashboard/components/memory-edit-modal.tsx | 109 | ||||
| -rw-r--r-- | packages/web/src/app/dashboard/components/project-badge.tsx | 21 | ||||
| -rw-r--r-- | packages/web/src/app/dashboard/components/project-filter.tsx | 35 | ||||
| -rw-r--r-- | packages/web/src/app/dashboard/components/search-bar.tsx | 24 | ||||
| -rw-r--r-- | packages/web/src/app/dashboard/dashboard-content.tsx | 334 | ||||
| -rw-r--r-- | packages/web/src/env.js | 2 | ||||
| -rw-r--r-- | packages/web/src/hooks/use-debounce.ts | 19 | ||||
| -rw-r--r-- | packages/web/src/server/api/root.ts | 2 | ||||
| -rw-r--r-- | packages/web/src/server/api/routers/memory.ts | 142 | ||||
| -rw-r--r-- | packages/web/src/server/api/routers/project.ts | 79 |
12 files changed, 763 insertions, 75 deletions
diff --git a/packages/web/src/app/dashboard/components/index.ts b/packages/web/src/app/dashboard/components/index.ts new file mode 100644 index 0000000..e0ec52d --- /dev/null +++ b/packages/web/src/app/dashboard/components/index.ts @@ -0,0 +1,5 @@ +export { MemoryCard } from "./memory-card"; +export { MemoryEditModal } from "./memory-edit-modal"; +export { ProjectBadge } from "./project-badge"; +export { ProjectFilter } from "./project-filter"; +export { SearchBar } from "./search-bar"; diff --git a/packages/web/src/app/dashboard/components/memory-card.tsx b/packages/web/src/app/dashboard/components/memory-card.tsx new file mode 100644 index 0000000..2b4aca1 --- /dev/null +++ b/packages/web/src/app/dashboard/components/memory-card.tsx @@ -0,0 +1,66 @@ +"use client"; + +import type { Memory, Project } from "@imemio/sdk"; +import { ProjectBadge } from "./project-badge"; + +type MemoryWithProject = Memory & { + project: Project | null; +}; + +type MemoryCardProps = { + memory: MemoryWithProject; + onEdit: () => void; + onDelete: () => void; + isDeleting?: boolean; +}; + +export function MemoryCard({ + memory, + onEdit, + onDelete, + isDeleting, +}: MemoryCardProps) { + return ( + <div className="flex flex-col gap-2 border border-[#2a2a2a] bg-[#0f0f0f] p-3"> + <div className="flex items-start justify-between gap-4"> + <div className="flex-1"> + <p className="whitespace-pre-wrap text-white">{memory.content}</p> + </div> + </div> + + <div className="flex items-center justify-between"> + <div className="flex items-center gap-2"> + {memory.project && ( + <ProjectBadge + isGlobal={memory.project.isGlobal} + name={memory.project.name} + /> + )} + + <span className="text-xs text-[#666666]"> + {new Date(memory.createdAt).toLocaleString()} + </span> + </div> + + <div className="flex gap-2"> + <button + className="border border-[#2a2a2a] bg-[#0f0f0f] px-3 py-1 text-xs text-[#999999] transition hover:border-[#666666] hover:text-white" + onClick={onEdit} + type="button" + > + edit + </button> + + <button + className="border border-[#2a2a2a] bg-[#0f0f0f] px-3 py-1 text-xs text-[#999999] transition hover:border-[#662222] hover:text-[#ff6666]" + disabled={isDeleting} + onClick={onDelete} + type="button" + > + {isDeleting ? "deleting ..." : "delete"} + </button> + </div> + </div> + </div> + ); +} diff --git a/packages/web/src/app/dashboard/components/memory-edit-modal.tsx b/packages/web/src/app/dashboard/components/memory-edit-modal.tsx new file mode 100644 index 0000000..30f6161 --- /dev/null +++ b/packages/web/src/app/dashboard/components/memory-edit-modal.tsx @@ -0,0 +1,109 @@ +"use client"; + +import type { Memory, Project } from "@imemio/sdk"; +import { useEffect, useState } from "react"; + +type MemoryWithProject = Memory & { + project: Project | null; +}; + +type MemoryEditModalProps = { + memory: MemoryWithProject; + projects: Project[]; + onSave: (data: { content: string; projectId: string }) => void; + onClose: () => void; + isSaving?: boolean; +}; + +export function MemoryEditModal({ + memory, + projects, + onSave, + onClose, + isSaving, +}: MemoryEditModalProps) { + const [content, setContent] = useState(memory.content); + const [projectId, setProjectId] = useState(memory.projectId); + + useEffect(() => { + setContent(memory.content); + setProjectId(memory.projectId); + }, [memory]); + + const handleSubmit = (formSubmitEvent: React.FormEvent) => { + formSubmitEvent.preventDefault(); + + if (content.trim()) { + onSave({ content, projectId }); + } + }; + + return ( + <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/80"> + <div className="w-full max-w-lg border border-[#2a2a2a] bg-[#0f0f0f] p-6"> + <div className="mb-4 flex items-center justify-between"> + <h2 className="text-lg text-white"> + <span className="text-[#999999]">></span> edit memory + </h2> + + <button + className="text-[#666666] transition hover:text-white" + onClick={onClose} + type="button" + > + [esc] + </button> + </div> + + <form className="flex flex-col gap-4" onSubmit={handleSubmit}> + <div> + <label className="mb-1 block text-xs text-[#666666]">content</label> + <textarea + className="w-full border border-[#2a2a2a] bg-[#070707] px-3 py-2 text-white placeholder:text-[#666666] focus:border-[#666666] focus:outline-none" + onChange={(textareaChangeEvent) => + setContent(textareaChangeEvent.target.value) + } + rows={5} + value={content} + /> + </div> + + <div> + <label className="mb-1 block text-xs text-[#666666]">project</label> + <select + className="w-full border border-[#2a2a2a] bg-[#070707] px-3 py-2 text-white focus:border-[#666666] focus:outline-none" + onChange={(selectChangeEvent) => + setProjectId(selectChangeEvent.target.value) + } + value={projectId} + > + {projects.map((project) => ( + <option key={project.id} value={project.id}> + {project.isGlobal ? `* ${project.name} (global)` : project.name} + </option> + ))} + </select> + </div> + + <div className="flex justify-end gap-2"> + <button + className="border border-[#2a2a2a] bg-[#0f0f0f] px-4 py-2 text-[#999999] transition hover:border-[#666666] hover:text-white" + onClick={onClose} + type="button" + > + cancel + </button> + + <button + className="border border-[#2a2a2a] bg-[#0f0f0f] px-4 py-2 text-white transition hover:border-[#666666] disabled:text-[#666666] disabled:hover:border-[#2a2a2a]" + disabled={isSaving || !content.trim()} + type="submit" + > + {isSaving ? "saving ..." : "save changes"} + </button> + </div> + </form> + </div> + </div> + ); +} diff --git a/packages/web/src/app/dashboard/components/project-badge.tsx b/packages/web/src/app/dashboard/components/project-badge.tsx new file mode 100644 index 0000000..ee95cbf --- /dev/null +++ b/packages/web/src/app/dashboard/components/project-badge.tsx @@ -0,0 +1,21 @@ +type ProjectBadgeProps = { + name: string; + isGlobal?: boolean; +}; + +export function ProjectBadge({ name, isGlobal }: ProjectBadgeProps) { + if (isGlobal) { + return ( + <span className="inline-flex items-center border border-[#3d3d1f] bg-[#1a1a0f] px-2 py-0.5 text-xs text-[#b3b366]"> + <span className="mr-1 text-[#666633]">*</span> + {name} + </span> + ); + } + + return ( + <span className="inline-flex items-center border border-[#2a2a2a] bg-[#0f0f0f] px-2 py-0.5 text-xs text-[#666666]"> + {name} + </span> + ); +} diff --git a/packages/web/src/app/dashboard/components/project-filter.tsx b/packages/web/src/app/dashboard/components/project-filter.tsx new file mode 100644 index 0000000..d2b9818 --- /dev/null +++ b/packages/web/src/app/dashboard/components/project-filter.tsx @@ -0,0 +1,35 @@ +"use client"; + +import type { Project } from "@imemio/sdk"; + +type ProjectFilterProps = { + projects: Project[]; + selectedProjectId: string | undefined; + onProjectChange: (projectId: string | undefined) => void; +}; + +export function ProjectFilter({ + projects, + selectedProjectId, + onProjectChange, +}: ProjectFilterProps) { + return ( + <select + className="border border-[#2a2a2a] bg-[#0f0f0f] px-3 py-1.5 text-sm text-white focus:border-[#666666] focus:outline-none" + onChange={(selectChangeEvent) => { + const value = selectChangeEvent.target.value; + + onProjectChange(value === "" ? undefined : value); + }} + value={selectedProjectId ?? ""} + > + <option value="">all projects</option> + + {projects.map((project) => ( + <option key={project.id} value={project.id}> + {project.isGlobal ? `* ${project.name}` : project.name} + </option> + ))} + </select> + ); +} diff --git a/packages/web/src/app/dashboard/components/search-bar.tsx b/packages/web/src/app/dashboard/components/search-bar.tsx new file mode 100644 index 0000000..5791e22 --- /dev/null +++ b/packages/web/src/app/dashboard/components/search-bar.tsx @@ -0,0 +1,24 @@ +"use client"; + +type SearchBarProps = { + value: string; + onChange: (value: string) => void; + placeholder?: string; +}; + +export function SearchBar({ value, onChange, placeholder }: SearchBarProps) { + return ( + <div className="relative"> + <span className="absolute left-3 top-1/2 -translate-y-1/2 text-[#666666]"> + / + </span> + <input + className="w-full border border-[#2a2a2a] bg-[#0f0f0f] py-1.5 pl-7 pr-3 text-white placeholder:text-[#666666] focus:border-[#666666] focus:outline-none" + onChange={(inputChangeEvent) => onChange(inputChangeEvent.target.value)} + placeholder={placeholder ?? "search memories ..."} + type="text" + value={value} + /> + </div> + ); +} diff --git a/packages/web/src/app/dashboard/dashboard-content.tsx b/packages/web/src/app/dashboard/dashboard-content.tsx index aab2cfd..cd52ea7 100644 --- a/packages/web/src/app/dashboard/dashboard-content.tsx +++ b/packages/web/src/app/dashboard/dashboard-content.tsx @@ -1,24 +1,38 @@ "use client"; +import type { Memory, Project } from "@imemio/sdk"; import Link from "next/link"; -import { useState } from "react"; +import { Suspense, useState } from "react"; +import { useDebounce } from "~/hooks/use-debounce"; import { api } from "~/trpc/react"; +import { + MemoryCard, + MemoryEditModal, + ProjectFilter, + SearchBar, +} from "./components"; -function MemoryList() { - const [memories] = api.memory.list.useSuspenseQuery(); - const trpcUtilities = api.useUtils(); - const deleteMemory = api.memory.delete.useMutation({ - onSuccess: async () => { - await trpcUtilities.memory.invalidate(); - }, - }); +type MemoryWithProject = Memory & { + project: Project | null; +}; +function MemoryListContent({ + memories, + projects, + onEdit, + deletingId, + onDelete, +}: { + memories: MemoryWithProject[]; + projects: Project[]; + onEdit: (memory: MemoryWithProject) => void; + deletingId: string | null; + onDelete: (id: string) => void; +}) { if (memories.length === 0) { return ( <div className="border border-[#2a2a2a] bg-[#0f0f0f] p-4 text-center"> - <p className="text-[#666666]"> - no memories yet. create your first one below. - </p> + <p className="text-[#666666]">no memories found.</p> </div> ); } @@ -26,32 +40,123 @@ function MemoryList() { return ( <div className="flex w-full flex-col gap-2"> {memories.map((memory) => ( - <div - className="flex items-start justify-between gap-4 border border-[#2a2a2a] bg-[#0f0f0f] p-3" + <MemoryCard + isDeleting={deletingId === memory.id} key={memory.id} - > - <div className="flex-1"> - <p className="text-white">{memory.content}</p> - <p className="mt-1 text-xs text-[#666666]"> - {new Date(memory.createdAt).toLocaleString()} - </p> - </div> - <button - className="border border-[#2a2a2a] bg-[#0f0f0f] px-3 py-1 text-sm text-[#999999] transition hover:border-[#666666] hover:text-white" - disabled={deleteMemory.isPending} - onClick={() => deleteMemory.mutate({ id: memory.id })} - type="button" - > - {deleteMemory.isPending ? "deleting ..." : "delete"} - </button> - </div> + memory={memory} + onDelete={() => onDelete(memory.id)} + onEdit={() => onEdit(memory)} + /> ))} </div> ); } -function CreateMemoryForm() { +function MemoryList({ + searchQuery, + selectedProjectId, + onEdit, +}: { + searchQuery: string; + selectedProjectId: string | undefined; + onEdit: (memory: MemoryWithProject) => void; +}) { + const debouncedSearchQuery = useDebounce(searchQuery, 300); + const [deletingId, setDeletingId] = useState<string | null>(null); + const trpcUtilities = api.useUtils(); + const isSearching = debouncedSearchQuery.length > 0; + const listQuery = api.memory.listWithProjects.useQuery( + { projectId: selectedProjectId, sortOrder: "desc" }, + { enabled: !isSearching }, + ); + const searchQueryResult = api.memory.search.useQuery( + { query: debouncedSearchQuery, projectId: selectedProjectId, limit: 50 }, + { enabled: isSearching }, + ); + const deleteMemory = api.memory.delete.useMutation({ + onMutate: ({ id }) => { + setDeletingId(id); + }, + onSettled: () => { + setDeletingId(null); + }, + onSuccess: async () => { + await trpcUtilities.memory.invalidate(); + }, + }); + const handleDelete = (id: string) => { + deleteMemory.mutate({ id }); + }; + + if (isSearching) { + if (searchQueryResult.isLoading) { + return ( + <div className="border border-[#2a2a2a] bg-[#0f0f0f] p-4 text-center"> + <p className="text-[#666666]">searching ...</p> + </div> + ); + } + + if (searchQueryResult.error) { + return ( + <div className="border border-[#2a2a2a] bg-[#0f0f0f] p-4 text-center"> + <p className="text-[#996666]">{searchQueryResult.error.message}</p> + </div> + ); + } + + const searchResults = searchQueryResult.data?.results ?? []; + const projects = searchQueryResult.data?.projects ?? []; + const memoriesWithProjects: MemoryWithProject[] = searchResults.map( + (result) => ({ + id: result.id, + content: result.content, + projectId: result.projectId, + folderId: result.folderId, + tags: result.tags, + metadata: result.metadata, + createdAt: result.createdAt, + updatedAt: result.updatedAt, + project: projects.find((project) => project.id === result.projectId) ?? null, + }), + ); + + return ( + <MemoryListContent + deletingId={deletingId} + memories={memoriesWithProjects} + onDelete={handleDelete} + onEdit={onEdit} + projects={projects} + /> + ); + } + + if (listQuery.isLoading) { + return ( + <div className="border border-[#2a2a2a] bg-[#0f0f0f] p-4 text-center"> + <p className="text-[#666666]">loading ...</p> + </div> + ); + } + + const memories = listQuery.data?.memories ?? []; + const projects = listQuery.data?.projects ?? []; + + return ( + <MemoryListContent + deletingId={deletingId} + memories={memories} + onDelete={handleDelete} + onEdit={onEdit} + projects={projects} + /> + ); +} + +function CreateMemoryForm({ projects }: { projects: Project[] }) { const [content, setContent] = useState(""); + const [projectId, setProjectId] = useState<string>(""); const trpcUtilities = api.useUtils(); const createMemory = api.memory.create.useMutation({ onSuccess: async () => { @@ -59,6 +164,8 @@ function CreateMemoryForm() { setContent(""); }, }); + const globalProject = projects.find((project) => project.isGlobal); + const effectiveProjectId = projectId || globalProject?.id; return ( <form @@ -66,8 +173,8 @@ function CreateMemoryForm() { onSubmit={(formSubmitEvent) => { formSubmitEvent.preventDefault(); - if (content.trim()) { - createMemory.mutate({ content }); + if (content.trim() && effectiveProjectId) { + createMemory.mutate({ content, projectId: effectiveProjectId }); } }} > @@ -80,50 +187,141 @@ function CreateMemoryForm() { rows={3} value={content} /> - <button - className="border border-[#2a2a2a] bg-[#0f0f0f] px-6 py-2 text-white transition hover:border-[#666666] disabled:text-[#666666] disabled:hover:border-[#2a2a2a]" - disabled={createMemory.isPending || !content.trim()} - type="submit" - > - {createMemory.isPending ? "creating ..." : "create memory"} - </button> + + <div className="flex items-center gap-3"> + <select + className="border border-[#2a2a2a] bg-[#0f0f0f] px-3 py-2 text-sm text-white focus:border-[#666666] focus:outline-none" + onChange={(selectChangeEvent) => + setProjectId(selectChangeEvent.target.value) + } + value={projectId} + > + <option value=""> + {globalProject ? `* ${globalProject.name} (default)` : "select project"} + </option> + + {projects + .filter((project) => !project.isGlobal) + .map((project) => ( + <option key={project.id} value={project.id}> + {project.name} + </option> + ))} + </select> + + <button + className="flex-1 border border-[#2a2a2a] bg-[#0f0f0f] px-6 py-2 text-white transition hover:border-[#666666] disabled:text-[#666666] disabled:hover:border-[#2a2a2a]" + disabled={createMemory.isPending || !content.trim() || !effectiveProjectId} + type="submit" + > + {createMemory.isPending ? "creating ..." : "create memory"} + </button> + </div> </form> ); } -export function DashboardContent() { +function DashboardInner() { + const [searchQuery, setSearchQuery] = useState(""); + const [selectedProjectId, setSelectedProjectId] = useState<string | undefined>(undefined); + const [editingMemory, setEditingMemory] = useState<MemoryWithProject | null>(null); + const trpcUtilities = api.useUtils(); + const projectsQuery = api.project.list.useQuery(); + const projects = projectsQuery.data ?? []; + + api.project.getOrCreateGlobal.useQuery(); + + const updateMemory = api.memory.update.useMutation({ + onSuccess: async () => { + await trpcUtilities.memory.invalidate(); + setEditingMemory(null); + }, + }); + const handleSaveEdit = (data: { content: string; projectId: string }) => { + if (editingMemory) { + updateMemory.mutate({ + id: editingMemory.id, + content: data.content, + projectId: data.projectId, + }); + } + }; + return ( - <main className="flex min-h-screen flex-col items-center bg-[#070707]"> - <div className="container flex max-w-2xl flex-col items-center gap-6 px-4 py-12"> - <div className="flex w-full items-center justify-between"> - <h1 className="text-2xl tracking-tight text-white"> - <span className="text-[#999999]">></span> memory dashboard - </h1> - <div className="flex gap-2"> - <Link - className="border border-[#2a2a2a] bg-[#0f0f0f] px-4 py-1 text-[#999999] transition hover:border-[#666666] hover:text-white" - href="/dashboard/settings" - > - settings - </Link> - <Link - className="border border-[#2a2a2a] bg-[#0f0f0f] px-4 py-1 text-[#999999] transition hover:border-[#666666] hover:text-white" - href="/" - > - home - </Link> + <> + <div className="flex w-full items-center justify-between"> + <h1 className="text-2xl tracking-tight text-white"> + <span className="text-[#999999]">></span> memory dashboard + </h1> + + <div className="flex gap-2"> + <Link + className="border border-[#2a2a2a] bg-[#0f0f0f] px-4 py-1 text-[#999999] transition hover:border-[#666666] hover:text-white" + href="/dashboard/settings" + > + settings + </Link> + + <Link + className="border border-[#2a2a2a] bg-[#0f0f0f] px-4 py-1 text-[#999999] transition hover:border-[#666666] hover:text-white" + href="/" + > + home + </Link> + </div> + </div> + + <section className="w-full"> + <div className="mb-3 flex items-center gap-3"> + <div className="flex-1"> + <SearchBar onChange={setSearchQuery} value={searchQuery} /> </div> + + <ProjectFilter + onProjectChange={setSelectedProjectId} + projects={projects} + selectedProjectId={selectedProjectId} + /> </div> - <section className="w-full"> - <h2 className="mb-3 text-sm text-[#666666]">your memories</h2> - <MemoryList /> - </section> + <MemoryList + onEdit={setEditingMemory} + searchQuery={searchQuery} + selectedProjectId={selectedProjectId} + /> + </section> - <section className="w-full"> - <h2 className="mb-3 text-sm text-[#666666]">create new memory</h2> - <CreateMemoryForm /> - </section> + <section className="w-full"> + <h2 className="mb-3 text-sm text-[#666666]">create new memory</h2> + <CreateMemoryForm projects={projects} /> + </section> + + {editingMemory && ( + <MemoryEditModal + isSaving={updateMemory.isPending} + memory={editingMemory} + onClose={() => setEditingMemory(null)} + onSave={handleSaveEdit} + projects={projects} + /> + )} + </> + ); +} + +export function DashboardContent() { + return ( + <main className="flex min-h-screen flex-col items-center bg-[#070707]"> + <div className="container flex max-w-2xl flex-col items-center gap-6 px-4 py-12"> + <Suspense + fallback={ + <div className="w-full text-center"> + <p className="text-[#666666]">loading dashboard ...</p> + </div> + } + > + <DashboardInner /> + </Suspense> </div> </main> ); diff --git a/packages/web/src/env.js b/packages/web/src/env.js index 5779b5f..8ee6297 100644 --- a/packages/web/src/env.js +++ b/packages/web/src/env.js @@ -11,6 +11,7 @@ export const env = createEnv({ NODE_ENV: z .enum(["development", "test", "production"]) .default("development"), + OPENAI_API_KEY: z.string().optional(), }, /** @@ -30,6 +31,7 @@ export const env = createEnv({ runtimeEnv: { DATABASE_URL: process.env.DATABASE_URL, NODE_ENV: process.env.NODE_ENV, + OPENAI_API_KEY: process.env.OPENAI_API_KEY, NEXT_PUBLIC_SUPABASE_URL: process.env.NEXT_PUBLIC_SUPABASE_URL, NEXT_PUBLIC_SUPABASE_ANON_KEY: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY, }, diff --git a/packages/web/src/hooks/use-debounce.ts b/packages/web/src/hooks/use-debounce.ts new file mode 100644 index 0000000..cf5221f --- /dev/null +++ b/packages/web/src/hooks/use-debounce.ts @@ -0,0 +1,19 @@ +"use client"; + +import { useEffect, useState } from "react"; + +export function useDebounce<T>(value: T, delay: number): T { + const [debouncedValue, setDebouncedValue] = useState<T>(value); + + useEffect(() => { + const timeoutId = setTimeout(() => { + setDebouncedValue(value); + }, delay); + + return () => { + clearTimeout(timeoutId); + }; + }, [value, delay]); + + return debouncedValue; +} diff --git a/packages/web/src/server/api/root.ts b/packages/web/src/server/api/root.ts index 919ba21..c382d36 100644 --- a/packages/web/src/server/api/root.ts +++ b/packages/web/src/server/api/root.ts @@ -1,12 +1,14 @@ import { apiKeyRouter } from "~/server/api/routers/api-key"; import { memoryRouter } from "~/server/api/routers/memory"; import { postRouter } from "~/server/api/routers/post"; +import { projectRouter } from "~/server/api/routers/project"; import { createCallerFactory, createTRPCRouter } from "~/server/api/trpc"; export const appRouter = createTRPCRouter({ apiKey: apiKeyRouter, memory: memoryRouter, post: postRouter, + project: projectRouter, }); // export type definition of API diff --git a/packages/web/src/server/api/routers/memory.ts b/packages/web/src/server/api/routers/memory.ts index e6d206b..eebe0f8 100644 --- a/packages/web/src/server/api/routers/memory.ts +++ b/packages/web/src/server/api/routers/memory.ts @@ -1,9 +1,21 @@ -import { SupabaseProjectStore, SupabaseStore } from "@imemio/sdk"; +import { EmbeddingService, SupabaseProjectStore, SupabaseStore } from "@imemio/sdk"; import type { SupabaseClient } from "@supabase/supabase-js"; +import { TRPCError } from "@trpc/server"; import { z } from "zod"; +import { env } from "~/env"; import { createClient } from "~/lib/supabase/server"; import { createTRPCRouter, protectedProcedure } from "~/server/api/trpc"; +function getEmbeddingService(): EmbeddingService | null { + const openaiApiKey = env.OPENAI_API_KEY; + + if (!openaiApiKey) { + return null; + } + + return new EmbeddingService({ apiKey: openaiApiKey }); +} + const defaultProjectName = "default"; async function getOrCreateDefaultProject( @@ -47,14 +59,53 @@ export const memoryRouter = createTRPCRouter({ return memories; }), - create: protectedProcedure - .input(z.object({ content: z.string().min(1) })) - .mutation(async ({ context, input }) => { + listWithProjects: protectedProcedure + .input( + z.object({ + projectId: z.string().optional(), + sortOrder: z.enum(["asc", "desc"]).optional().default("desc"), + }), + ) + .query(async ({ ctx: context, input }) => { const supabaseClient = await createClient(); - const projectId = await getOrCreateDefaultProject( + const memoryStore = new SupabaseStore(supabaseClient, context.user.id); + const projectStore = new SupabaseProjectStore( supabaseClient, context.user.id, ); + const memories = await memoryStore.list( + input.projectId ? { projectId: input.projectId } : undefined, + ); + const projectsResult = await projectStore.list(); + const projects = projectsResult.success ? projectsResult.value : []; + const projectMap = new Map(projects.map((project) => [project.id, project])); + const memoriesWithProjects = memories.map((memory) => ({ + ...memory, + project: projectMap.get(memory.projectId) ?? null, + })); + const sortedMemories = memoriesWithProjects.sort((memoryA, memoryB) => { + const comparison = + new Date(memoryA.createdAt).getTime() - + new Date(memoryB.createdAt).getTime(); + + return input.sortOrder === "desc" ? -comparison : comparison; + }); + + return { memories: sortedMemories, projects }; + }), + + create: protectedProcedure + .input( + z.object({ + content: z.string().min(1), + projectId: z.string().optional(), + }), + ) + .mutation(async ({ ctx: context, input }) => { + const supabaseClient = await createClient(); + const projectId = + input.projectId ?? + (await getOrCreateDefaultProject(supabaseClient, context.user.id)); const memoryStore = new SupabaseStore(supabaseClient, context.user.id); const memory = await memoryStore.create({ content: input.content, @@ -64,17 +115,94 @@ export const memoryRouter = createTRPCRouter({ return memory; }), + update: protectedProcedure + .input( + z.object({ + id: z.string(), + content: z.string().min(1).optional(), + projectId: z.string().optional(), + }), + ) + .mutation(async ({ ctx: context, input }) => { + const supabaseClient = await createClient(); + const memoryStore = new SupabaseStore(supabaseClient, context.user.id); + const updateData: { content?: string; projectId?: string } = {}; + + if (input.content !== undefined) { + updateData.content = input.content; + } + + if (input.projectId !== undefined) { + updateData.projectId = input.projectId; + } + + const updateResult = await memoryStore.update(input.id, updateData); + + if (!updateResult.success) { + throw new TRPCError({ + code: "NOT_FOUND", + message: `Memory not found: ${input.id}`, + }); + } + + return updateResult.value; + }), + delete: protectedProcedure .input(z.object({ id: z.string() })) - .mutation(async ({ context, input }) => { + .mutation(async ({ ctx: context, input }) => { const supabaseClient = await createClient(); const memoryStore = new SupabaseStore(supabaseClient, context.user.id); const deleteResult = await memoryStore.delete(input.id); if (!deleteResult.success) { - throw new Error(`Memory not found: ${input.id}`); + throw new TRPCError({ + code: "NOT_FOUND", + message: `Memory not found: ${input.id}`, + }); } return { success: true }; }), + + search: protectedProcedure + .input( + z.object({ + query: z.string().min(1), + projectId: z.string().optional(), + limit: z.number().optional().default(20), + }), + ) + .query(async ({ ctx: context, input }) => { + const embeddingService = getEmbeddingService(); + + if (!embeddingService) { + throw new TRPCError({ + code: "PRECONDITION_FAILED", + message: + "Search is not available. Configure OPENAI_API_KEY to enable semantic search.", + }); + } + + const supabaseClient = await createClient(); + const memoryStore = new SupabaseStore(supabaseClient, context.user.id); + const projectStore = new SupabaseProjectStore( + supabaseClient, + context.user.id, + ); + const queryEmbedding = await embeddingService.generate(input.query); + const searchResults = await memoryStore.search(queryEmbedding, { + projectId: input.projectId, + limit: input.limit, + }); + const projectsResult = await projectStore.list(); + const projects = projectsResult.success ? projectsResult.value : []; + const projectMap = new Map(projects.map((project) => [project.id, project])); + const resultsWithProjects = searchResults.map((result) => ({ + ...result, + project: projectMap.get(result.projectId) ?? null, + })); + + return { results: resultsWithProjects, projects }; + }), }); diff --git a/packages/web/src/server/api/routers/project.ts b/packages/web/src/server/api/routers/project.ts new file mode 100644 index 0000000..c76070d --- /dev/null +++ b/packages/web/src/server/api/routers/project.ts @@ -0,0 +1,79 @@ +import { SupabaseProjectStore } from "@imemio/sdk"; +import { z } from "zod"; +import { createClient } from "~/lib/supabase/server"; +import { createTRPCRouter, protectedProcedure } from "~/server/api/trpc"; + +const globalProjectName = "global"; +export const projectRouter = createTRPCRouter({ + list: protectedProcedure.query(async ({ ctx: context }) => { + const supabaseClient = await createClient(); + const projectStore = new SupabaseProjectStore( + supabaseClient, + context.user.id, + ); + const result = await projectStore.list(); + + if (!result.success) { + return []; + } + + return result.value; + }), + + create: protectedProcedure + .input( + z.object({ + name: z.string().min(1), + description: z.string().optional(), + isGlobal: z.boolean().optional().default(false), + }), + ) + .mutation(async ({ ctx: context, input }) => { + const supabaseClient = await createClient(); + const projectStore = new SupabaseProjectStore( + supabaseClient, + context.user.id, + ); + const result = await projectStore.create({ + name: input.name, + description: input.description, + isGlobal: input.isGlobal, + }); + + if (!result.success) { + throw new Error("Failed to create project"); + } + + return result.value; + }), + + getOrCreateGlobal: protectedProcedure.query(async ({ ctx: context }) => { + const supabaseClient = await createClient(); + const projectStore = new SupabaseProjectStore( + supabaseClient, + context.user.id, + ); + const listResult = await projectStore.list(); + + if (listResult.success) { + const existingGlobal = listResult.value.find( + (project) => project.isGlobal, + ); + + if (existingGlobal) { + return existingGlobal; + } + } + + const createResult = await projectStore.create({ + name: globalProjectName, + isGlobal: true, + }); + + if (!createResult.success) { + throw new Error("Failed to create global project"); + } + + return createResult.value; + }), +}); |