diff --git a/echo/.devcontainer/devcontainer.json b/echo/.devcontainer/devcontainer.json index fecdb7a7..a9652e9f 100644 --- a/echo/.devcontainer/devcontainer.json +++ b/echo/.devcontainer/devcontainer.json @@ -15,18 +15,17 @@ "customizations": { "vscode": { "extensions": [ - "esbenp.prettier-vscode", "YoavBls.pretty-ts-errors", "bradlc.vscode-tailwindcss", "ms-python.python", "charliermarsh.ruff", "matangover.mypy", "ms-azuretools.vscode-docker", - "dbaeumer.vscode-eslint", "mhutchie.git-graph", "cweijan.vscode-postgresql-client2", "github.vscode-pull-request-github", "nguyenngoclong.terminal-keeper", + "biomejs.biome", // for cursor specifically lol "anysphere.cursorpyright" ] diff --git a/echo/.vscode/sessions.json b/echo/.vscode/sessions.json index 9d3859fc..d204281b 100644 --- a/echo/.vscode/sessions.json +++ b/echo/.vscode/sessions.json @@ -1,72 +1,72 @@ { - "$schema": "https://cdn.statically.io/gh/nguyenngoclongdev/cdn/main/schema/v11/terminal-keeper.json", - "theme": "tribe", - "active": "default", - "keepExistingTerminals": false, - "sessions": { - "default": [ - { - "autoExecuteCommands": true, - "name": "server", - "icon": "server", - "commands": [ - "cd server", - "source .venv/bin/activate", - "./run.sh" + "$schema": "https://cdn.statically.io/gh/nguyenngoclongdev/cdn/main/schema/v11/terminal-keeper.json", + "theme": "tribe", + "active": "default", + "keepExistingTerminals": false, + "sessions": { + "default": [ + { + "autoExecuteCommands": true, + "name": "server", + "icon": "server", + "commands": [ + "cd server", + "source .venv/bin/activate", + "./run.sh" + ] + }, + [ + { + "autoExecuteCommands": true, + "name": "workers", + "icon": "gear", + "commands": [ + "cd server", + "source .venv/bin/activate", + "./run-worker.sh" + ] + }, + { + "autoExecuteCommands": true, + "name": "workers-cpu", + "icon": "gear", + "commands": [ + "cd server", + "source .venv/bin/activate", + "./run-worker-cpu.sh" + ] + }, + { + "autoExecuteCommands": true, + "name": "scheduler", + "icon": "clock", + "commands": [ + "cd server", + "source .venv/bin/activate", + "./run-scheduler.sh" + ] + } + ], + [ + { + "autoExecuteCommands": true, + "name": "admin-dashboard", + "icon": "browser", + "commands": [ + "cd frontend", + "pnpm run dev" + ] + }, + { + "autoExecuteCommands": true, + "name": "participant-portal", + "icon": "browser", + "commands": [ + "cd frontend", + "pnpm run participant:dev" + ] + } + ] ] - }, - [ - { - "autoExecuteCommands": true, - "name": "workers", - "icon": "gear", - "commands": [ - "cd server", - "source .venv/bin/activate", - "./run-worker.sh" - ] - }, - { - "autoExecuteCommands": true, - "name": "workers-cpu", - "icon": "gear", - "commands": [ - "cd server", - "source .venv/bin/activate", - "./run-worker-cpu.sh" - ] - }, - { - "autoExecuteCommands": true, - "name": "scheduler", - "icon": "clock", - "commands": [ - "cd server", - "source .venv/bin/activate", - "./run-scheduler.sh" - ] - } - ], - [ - { - "autoExecuteCommands": true, - "name": "admin-dashboard", - "icon": "browser", - "commands": [ - "cd frontend", - "pnpm run dev" - ] - }, - { - "autoExecuteCommands": true, - "name": "participant-portal", - "icon": "browser", - "commands": [ - "cd frontend", - "pnpm run participant:dev" - ] - } - ] - ] - } + } } \ No newline at end of file diff --git a/echo/.vscode/settings.json b/echo/.vscode/settings.json index 58238cb0..91779e2f 100644 --- a/echo/.vscode/settings.json +++ b/echo/.vscode/settings.json @@ -22,13 +22,20 @@ "python.testing.autoTestDiscoverOnSaveEnabled": true, // ts "[typescript]": { - "editor.defaultFormatter": "esbenp.prettier-vscode", + "editor.defaultFormatter": "biomejs.biome", + "editor.formatOnSave": true, "editor.useTabStops": true }, "[typescriptreact]": { - "editor.defaultFormatter": "esbenp.prettier-vscode", - "editor.useTabStops": true + "editor.defaultFormatter": "biomejs.biome", + "editor.useTabStops": true, + "editor.formatOnSave": true }, - "eslint.enable": true, - "eslint.lintTask.options": "-c /workspaces/echo/frontend/.eslintrc" + "biome.enabled": true, + "biome.lsp.bin": "frontend/node_modules/.bin/biome", + "biome.configurationPath": "frontend/biome.json", + "editor.codeActionsOnSave": { + "source.fixAll.biome": "always", + "source.action.organizeImports.biome": "always" + } } diff --git a/echo/check-later.md b/echo/check-later.md new file mode 100644 index 00000000..b6f72584 --- /dev/null +++ b/echo/check-later.md @@ -0,0 +1,8 @@ +- prod worker cpu using watch +- in the regular worker removed watch +- 8001? let's use devcontainers +- many local imports python +- pr size +- dockerfile update + +- can probably run ruff / mypy in parallel \ No newline at end of file diff --git a/echo/frontend/biome.json b/echo/frontend/biome.json new file mode 100644 index 00000000..30da9ce0 --- /dev/null +++ b/echo/frontend/biome.json @@ -0,0 +1,37 @@ +{ + "$schema": "https://biomejs.dev/schemas/2.2.4/schema.json", + "vcs": { + "enabled": false, + "clientKind": "git", + "useIgnoreFile": false + }, + "files": { + "ignoreUnknown": false + }, + "formatter": { + "enabled": true, + "indentStyle": "tab" + }, + "linter": { + "enabled": true, + "rules": { + "recommended": true, + "correctness": { + "useUniqueElementIds": "off" + } + } + }, + "javascript": { + "formatter": { + "quoteStyle": "double" + } + }, + "assist": { + "enabled": true, + "actions": { + "source": { + "organizeImports": "on" + } + } + } +} diff --git a/echo/frontend/package.json b/echo/frontend/package.json index b8a36264..84375eb6 100644 --- a/echo/frontend/package.json +++ b/echo/frontend/package.json @@ -85,6 +85,7 @@ "zod": "^3.24.2" }, "devDependencies": { + "@biomejs/biome": "^2.2.4", "@lingui/babel-plugin-lingui-macro": "^5.3.0", "@lingui/cli": "^5.3.0", "@lingui/swc-plugin": "^5.5.1", diff --git a/echo/frontend/pnpm-lock.yaml b/echo/frontend/pnpm-lock.yaml index 32c827d5..40478b9e 100644 --- a/echo/frontend/pnpm-lock.yaml +++ b/echo/frontend/pnpm-lock.yaml @@ -218,6 +218,9 @@ importers: specifier: ^3.24.2 version: 3.24.2 devDependencies: + '@biomejs/biome': + specifier: ^2.2.4 + version: 2.2.4 '@lingui/babel-plugin-lingui-macro': specifier: ^5.3.0 version: 5.3.0(babel-plugin-macros@3.1.0)(typescript@5.8.2) @@ -529,6 +532,59 @@ packages: resolution: {integrity: sha512-x0LvFTekgSX+83TI28Y9wYPUfzrnl2aT5+5QLnO6v7mSJYtEEevuDRN0F0uSHRk1G1IWZC43o00Y0xDDrpBGPQ==} engines: {node: '>=6.9.0'} + '@biomejs/biome@2.2.4': + resolution: {integrity: sha512-TBHU5bUy/Ok6m8c0y3pZiuO/BZoY/OcGxoLlrfQof5s8ISVwbVBdFINPQZyFfKwil8XibYWb7JMwnT8wT4WVPg==} + engines: {node: '>=14.21.3'} + hasBin: true + + '@biomejs/cli-darwin-arm64@2.2.4': + resolution: {integrity: sha512-RJe2uiyaloN4hne4d2+qVj3d3gFJFbmrr5PYtkkjei1O9c+BjGXgpUPVbi8Pl8syumhzJjFsSIYkcLt2VlVLMA==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [darwin] + + '@biomejs/cli-darwin-x64@2.2.4': + resolution: {integrity: sha512-cFsdB4ePanVWfTnPVaUX+yr8qV8ifxjBKMkZwN7gKb20qXPxd/PmwqUH8mY5wnM9+U0QwM76CxFyBRJhC9tQwg==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [darwin] + + '@biomejs/cli-linux-arm64-musl@2.2.4': + resolution: {integrity: sha512-7TNPkMQEWfjvJDaZRSkDCPT/2r5ESFPKx+TEev+I2BXDGIjfCZk2+b88FOhnJNHtksbOZv8ZWnxrA5gyTYhSsQ==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [linux] + + '@biomejs/cli-linux-arm64@2.2.4': + resolution: {integrity: sha512-M/Iz48p4NAzMXOuH+tsn5BvG/Jb07KOMTdSVwJpicmhN309BeEyRyQX+n1XDF0JVSlu28+hiTQ2L4rZPvu7nMw==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [linux] + + '@biomejs/cli-linux-x64-musl@2.2.4': + resolution: {integrity: sha512-m41nFDS0ksXK2gwXL6W6yZTYPMH0LughqbsxInSKetoH6morVj43szqKx79Iudkp8WRT5SxSh7qVb8KCUiewGg==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [linux] + + '@biomejs/cli-linux-x64@2.2.4': + resolution: {integrity: sha512-orr3nnf2Dpb2ssl6aihQtvcKtLySLta4E2UcXdp7+RTa7mfJjBgIsbS0B9GC8gVu0hjOu021aU8b3/I1tn+pVQ==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [linux] + + '@biomejs/cli-win32-arm64@2.2.4': + resolution: {integrity: sha512-NXnfTeKHDFUWfxAefa57DiGmu9VyKi0cDqFpdI+1hJWQjGJhJutHPX0b5m+eXvTKOaf+brU+P0JrQAZMb5yYaQ==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [win32] + + '@biomejs/cli-win32-x64@2.2.4': + resolution: {integrity: sha512-3Y4V4zVRarVh/B/eSHczR4LYoSVyv3Dfuvm3cWs5w/HScccS0+Wt/lHOcDTRYeHjQmMYVC3rIRWqyN2EI52+zg==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [win32] + '@codemirror/autocomplete@6.18.6': resolution: {integrity: sha512-PHHBXFomUs5DF+9tCOM/UoW6XQ4R44lLNNhRaW9PKPTU0D7lIjRg3ElxaJnTwsl/oHiR93WSXDBrekhoUGCPtg==} @@ -2630,11 +2686,8 @@ packages: resolution: {integrity: sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==} engines: {node: '>=10'} - caniuse-lite@1.0.30001707: - resolution: {integrity: sha512-3qtRjw/HQSMlDWf+X79N206fepf4SOOU6SQLMaq/0KkZLmSjPxAkBOQQ+FxbHKfHmYLZFfdWsO3KA90ceHPSnw==} - - caniuse-lite@1.0.30001727: - resolution: {integrity: sha512-pB68nIHmbN6L/4C6MH1DokyR3bYqFwjaSs/sWDHGj4CTcFtQUQMuJftVwWkXq7mNWOybD3KhUv3oWHoGxgP14Q==} + caniuse-lite@1.0.30001746: + resolution: {integrity: sha512-eA7Ys/DGw+pnkWWSE/id29f2IcPHVoE8wxtvE5JdvD2V28VTDPy1yEeo11Guz0sJ4ZeGRcm3uaTcAqK1LXaphA==} canvas@3.1.0: resolution: {integrity: sha512-tTj3CqqukVJ9NgSahykNwtGda7V33VLObwrHfzT0vqJXu7J4d4C/7kQQW3fOEGDfZZoILPut5H00gOjyttPGyg==} @@ -5281,6 +5334,41 @@ snapshots: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.27.1 + '@biomejs/biome@2.2.4': + optionalDependencies: + '@biomejs/cli-darwin-arm64': 2.2.4 + '@biomejs/cli-darwin-x64': 2.2.4 + '@biomejs/cli-linux-arm64': 2.2.4 + '@biomejs/cli-linux-arm64-musl': 2.2.4 + '@biomejs/cli-linux-x64': 2.2.4 + '@biomejs/cli-linux-x64-musl': 2.2.4 + '@biomejs/cli-win32-arm64': 2.2.4 + '@biomejs/cli-win32-x64': 2.2.4 + + '@biomejs/cli-darwin-arm64@2.2.4': + optional: true + + '@biomejs/cli-darwin-x64@2.2.4': + optional: true + + '@biomejs/cli-linux-arm64-musl@2.2.4': + optional: true + + '@biomejs/cli-linux-arm64@2.2.4': + optional: true + + '@biomejs/cli-linux-x64-musl@2.2.4': + optional: true + + '@biomejs/cli-linux-x64@2.2.4': + optional: true + + '@biomejs/cli-win32-arm64@2.2.4': + optional: true + + '@biomejs/cli-win32-x64@2.2.4': + optional: true + '@codemirror/autocomplete@6.18.6': dependencies: '@codemirror/language': 6.11.0 @@ -7541,7 +7629,7 @@ snapshots: autoprefixer@10.4.21(postcss@8.5.3): dependencies: browserslist: 4.24.4 - caniuse-lite: 1.0.30001707 + caniuse-lite: 1.0.30001746 fraction.js: 4.3.7 normalize-range: 0.1.2 picocolors: 1.1.1 @@ -7609,14 +7697,14 @@ snapshots: browserslist@4.24.4: dependencies: - caniuse-lite: 1.0.30001707 + caniuse-lite: 1.0.30001746 electron-to-chromium: 1.5.128 node-releases: 2.0.19 update-browserslist-db: 1.1.3(browserslist@4.24.4) browserslist@4.25.1: dependencies: - caniuse-lite: 1.0.30001727 + caniuse-lite: 1.0.30001746 electron-to-chromium: 1.5.190 node-releases: 2.0.19 update-browserslist-db: 1.1.3(browserslist@4.25.1) @@ -7642,9 +7730,7 @@ snapshots: camelcase@6.3.0: {} - caniuse-lite@1.0.30001707: {} - - caniuse-lite@1.0.30001727: {} + caniuse-lite@1.0.30001746: {} canvas@3.1.0: dependencies: diff --git a/echo/frontend/src/components/conversation/ConversationAccordion.tsx b/echo/frontend/src/components/conversation/ConversationAccordion.tsx index facbf820..835c5efd 100644 --- a/echo/frontend/src/components/conversation/ConversationAccordion.tsx +++ b/echo/frontend/src/components/conversation/ConversationAccordion.tsx @@ -1,426 +1,421 @@ +import { useAutoAnimate } from "@formkit/auto-animate/react"; import { t } from "@lingui/core/macro"; import { Trans } from "@lingui/react/macro"; -import { Icons } from "@/icons"; -import { useProjectById } from "@/components/project/hooks"; -import { - useAddChatContextMutation, - useDeleteChatContextMutation, - useMoveConversationMutation, - useConversationsByProjectId, - useInfiniteConversationsByProjectId, - useConversationsCountByProjectId, -} from "./hooks"; -import { useInfiniteProjects } from "@/components/project/hooks"; -import { useProjectChatContext } from "@/components/chat/hooks"; -import { useAutoAnimate } from "@formkit/auto-animate/react"; import { - Accordion, - ActionIcon, - Anchor, - Checkbox, - Group, - Loader, - LoadingOverlay, - Menu, - Pill, - Radio, - Skeleton, - Stack, - Text, - TextInput, - Title, - Tooltip, - Modal, - Button, - ScrollArea, - Center, - Badge, - Box, - Divider, + Accordion, + ActionIcon, + Anchor, + Badge, + Box, + Button, + Center, + Checkbox, + Divider, + Group, + Loader, + LoadingOverlay, + Menu, + Modal, + Pill, + Radio, + ScrollArea, + Skeleton, + Stack, + Text, + TextInput, + Title, + Tooltip, } from "@mantine/core"; import { - useState, - useRef, - useEffect, - useReducer, - useMemo, - useCallback, - Suspense, - RefObject, -} from "react"; -import { useLocation, useParams } from "react-router"; -import { UploadConversationDropzone } from "../dropzone/UploadConversationDropzone"; -import { useDebouncedValue, useMediaQuery } from "@mantine/hooks"; + useDebouncedValue, + useDisclosure, + useIntersection, + useMediaQuery, + useSessionStorage, +} from "@mantine/hooks"; import { - IconFilter, - IconSearch, - IconX, - IconArrowsExchange, - IconPinnedFilled, - IconPinned, - IconQrcode, - IconFileUpload, - IconSort09, - IconArrowsUpDown, - IconDotsVertical, - IconInfoCircle, - IconTags, - IconChevronDown, - IconChevronUp, + IconArrowsExchange, + IconArrowsUpDown, + IconChevronDown, + IconChevronUp, + IconSearch, + IconTags, + IconX, } from "@tabler/icons-react"; -import { formatDuration, formatRelative, intervalToDuration } from "date-fns"; -import { NavigationButton } from "../common/NavigationButton"; -import { cn } from "@/lib/utils"; +import { formatRelative, intervalToDuration } from "date-fns"; +import { + type RefObject, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; +import { Controller, useForm } from "react-hook-form"; +import { useInView } from "react-intersection-observer"; +import { useLocation, useParams } from "react-router"; +import { useProjectChatContext } from "@/components/chat/hooks"; import { I18nLink } from "@/components/common/i18nLink"; -import { useSessionStorage } from "@mantine/hooks"; -import { useDisclosure } from "@mantine/hooks"; - -import { useIntersection } from "@mantine/hooks"; -import { useForm, Controller } from "react-hook-form"; import { FormLabel } from "@/components/form/FormLabel"; -import { AutoSelectConversations } from "./AutoSelectConversations"; +import { + useInfiniteProjects, + useProjectById, +} from "@/components/project/hooks"; import { ENABLE_CHAT_AUTO_SELECT } from "@/config"; -import { InformationTooltip } from "../common/InformationTooltip"; +import { cn } from "@/lib/utils"; import { BaseSkeleton } from "../common/BaseSkeleton"; -import { useInView } from "react-intersection-observer"; +import { NavigationButton } from "../common/NavigationButton"; +import { UploadConversationDropzone } from "../dropzone/UploadConversationDropzone"; +import { AutoSelectConversations } from "./AutoSelectConversations"; +import { + useAddChatContextMutation, + useConversationsCountByProjectId, + useDeleteChatContextMutation, + useInfiniteConversationsByProjectId, + useMoveConversationMutation, +} from "./hooks"; type SortOption = { - label: string; - value: - | "-created_at" - | "created_at" - | "-participant_name" - | "participant_name" - | "-duration" - | "duration"; + label: string; + value: + | "-created_at" + | "created_at" + | "-participant_name" + | "participant_name" + | "-duration" + | "duration"; }; const ConversationAccordionLabelChatSelection = ({ - conversation, + conversation, }: { - conversation: Conversation; + conversation: Conversation; }) => { - const { chatId } = useParams(); - const projectChatContextQuery = useProjectChatContext(chatId ?? ""); - const addChatContextMutation = useAddChatContextMutation(); - const deleteChatContextMutation = useDeleteChatContextMutation(); - - if ( - projectChatContextQuery.isLoading || - addChatContextMutation.isPending || - deleteChatContextMutation.isPending - ) { - return ( - - - - ); - } - - const isSelected = !!projectChatContextQuery.data?.conversations?.find( - (c) => c.conversation_id === conversation.id, - ); - const isLocked = !!projectChatContextQuery.data?.conversations?.find( - (c) => c.conversation_id === conversation.id && c.locked, - ); - - const isAutoSelectEnabled = - projectChatContextQuery.data?.auto_select_bool ?? false; - - // Check if conversation has any content - const hasContent = conversation.chunks?.some( - (chunk) => chunk.transcript && chunk.transcript.trim().length > 0, - ); - - const handleSelectChat = () => { - if (!isSelected) { - // Don't allow adding empty conversations to chat context - if (!hasContent) { - return; - } - addChatContextMutation.mutate({ - chatId: chatId ?? "", - conversationId: conversation.id, - }); - } else { - deleteChatContextMutation.mutate({ - chatId: chatId ?? "", - conversationId: conversation.id, - }); - } - }; - - const tooltipLabel = isLocked - ? t`Already added to this chat` - : !hasContent - ? t`Cannot add empty conversation` - : isSelected - ? t`Remove from this chat` - : t`Add to this chat`; - - return ( - - - - ); + const { chatId } = useParams(); + const projectChatContextQuery = useProjectChatContext(chatId ?? ""); + const addChatContextMutation = useAddChatContextMutation(); + const deleteChatContextMutation = useDeleteChatContextMutation(); + + if ( + projectChatContextQuery.isLoading || + addChatContextMutation.isPending || + deleteChatContextMutation.isPending + ) { + return ( + + + + ); + } + + const isSelected = !!projectChatContextQuery.data?.conversations?.find( + (c) => c.conversation_id === conversation.id, + ); + const isLocked = !!projectChatContextQuery.data?.conversations?.find( + (c) => c.conversation_id === conversation.id && c.locked, + ); + + const isAutoSelectEnabled = + projectChatContextQuery.data?.auto_select_bool ?? false; + + // Check if conversation has any content + const hasContent = conversation.chunks?.some( + (chunk) => chunk.transcript && chunk.transcript.trim().length > 0, + ); + + const handleSelectChat = () => { + if (!isSelected) { + // Don't allow adding empty conversations to chat context + if (!hasContent) { + return; + } + addChatContextMutation.mutate({ + chatId: chatId ?? "", + conversationId: conversation.id, + }); + } else { + deleteChatContextMutation.mutate({ + chatId: chatId ?? "", + conversationId: conversation.id, + }); + } + }; + + const tooltipLabel = isLocked + ? t`Already added to this chat` + : !hasContent + ? t`Cannot add empty conversation` + : isSelected + ? t`Remove from this chat` + : t`Add to this chat`; + + return ( + + + + ); }; type MoveConversationFormData = { - search: string; - targetProjectId: string; + search: string; + targetProjectId: string; }; export const MoveConversationButton = ({ - conversation, + conversation, }: { - conversation: Conversation; + conversation: Conversation; }) => { - const [opened, { open, close }] = useDisclosure(false); - const lastItemRef = useRef(null); - const { ref, entry } = useIntersection({ - root: lastItemRef.current, - threshold: 1, - }); - - const form = useForm({ - defaultValues: { - search: "", - targetProjectId: "", - }, - }); - - const { watch } = form; - const search = watch("search"); - - const projectsQuery = useInfiniteProjects({ - query: { - sort: ["-updated_at"], - filter: { - // @ts-expect-error not tyed - _and: [{ id: { _neq: conversation.project_id } }], - }, - search: search, - }, - enabled: opened, - }); - - const moveConversationMutation = useMoveConversationMutation(); - - // Reset form when modal closes - useEffect(() => { - if (!opened) { - form.reset(); - } - }, [opened]); - - const handleMove = (data: MoveConversationFormData) => { - if (!data.targetProjectId) return; - moveConversationMutation.mutate( - { - conversationId: conversation.id, - targetProjectId: data.targetProjectId, - }, - { - onSuccess: () => { - close(); - }, - }, - ); - }; - - useEffect(() => { - if (entry?.isIntersecting && projectsQuery.hasNextPage) { - projectsQuery.fetchNextPage(); - } - }, [entry?.isIntersecting]); - - const allProjects = - projectsQuery.data?.pages.flatMap((page) => page.projects) ?? []; - - return ( - <> - - - -
- - ( - - } - placeholder={t`Search projects...`} - leftSection={} - {...field} - /> - )} - /> - - - {projectsQuery.isLoading ? ( -
- -
- ) : ( - ( - - } - {...field} - > - - {allProjects.map((project, index) => ( -
- -
- ))} - {projectsQuery.isFetchingNextPage && ( -
- -
- )} -
-
- )} - /> - )} -
- - - - - -
-
-
- - ); + const [opened, { open, close }] = useDisclosure(false); + const lastItemRef = useRef(null); + const { ref, entry } = useIntersection({ + root: lastItemRef.current, + threshold: 1, + }); + + const form = useForm({ + defaultValues: { + search: "", + targetProjectId: "", + }, + }); + + const { watch } = form; + const search = watch("search"); + + const projectsQuery = useInfiniteProjects({ + query: { + sort: ["-updated_at"], + filter: { + // @ts-expect-error not tyed + _and: [{ id: { _neq: conversation.project_id } }], + }, + search: search, + }, + enabled: opened, + }); + + const moveConversationMutation = useMoveConversationMutation(); + + // Reset form when modal closes + useEffect(() => { + if (!opened) { + form.reset(); + } + }, [opened, form.reset]); + + const handleMove = (data: MoveConversationFormData) => { + if (!data.targetProjectId) return; + moveConversationMutation.mutate( + { + conversationId: conversation.id, + targetProjectId: data.targetProjectId, + }, + { + onSuccess: () => { + close(); + }, + }, + ); + }; + + useEffect(() => { + if (entry?.isIntersecting && projectsQuery.hasNextPage) { + projectsQuery.fetchNextPage(); + } + }, [ + entry?.isIntersecting, + projectsQuery.fetchNextPage, + projectsQuery.hasNextPage, + ]); + + const allProjects = + projectsQuery.data?.pages.flatMap((page) => page.projects) ?? []; + + return ( + <> + + + +
+ + ( + + } + placeholder={t`Search projects...`} + leftSection={} + {...field} + /> + )} + /> + + + {projectsQuery.isLoading ? ( +
+ +
+ ) : ( + ( + + } + {...field} + > + + {allProjects.map((project, index) => ( +
+ +
+ ))} + {projectsQuery.isFetchingNextPage && ( +
+ +
+ )} +
+
+ )} + /> + )} +
+ + + + + +
+
+
+ + ); }; export const ConversationStatusIndicators = ({ - conversation, - showDuration = false, + conversation, + showDuration = false, }: { - conversation: Conversation; - showDuration?: boolean; + conversation: Conversation; + showDuration?: boolean; }) => { - const { projectId } = useParams(); - - const { data: project } = useProjectById({ - projectId: projectId ?? "", - query: { - fields: ["is_enhanced_audio_processing_enabled"], - }, - }); - - const hasContent = useMemo( - () => conversation.chunks?.length && conversation.chunks.length > 0, - [conversation.chunks], - ); - - const hasOnlyTextContent = useMemo( - () => - conversation.chunks?.length > 0 && - conversation.chunks?.every((chunk) => chunk.source === "PORTAL_TEXT"), - [conversation.chunks], - ); - - const fDuration = useCallback((duration: number) => { - const d = intervalToDuration({ - start: 0, - end: duration * 1000, - }); - - const hours = d.hours || 0; - const minutes = d.minutes || 0; - const seconds = d.seconds || 0; - - if (hours > 0) { - return `${hours.toString().padStart(2, "0")}:${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`; - } else { - return `${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`; - } - }, []); - - const isUpload = - conversation.source?.toLowerCase().includes("upload") ?? false; - - return ( - - {isUpload && ( - - {t`Upload`} - - )} - - {hasOnlyTextContent && ( - - Text - - )} - - {conversation.duration && conversation.duration > 0 && showDuration && ( - - {fDuration(conversation.duration)} - - )} - - {!hasContent && - conversation.is_finished === true && - conversation.is_all_chunks_transcribed === true && ( - - {t`Empty`} - - )} - {/* + const { projectId } = useParams(); + + useProjectById({ + projectId: projectId ?? "", + query: { + fields: ["is_enhanced_audio_processing_enabled"], + }, + }); + + const hasContent = useMemo( + () => conversation.chunks?.length && conversation.chunks.length > 0, + [conversation.chunks], + ); + + const hasOnlyTextContent = useMemo( + () => + conversation.chunks?.length > 0 && + conversation.chunks?.every((chunk) => chunk.source === "PORTAL_TEXT"), + [conversation.chunks], + ); + + const fDuration = useCallback((duration: number) => { + const d = intervalToDuration({ + start: 0, + end: duration * 1000, + }); + + const hours = d.hours || 0; + const minutes = d.minutes || 0; + const seconds = d.seconds || 0; + + if (hours > 0) { + return `${hours.toString().padStart(2, "0")}:${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`; + } else { + return `${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`; + } + }, []); + + const isUpload = + conversation.source?.toLowerCase().includes("upload") ?? false; + + return ( + + {isUpload && ( + + {t`Upload`} + + )} + + {hasOnlyTextContent && ( + + Text + + )} + + {conversation.duration && conversation.duration > 0 && showDuration && ( + + {fDuration(conversation.duration)} + + )} + + {!hasContent && + conversation.is_finished === true && + conversation.is_all_chunks_transcribed === true && ( + + {t`Empty`} + + )} + {/* {conversation.error != null && ( )} */} - - ); + + ); }; const ConversationAccordionItem = ({ - conversation, - highlight = false, - showDuration = false, + conversation, + highlight = false, + showDuration = false, }: { - conversation?: Conversation & { live: boolean }; - highlight?: boolean; - showDuration?: boolean; + conversation?: Conversation & { live: boolean }; + highlight?: boolean; + showDuration?: boolean; }) => { - const location = useLocation(); - const inChatMode = location.pathname.includes("/chats/"); - - const { chatId } = useParams(); - const chatContextQuery = useProjectChatContext(chatId ?? ""); - - if (inChatMode && chatContextQuery.isLoading) { - return ; - } - - if (!conversation) { - return null; - } - - const isLocked = chatContextQuery.data?.conversations?.find( - (c) => c.conversation_id === conversation.id && c.locked, - ); - - const isAutoSelectEnabled = chatContextQuery.data?.auto_select_bool ?? false; - - return ( - - ) - } - > - - - - - {conversation.participant_email ?? conversation.participant_name} - - - - -
- - {formatRelative( - new Date(conversation.created_at ?? new Date()), - new Date(), - )} - - { - // if from portal and not finished - ["portal_audio"].includes( - conversation.source?.toLowerCase() ?? "", - ) && - conversation.live && ( - -
- - Ongoing - - - ) - } -
- - {conversation.tags && - conversation.tags - .filter((tag) => tag.project_tag_id && tag.project_tag_id != null) - .map((tag) => ( - - {(tag?.project_tag_id as unknown as ProjectTag)?.text} - - ))} - - - - ); + const location = useLocation(); + const inChatMode = location.pathname.includes("/chats/"); + + const { chatId } = useParams(); + const chatContextQuery = useProjectChatContext(chatId ?? ""); + + if (inChatMode && chatContextQuery.isLoading) { + return ; + } + + if (!conversation) { + return null; + } + + const isLocked = chatContextQuery.data?.conversations?.find( + (c) => c.conversation_id === conversation.id && c.locked, + ); + + const isAutoSelectEnabled = chatContextQuery.data?.auto_select_bool ?? false; + + return ( + + ) + } + > + + + + + {conversation.participant_email ?? conversation.participant_name} + + + + +
+ + {formatRelative( + new Date(conversation.created_at ?? new Date()), + new Date(), + )} + + { + // if from portal and not finished + ["portal_audio"].includes( + conversation.source?.toLowerCase() ?? "", + ) && + conversation.live && ( + +
+ + Ongoing + + + ) + } +
+ + {conversation.tags + ?.filter((tag) => tag.project_tag_id && tag.project_tag_id != null) + .map((tag) => ( + + {(tag?.project_tag_id as unknown as ProjectTag)?.text} + + ))} + + + + ); }; // Conversation Accordion -export const ConversationAccordion = ({ - projectId, - qrCodeRef -}: { - projectId: string; - qrCodeRef?: RefObject; +export const ConversationAccordion = ({ + projectId, + qrCodeRef, +}: { + projectId: string; + qrCodeRef?: RefObject; }) => { - const SORT_OPTIONS: SortOption[] = [ - { label: t`Newest First`, value: "-created_at" }, - { label: t`Oldest First`, value: "created_at" }, - { label: t`Name A-Z`, value: "participant_name" }, - { label: t`Name Z-A`, value: "-participant_name" }, - { label: t`Longest First`, value: "-duration" }, - { label: t`Shortest First`, value: "duration" }, - ]; - - const location = useLocation(); - const inChatMode = location.pathname.includes("/chats/"); - const isMobile = useMediaQuery("(max-width: 768px)"); - const { conversationId: activeConversationId } = useParams(); - const { ref: loadMoreRef, inView } = useInView(); - - // Temporarily disabled source filters - // const FILTER_OPTIONS = [ - // { label: t`Conversations from QR Code`, value: "PORTAL_AUDIO" }, - // { label: t`Conversations from Upload`, value: "DASHBOARD_UPLOAD" }, - // ]; - - const [sortBy, setSortBy] = useSessionStorage({ - key: "conversations-sort", - defaultValue: "-created_at", - }); - - const [conversationSearch, setConversationSearch] = useState(""); - const [debouncedConversationSearchValue] = useDebouncedValue( - conversationSearch, - 200, - ); - - // Track active filters (filters to include) - // Temporarily disabled source filters - // const [activeFilters, setActiveFilters] = useState([ - // "PORTAL_AUDIO", - // "DASHBOARD_UPLOAD", - // ]); - - // Get total conversations count without filters - const totalConversationsQuery = useConversationsByProjectId( - projectId, - false, - false, - { - limit: 1, - deep: { - // @ts-expect-error chunks is not typed - chunks: { - _limit: 25, - }, - }, - }, - ); - - // Generalized toggle with improved UX - // Temporarily disabled source filters - // const toggleFilter = (filterValue: string) => { - // setActiveFilters((prev) => { - // const allFilterValues = FILTER_OPTIONS.map((opt) => opt.value); - // const isActive = prev.includes(filterValue); - - // // Case 1: If all filters are active and user clicks one - // if (prev.length === allFilterValues.length) { - // // Exclude only the clicked filter (keep all others active) - // return prev.filter((f) => f !== filterValue); - // } - - // // Case 2: If the filter is inactive, toggle it on - // if (!isActive) { - // return [...prev, filterValue]; - // } - - // // Case 3: If the filter is active but it's the only active filter - // // don't allow removing the last filter (prevent zero filters) - // if (prev.length === 1) { - // // Keep at least one filter active - // return prev; - // } - - // // Case 4: If the filter is active and there are other active filters, - // // toggle it off - // return prev.filter((f) => f !== filterValue); - // }); - // }; - - // Use memoized active filters for the query - // const filterBySource = useMemo(() => activeFilters, [activeFilters]); - - const [showDuration, setShowDuration] = useSessionStorage({ - key: "conversations-show-duration", - defaultValue: true, - }); - - // Tags filter state (fetch only tags for minimal payload) - const { data: projectTags, isLoading: projectTagsLoading } = useProjectById({ - projectId, - query: { - fields: [ - { - tags: ["id", "text", "sort"], - }, - ], - deep: { - // @ts-expect-error tags not typed in CustomDirectusTypes - tags: { - _sort: "sort", - }, - }, - }, - }); - const [tagSearch, setTagSearch] = useState(""); - const [selectedTagIds, setSelectedTagIds] = useState([]); - const allProjectTags = useMemo( - () => (projectTags?.tags as unknown as ProjectTag[]) ?? [], - [projectTags?.tags], - ); - const filteredProjectTags = useMemo(() => { - const query = tagSearch.trim().toLowerCase(); - if (!query) return allProjectTags; - return allProjectTags.filter((t) => - (t.text ?? "").toLowerCase().includes(query), - ); - }, [allProjectTags, tagSearch]); - - const conversationsQuery = useInfiniteConversationsByProjectId( - projectId, - false, - false, - { - search: debouncedConversationSearchValue, - sort: sortBy, - deep: { - // @ts-expect-error chunks is not typed - chunks: { - _limit: 25, - }, - }, - // Override filter to add tag filtering while preserving project scope - filter: { - project_id: { _eq: projectId }, - ...(selectedTagIds.length > 0 && { - tags: { - _some: { - project_tag_id: { - id: { _in: selectedTagIds }, - }, - }, - }, - }), - }, - }, - // Temporarily disabled source filters - // filterBySource, - undefined, - { - initialLimit: 15, - }, - ); - - // Get total conversations count for display - const conversationsCountQuery = useConversationsCountByProjectId(projectId); - const totalConversations = Number(conversationsCountQuery.data) ?? 0; - - // Load more conversations when user scrolls to bottom - useEffect(() => { - if ( - inView && - conversationsQuery.hasNextPage && - !conversationsQuery.isFetchingNextPage - ) { - conversationsQuery.fetchNextPage(); - } - }, [ - inView, - conversationsQuery.hasNextPage, - conversationsQuery.isFetchingNextPage, - conversationsQuery.fetchNextPage, - ]); - - // Flatten all conversations from all pages - const allConversations = - conversationsQuery.data?.pages.flatMap((page) => page.conversations) ?? []; - - const [parent2] = useAutoAnimate(); - - const filterApplied = useMemo( - () => - debouncedConversationSearchValue !== "" || - sortBy !== "-created_at" || - selectedTagIds.length > 0, - // Temporarily disabled source filters - // sortBy !== "-created_at" || - // activeFilters.length !== FILTER_OPTIONS.length, - // [debouncedConversationSearchValue, sortBy, activeFilters], - [debouncedConversationSearchValue, sortBy, selectedTagIds.length], - ); - - const appliedFiltersCount = useMemo(() => { - return selectedTagIds.length; - }, [sortBy, selectedTagIds.length]); - - const [showFilterActions, setShowFilterActions] = useState(false); - const [sortMenuOpened, setSortMenuOpened] = useState(false); - const [tagsMenuOpened, setTagsMenuOpened] = useState(false); - - const resetEverything = useCallback(() => { - setConversationSearch(""); - setSortBy("-created_at"); - // Temporarily disabled source filters - // setActiveFilters(["PORTAL_AUDIO", "DASHBOARD_UPLOAD"]); - setShowDuration(true); - setSelectedTagIds([]); - setTagSearch(""); - }, []); - - // Temporarily disabled source filters - // const FilterPin = ({ - // option, - // }: { - // option: { label: string; value: string }; - // }) => { - // const isActive = activeFilters.includes(option.value); - - // // Determine which icon to use based on the filter type - // const getIcon = () => { - // if (option.value === "PORTAL_AUDIO") { - // return isActive ? ( - // - // ) : ( - // - // ); - // } else { - // return isActive ? ( - // - // ) : ( - // - // ); - // } - // }; - - // return ( - // - // toggleFilter(option.value)} - // className="transition-all" - // radius="xl" - // size="md" - // aria-label={option.label} - // > - // {getIcon()} - // - // - // ); - // }; - - return ( - - - - - <span className="min-w-[48px] pr-2 font-normal text-gray-500"> - {conversationsCountQuery.isLoading ? ( - <Loader size="xs" /> - ) : ( - totalConversations - )} - </span> - <Trans>Conversations</Trans> - - -
e.stopPropagation()}> - -
-
-
- - - - {inChatMode && ENABLE_CHAT_AUTO_SELECT && totalConversations > 0 && ( - - - - - )} - - {!( - totalConversations === 0 && debouncedConversationSearchValue === "" - ) && ( - - } - rightSection={ - !!conversationSearch && ( - { - setConversationSearch(""); - }} - > - - - ) - } - placeholder={t`Search conversations`} - value={conversationSearch} - size="sm" - onChange={(e) => setConversationSearch(e.currentTarget.value)} - className="flex-grow" - /> - - - setShowFilterActions((prev) => !prev)} - aria-label={t`Options`} - > - {showFilterActions ? ( - - ) : ( - - )} - - {appliedFiltersCount > 0 && ( - - {appliedFiltersCount} - - )} - - - - )} - - {showFilterActions && ( - - - - - - - - - - Sort - - - - setSortBy(value as SortOption["value"]) - } - name="sortOptions" - > - - {SORT_OPTIONS.map((option) => ( - - ))} - - - - - - - - - - - - - - - setTagSearch(e.currentTarget.value)} - size="sm" - rightSection={ - !!tagSearch && ( - setTagSearch("")} - size="sm" - > - - - ) - } - /> - - {selectedTagIds.length > 0 && ( - - {selectedTagIds.map((tagId) => { - const tag = allProjectTags.find( - (t) => t.id === tagId, - ); - if (!tag) return null; - return ( - - setSelectedTagIds((prev) => - prev.filter((id) => id !== tagId), - ) - } - > - {tag.text} - - ); - })} - - )} - - - - {projectTagsLoading ? ( -
- -
- ) : ( - - - {filteredProjectTags.map((tag) => { - const checked = selectedTagIds.includes(tag.id); - return ( - { - const isChecked = e.currentTarget.checked; - setSelectedTagIds((prev) => { - if (isChecked) { - if (prev.includes(tag.id)) return prev; - return [...prev, tag.id]; - } - return prev.filter((id) => id !== tag.id); - }); - }} - styles={{ - labelWrapper: { - width: "100%", - }, - }} - /> - ); - })} - {filteredProjectTags.length === 0 && ( - - No tags found - - )} - - - )} -
-
-
- - - - - - -
- )} - - {/* Filter icons that always appear under the search bar */} - {/* Temporarily disabled source filters */} - {/* {totalConversationsQuery.data?.length !== 0 && ( + const SORT_OPTIONS: SortOption[] = [ + { label: t`Newest First`, value: "-created_at" }, + { label: t`Oldest First`, value: "created_at" }, + { label: t`Name A-Z`, value: "participant_name" }, + { label: t`Name Z-A`, value: "-participant_name" }, + { label: t`Longest First`, value: "-duration" }, + { label: t`Shortest First`, value: "duration" }, + ]; + + const location = useLocation(); + const inChatMode = location.pathname.includes("/chats/"); + const isMobile = useMediaQuery("(max-width: 768px)"); + const { conversationId: activeConversationId } = useParams(); + const { ref: loadMoreRef, inView } = useInView(); + + // Temporarily disabled source filters + // const FILTER_OPTIONS = [ + // { label: t`Conversations from QR Code`, value: "PORTAL_AUDIO" }, + // { label: t`Conversations from Upload`, value: "DASHBOARD_UPLOAD" }, + // ]; + + const [sortBy, setSortBy] = useSessionStorage({ + key: "conversations-sort", + defaultValue: "-created_at", + }); + + const [conversationSearch, setConversationSearch] = useState(""); + const [debouncedConversationSearchValue] = useDebouncedValue( + conversationSearch, + 200, + ); + + // Track active filters (filters to include) + // Temporarily disabled source filters + // const [activeFilters, setActiveFilters] = useState([ + // "PORTAL_AUDIO", + // "DASHBOARD_UPLOAD", + // ]); + + // Get total conversations count without filters + + // Generalized toggle with improved UX + // Temporarily disabled source filters + // const toggleFilter = (filterValue: string) => { + // setActiveFilters((prev) => { + // const allFilterValues = FILTER_OPTIONS.map((opt) => opt.value); + // const isActive = prev.includes(filterValue); + + // // Case 1: If all filters are active and user clicks one + // if (prev.length === allFilterValues.length) { + // // Exclude only the clicked filter (keep all others active) + // return prev.filter((f) => f !== filterValue); + // } + + // // Case 2: If the filter is inactive, toggle it on + // if (!isActive) { + // return [...prev, filterValue]; + // } + + // // Case 3: If the filter is active but it's the only active filter + // // don't allow removing the last filter (prevent zero filters) + // if (prev.length === 1) { + // // Keep at least one filter active + // return prev; + // } + + // // Case 4: If the filter is active and there are other active filters, + // // toggle it off + // return prev.filter((f) => f !== filterValue); + // }); + // }; + + // Use memoized active filters for the query + // const filterBySource = useMemo(() => activeFilters, [activeFilters]); + + const [showDuration, setShowDuration] = useSessionStorage({ + key: "conversations-show-duration", + defaultValue: true, + }); + + // Tags filter state (fetch only tags for minimal payload) + const { data: projectTags, isLoading: projectTagsLoading } = useProjectById({ + projectId, + query: { + fields: [ + { + tags: ["id", "text", "sort"], + }, + ], + deep: { + // @ts-expect-error tags not typed in CustomDirectusTypes + tags: { + _sort: "sort", + }, + }, + }, + }); + const [tagSearch, setTagSearch] = useState(""); + const [selectedTagIds, setSelectedTagIds] = useState([]); + const allProjectTags = useMemo( + () => (projectTags?.tags as unknown as ProjectTag[]) ?? [], + [projectTags?.tags], + ); + const filteredProjectTags = useMemo(() => { + const query = tagSearch.trim().toLowerCase(); + if (!query) return allProjectTags; + return allProjectTags.filter((t) => + (t.text ?? "").toLowerCase().includes(query), + ); + }, [allProjectTags, tagSearch]); + + const conversationsQuery = useInfiniteConversationsByProjectId( + projectId, + false, + false, + { + search: debouncedConversationSearchValue, + sort: sortBy, + deep: { + // @ts-expect-error chunks is not typed + chunks: { + _limit: 25, + }, + }, + // Override filter to add tag filtering while preserving project scope + filter: { + project_id: { _eq: projectId }, + ...(selectedTagIds.length > 0 && { + tags: { + _some: { + project_tag_id: { + id: { _in: selectedTagIds }, + }, + }, + }, + }), + }, + }, + // Temporarily disabled source filters + // filterBySource, + undefined, + { + initialLimit: 15, + }, + ); + + // Get total conversations count for display + const conversationsCountQuery = useConversationsCountByProjectId(projectId); + const totalConversations = Number(conversationsCountQuery.data) ?? 0; + + // Load more conversations when user scrolls to bottom + useEffect(() => { + if ( + inView && + conversationsQuery.hasNextPage && + !conversationsQuery.isFetchingNextPage + ) { + conversationsQuery.fetchNextPage(); + } + }, [ + inView, + conversationsQuery.hasNextPage, + conversationsQuery.isFetchingNextPage, + conversationsQuery.fetchNextPage, + ]); + + // Flatten all conversations from all pages + const allConversations = + conversationsQuery.data?.pages.flatMap((page) => page.conversations) ?? []; + + const [parent2] = useAutoAnimate(); + + const filterApplied = useMemo( + () => + debouncedConversationSearchValue !== "" || + sortBy !== "-created_at" || + selectedTagIds.length > 0, + // Temporarily disabled source filters + // sortBy !== "-created_at" || + // activeFilters.length !== FILTER_OPTIONS.length, + // [debouncedConversationSearchValue, sortBy, activeFilters], + [debouncedConversationSearchValue, sortBy, selectedTagIds.length], + ); + + // biome-ignore lint/correctness/useExhaustiveDependencies: + const appliedFiltersCount = useMemo(() => { + return selectedTagIds.length; + }, [sortBy, selectedTagIds.length]); + + const [showFilterActions, setShowFilterActions] = useState(false); + const [sortMenuOpened, setSortMenuOpened] = useState(false); + const [tagsMenuOpened, setTagsMenuOpened] = useState(false); + + const resetEverything = useCallback(() => { + setConversationSearch(""); + setSortBy("-created_at"); + // Temporarily disabled source filters + // setActiveFilters(["PORTAL_AUDIO", "DASHBOARD_UPLOAD"]); + setShowDuration(true); + setSelectedTagIds([]); + setTagSearch(""); + // not sure why only these 2 were needed. biome seems to shut up with these 2. i tried putting all. will need to investigate + }, [setSortBy, setShowDuration]); + + // Temporarily disabled source filters + // const FilterPin = ({ + // option, + // }: { + // option: { label: string; value: string }; + // }) => { + // const isActive = activeFilters.includes(option.value); + + // // Determine which icon to use based on the filter type + // const getIcon = () => { + // if (option.value === "PORTAL_AUDIO") { + // return isActive ? ( + // + // ) : ( + // + // ); + // } else { + // return isActive ? ( + // + // ) : ( + // + // ); + // } + // }; + + // return ( + // + // toggleFilter(option.value)} + // className="transition-all" + // radius="xl" + // size="md" + // aria-label={option.label} + // > + // {getIcon()} + // + // + // ); + // }; + + return ( + + + + + <span className="min-w-[48px] pr-2 font-normal text-gray-500"> + {conversationsCountQuery.isLoading ? ( + <Loader size="xs" /> + ) : ( + totalConversations + )} + </span> + <Trans>Conversations</Trans> + + + {/** biome-ignore lint/a11y/noStaticElementInteractions: */} + {/** biome-ignore lint/a11y/useKeyWithClickEvents: */} +
e.stopPropagation()}> + +
+
+
+ + + + {inChatMode && ENABLE_CHAT_AUTO_SELECT && totalConversations > 0 && ( + + + + + )} + + {!( + totalConversations === 0 && debouncedConversationSearchValue === "" + ) && ( + + } + rightSection={ + !!conversationSearch && ( + { + setConversationSearch(""); + }} + > + + + ) + } + placeholder={t`Search conversations`} + value={conversationSearch} + size="sm" + onChange={(e) => setConversationSearch(e.currentTarget.value)} + className="flex-grow" + /> + + + setShowFilterActions((prev) => !prev)} + aria-label={t`Options`} + > + {showFilterActions ? ( + + ) : ( + + )} + + {appliedFiltersCount > 0 && ( + + {appliedFiltersCount} + + )} + + + + )} + + {showFilterActions && ( + + + + + + + + + + Sort + + + + setSortBy(value as SortOption["value"]) + } + name="sortOptions" + > + + {SORT_OPTIONS.map((option) => ( + + ))} + + + + + + + + + + + + + + + setTagSearch(e.currentTarget.value)} + size="sm" + rightSection={ + !!tagSearch && ( + setTagSearch("")} + size="sm" + > + + + ) + } + /> + + {selectedTagIds.length > 0 && ( + + {selectedTagIds.map((tagId) => { + const tag = allProjectTags.find( + (t) => t.id === tagId, + ); + if (!tag) return null; + return ( + + setSelectedTagIds((prev) => + prev.filter((id) => id !== tagId), + ) + } + > + {tag.text} + + ); + })} + + )} + + + + {projectTagsLoading ? ( +
+ +
+ ) : ( + + + {filteredProjectTags.map((tag) => { + const checked = selectedTagIds.includes(tag.id); + return ( + { + const isChecked = e.currentTarget.checked; + setSelectedTagIds((prev) => { + if (isChecked) { + if (prev.includes(tag.id)) return prev; + return [...prev, tag.id]; + } + return prev.filter((id) => id !== tag.id); + }); + }} + styles={{ + labelWrapper: { + width: "100%", + }, + }} + /> + ); + })} + {filteredProjectTags.length === 0 && ( + + No tags found + + )} + + + )} +
+
+
+ + + + + + +
+ )} + + {/* Filter icons that always appear under the search bar */} + {/* Temporarily disabled source filters */} + {/* {totalConversationsQuery.data?.length !== 0 && ( Sources: @@ -1110,55 +1094,58 @@ export const ConversationAccordion = ({ )} */} - {allConversations.length === 0 && !conversationsQuery.isLoading && ( - - - No conversations found. Start a conversation using the - participation invite link from the{" "} - - { - if (qrCodeRef?.current && isMobile) { - e.preventDefault(); - qrCodeRef.current.scrollIntoView({ behavior: "smooth", block: "center" }); - } - }} - > - project overview. - - - - - )} - - - {conversationsQuery.status === "pending" && ( - - )} - {allConversations.map((item, index) => ( -
- -
- ))} - {conversationsQuery.isFetchingNextPage && ( -
- -
- )} - {/* {!conversationsQuery.hasNextPage && + {allConversations.length === 0 && !conversationsQuery.isLoading && ( + + + No conversations found. Start a conversation using the + participation invite link from the{" "} + + { + if (qrCodeRef?.current && isMobile) { + e.preventDefault(); + qrCodeRef.current.scrollIntoView({ + behavior: "smooth", + block: "center", + }); + } + }} + > + project overview. + + + + + )} + + + {conversationsQuery.status === "pending" && ( + + )} + {allConversations.map((item, index) => ( +
+ +
+ ))} + {conversationsQuery.isFetchingNextPage && ( +
+ +
+ )} + {/* {!conversationsQuery.hasNextPage && allConversations.length > 0 && debouncedConversationSearchValue === "" && (
@@ -1171,16 +1158,16 @@ export const ConversationAccordion = ({
)} */} - {/* Temporarily disabled source filters */} - {/* {allConversations.length === 0 && + {/* Temporarily disabled source filters */} + {/* {allConversations.length === 0 && filterBySource.length === 0 && ( Please select at least one source )} */} -
-
-
-
- ); +
+ +
+
+ ); }; diff --git a/echo/frontend/src/components/conversation/ConversationChunkAudioTranscript.tsx b/echo/frontend/src/components/conversation/ConversationChunkAudioTranscript.tsx index bbd25bb5..b0718f62 100644 --- a/echo/frontend/src/components/conversation/ConversationChunkAudioTranscript.tsx +++ b/echo/frontend/src/components/conversation/ConversationChunkAudioTranscript.tsx @@ -1,401 +1,78 @@ import { t } from "@lingui/core/macro"; -import { - Text, - Divider, - Skeleton, - ActionIcon, - Modal, - Badge, - Group, - Switch, - Tooltip, -} from "@mantine/core"; +import { Divider, Skeleton, Text } from "@mantine/core"; + import { BaseMessage } from "../chat/BaseMessage"; import { useConversationChunkContentUrl } from "./hooks"; -import { IconInfoCircle, IconClockPlay } from "@tabler/icons-react"; -import { useDisclosure } from "@mantine/hooks"; -import DiffViewer from "../common/DiffViewer"; -import { - useEffect, - useMemo, - useRef, - useState, - useCallback, -} from "react"; - -type Word = { text: string; start: number; end: number; confidence?: number }; -type Segment = { id: string; text: string; startSec: number; endSec: number }; - -function isMs(v: number) { - return v > 600; -} -function toSec(v: number) { - return isMs(v) ? v / 1000 : v; -} -function formatTime(sec: number) { - const s = Math.max(0, Math.floor(sec)); - const m = Math.floor(s / 60); - const r = s % 60; - return `${m.toString().padStart(2, "0")}:${r.toString().padStart(2, "0")}`; -} - -function splitSentences(s: string): string[] { - if (!s?.trim()) return []; - const matches = s.replace(/\r\n/g, "\n").match(/[^.!?\n]+[.!?]?(?:\s+|$)/g); - if (!matches) return [s.trim()]; - return matches.map((p) => p.trim()).filter(Boolean); -} - -function groupSentencesToParagraphs(sentences: string[], maxChars = 280, maxSentences = 3) { - const paras: string[] = []; - let cur: string[] = []; - let curLen = 0; - for (const s of sentences) { - if (!s) continue; - if (cur.length >= maxSentences || curLen + s.length > maxChars) { - paras.push(cur.join(" ")); - cur = []; - curLen = 0; - } - cur.push(s); - curLen += s.length + 1; - } - if (cur.length) paras.push(cur.join(" ")); - return paras.length ? paras : [sentences.join(" ")]; -} export const ConversationChunkAudioTranscript = ({ - chunk, - showAudioPlayer = true, - isActive = false, - onSeek, - currentTime, - chunkOffsetStart = 0, - legacyView = false, - precomputedSegments, + chunk, + showAudioPlayer = true, }: { - chunk: { - conversation_id: string; - id: string; - path: string; - timestamp: string; - transcript: string; - error?: string | null; - diarization?: any; - }; - showAudioPlayer?: boolean; - isActive?: boolean; - onSeek?: (timeSec: number) => void; - currentTime?: number; - chunkOffsetStart?: number; - legacyView?: boolean; - precomputedSegments?: Segment[]; + chunk: { + conversation_id: string; + id: string; + path: string; + timestamp: string; + transcript: string; + error: string; + }; + showAudioPlayer?: boolean; }) => { - const audioUrlQuery = useConversationChunkContentUrl( - chunk.conversation_id as string, - chunk.id, - showAudioPlayer && !!chunk.path, - ); - - const localAudioRef = useRef(null); - - const [rawTranscript, setRawTranscript] = useState(null); - const [note, setNote] = useState(null); - - // Follow toggle - const [follow, setFollow] = useState(true); - - // Active segment index for auto-scroll - const [activeSeg, setActiveSeg] = useState(-1); - - // Raw transcript for Diff modal - useEffect(() => { - if (chunk.diarization?.schema === "Dembrane-25-09") { - const data = chunk.diarization.data; - setRawTranscript(data?.raw?.text ?? null); - setNote(data?.note ?? null); - } - }, [chunk.diarization]); - - // Choose segments: prefer precomputed - const segments: Segment[] = useMemo( - () => precomputedSegments ?? [], - [precomputedSegments], - ); - - // Playback time helpers - const absoluteStart = chunkOffsetStart ?? 0; - - const getNow = useCallback(() => { - if (currentTime != null) return currentTime; - if (localAudioRef.current) return localAudioRef.current.currentTime; - return undefined; - }, [currentTime]); - - // Update active segment on time change - const updateActiveFromTime = useCallback(() => { - const now = getNow(); - if (now == null || !segments.length) return; - const t = now - absoluteStart; - if (t < 0) return; - - // binary search for performance could replace this - let idx = -1; - for (let i = 0; i < segments.length; i++) { - if (t >= segments[i].startSec && t < segments[i].endSec) { - idx = i; - break; - } - } - if (idx !== activeSeg) setActiveSeg(idx); - }, [getNow, absoluteStart, segments, activeSeg]); - - useEffect(() => { - updateActiveFromTime(); - }, [currentTime, updateActiveFromTime]); - - useEffect(() => { - const el = localAudioRef.current; - if (!el) return; - const onTime = () => updateActiveFromTime(); - el.addEventListener("timeupdate", onTime); - return () => el.removeEventListener("timeupdate", onTime); - }, [updateActiveFromTime]); - - // Auto-scroll to active segment - useEffect(() => { - if (!follow || activeSeg < 0) return; - const node = document.getElementById(`${chunk.id}-seg-${activeSeg}`); - if (node) node.scrollIntoView({ behavior: "smooth", block: "center" }); - }, [activeSeg, follow, chunk.id]); - - // Next/prev paragraph via global keyboard - useEffect(() => { - const prev = () => { - if (!isActive || segments.length === 0) return; - const target = Math.max(0, activeSeg >= 0 ? activeSeg - 1 : 0); - const start = segments[target].startSec + absoluteStart; - if (onSeek) onSeek(start); - else if (localAudioRef.current) localAudioRef.current.currentTime = start; - }; - const next = () => { - if (!isActive || segments.length === 0) return; - const target = Math.min( - segments.length - 1, - activeSeg >= 0 ? activeSeg + 1 : 0, - ); - const start = segments[target].startSec + absoluteStart; - if (onSeek) onSeek(start); - else if (localAudioRef.current) localAudioRef.current.currentTime = start; - }; - const onPrev = () => prev(); - const onNext = () => next(); - window.addEventListener("dembrane-transcript-prev", onPrev as any); - window.addEventListener("dembrane-transcript-next", onNext as any); - return () => { - window.removeEventListener("dembrane-transcript-prev", onPrev as any); - window.removeEventListener("dembrane-transcript-next", onNext as any); - }; - }, [isActive, segments, activeSeg, absoluteStart, onSeek]); - - // Click-to-seek helper - const seekTo = (seg: Segment) => { - const absolute = seg.startSec + absoluteStart; - if (onSeek) onSeek(absolute); - else if (localAudioRef.current) { - localAudioRef.current.currentTime = absolute; - localAudioRef.current.play().catch(() => void 0); - } - }; - - // Legacy plain rendering - const renderLegacy = () => { - if (!chunk.transcript || chunk.transcript.trim().length === 0) { - return chunk.error ? ( - {t`Transcript not available`} - ) : ( - {t`Transcription in progress…`} - ); - } - const sentences = splitSentences(chunk.transcript); - const paragraphs = groupSentencesToParagraphs(sentences); - return ( -
- {paragraphs.map((p, i) => ( - {p} - ))} -
- ); - }; - - const processedRawTranscript = useMemo(() => { - if (!rawTranscript) return ""; - return splitSentences(rawTranscript).join('\n'); - }, [rawTranscript]); - - const processedEnhancedTranscript = useMemo(() => { - if (!chunk.transcript) return ""; - return splitSentences(chunk.transcript).join('\n'); - }, [chunk.transcript]); - - const [ - diarizationModalOpened, - { open: openDiarizationModal, close: closeDiarizationModal }, - ] = useDisclosure(false); - - return ( - - - - {new Date(chunk.timestamp).toLocaleTimeString()} - - {!legacyView && onSeek && ( - - setFollow(e.currentTarget.checked)} - label={t`Follow`} - /> - - )} - - - {rawTranscript && ( - - - - )} -
- } - bottomSection={ - showAudioPlayer ? ( - <> - - {!chunk.path ? ( - - {t`Submitted via text input`} - - ) : audioUrlQuery.isLoading ? ( - - ) : audioUrlQuery.isError ? ( - - {t`Failed to load audio or the audio is not available`} - - ) : ( -
- } - /> - - )} - - ); + const audioUrlQuery = useConversationChunkContentUrl( + chunk.conversation_id as string, + chunk.id, + showAudioPlayer && !!chunk.path, + ); + + return ( + + {new Date(chunk.timestamp).toLocaleTimeString()} + + } + bottomSection={ + showAudioPlayer && ( + <> + + {!chunk.path ? ( + + Submitted via text input + + ) : audioUrlQuery.isLoading ? ( + + ) : audioUrlQuery.isError ? ( + + Failed to load audio or the audio is not available + + ) : ( + // biome-ignore lint/a11y/useMediaCaption: + + ); }; diff --git a/echo/frontend/src/components/conversation/CopyConversationTranscript.tsx b/echo/frontend/src/components/conversation/CopyConversationTranscript.tsx new file mode 100644 index 00000000..8c090a67 --- /dev/null +++ b/echo/frontend/src/components/conversation/CopyConversationTranscript.tsx @@ -0,0 +1,47 @@ +import { t } from "@lingui/core/macro"; +import { ActionIcon, CopyButton, Tooltip } from "@mantine/core"; +import { IconCheck, IconCopy } from "@tabler/icons-react"; +import { useCallback, useState } from "react"; +import { useGetConversationTranscriptStringMutation } from "./hooks"; + +export const CopyConversationTranscriptActionIcon = (props: { + conversationId: string; +}) => { + const { conversationId } = props; + + const [transcript, setTranscript] = useState(""); + + const getConversationTranscriptStringMutation = + useGetConversationTranscriptStringMutation(); + + const preCopy = useCallback(async () => { + getConversationTranscriptStringMutation.mutate(conversationId, { + onSuccess: (data) => { + setTranscript(data); + }, + }); + }, [getConversationTranscriptStringMutation, conversationId]); + + return ( + + {({ copied, copy }) => ( + + { + await preCopy(); + // hmm this is a hack to wait for the transcript to be set. not rly a best practice + // i rly wanted to use the CopyButton haha + await new Promise((resolve) => setTimeout(resolve, 500)); + copy(); + }} + disabled={getConversationTranscriptStringMutation.isPending} + > + {copied ? : } + + + )} + + ); +}; diff --git a/echo/frontend/src/components/conversation/DownloadConversationTranscript.tsx b/echo/frontend/src/components/conversation/DownloadConversationTranscript.tsx new file mode 100644 index 00000000..844340b8 --- /dev/null +++ b/echo/frontend/src/components/conversation/DownloadConversationTranscript.tsx @@ -0,0 +1,101 @@ +import { t } from "@lingui/core/macro"; +import { Trans } from "@lingui/react/macro"; +import { + ActionIcon, + Button, + Modal, + Stack, + TextInput, + Tooltip, +} from "@mantine/core"; +import { useDisclosure } from "@mantine/hooks"; +import { IconDownload } from "@tabler/icons-react"; +import { useState } from "react"; +import { useGetConversationTranscriptStringMutation } from "./hooks"; + +export const DownloadConversationTranscriptModalActionIcon = ({ + conversationId, +}: { + conversationId: string; +}) => { + const [opened, { open, close }] = useDisclosure(false); + + return ( + <> + + + + + + + + ); +}; + +export const DownloadConversationTranscriptModal = (props: { + opened: boolean; + onClose: () => void; + conversationId: string; + defaultFilename?: string; +}) => { + const { opened, onClose, conversationId, defaultFilename } = props; + + const getConversationTranscriptStringMutation = + useGetConversationTranscriptStringMutation(); + + const [filenameDownload, setFilenameDownload] = useState( + defaultFilename ?? "", + ); + + const handleDownloadTranscript = async () => { + const transcript = + await getConversationTranscriptStringMutation.mutateAsync(conversationId); + const blob = new Blob([transcript], { type: "text/markdown" }); + const url = window.URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + + if (transcript) { + a.download = + filenameDownload !== "" + ? filenameDownload + : `Conversation-${conversationId}-transcript.md`; + } + + a.click(); + + window.URL.revokeObjectURL(url); + }; + + return ( + + + setFilenameDownload(e.currentTarget.value)} + /> + + + + ); +}; diff --git a/echo/frontend/src/components/conversation/RetranscribeConversation.tsx b/echo/frontend/src/components/conversation/RetranscribeConversation.tsx new file mode 100644 index 00000000..4ddc4439 --- /dev/null +++ b/echo/frontend/src/components/conversation/RetranscribeConversation.tsx @@ -0,0 +1,159 @@ +import { t } from "@lingui/core/macro"; +import { Trans } from "@lingui/react/macro"; +import { + ActionIcon, + Alert, + Badge, + Button, + Group, + Modal, + Stack, + Switch, + Text, + TextInput, + Tooltip, +} from "@mantine/core"; +import { useDisclosure } from "@mantine/hooks"; +import { IconRefresh } from "@tabler/icons-react"; +import { useState } from "react"; +import { useParams } from "react-router"; +import { toast } from "sonner"; +import { useI18nNavigate } from "@/hooks/useI18nNavigate"; +import { ExponentialProgress } from "../common/ExponentialProgress"; +import { useRetranscribeConversationMutation } from "./hooks"; + +export const RetranscribeConversationModalActionIcon = ({ + conversationId, + conversationName, +}: { + conversationId: string; + conversationName: string; +}) => { + const [opened, { open, close }] = useDisclosure(false); + + return ( + <> + + + + + + + + + ); +}; + +export const RetranscribeConversationModal = ({ + conversationId, + conversationName, + opened, + onClose, +}: { + conversationId: string; + conversationName: string; + opened: boolean; + onClose: () => void; +}) => { + // this should rly be a prop im lazy + const { projectId } = useParams(); + + const retranscribeMutation = useRetranscribeConversationMutation(); + + const [newConversationName, setNewConversationName] = useState( + conversationName ?? "", + ); + const [usePiiRedaction, setUsePiiRedaction] = useState(false); + + const navigate = useI18nNavigate(); + + const handleRetranscribe = async () => { + if (!conversationId || !newConversationName.trim()) return; + const { new_conversation_id } = await retranscribeMutation.mutateAsync({ + conversationId, + newConversationName: newConversationName.trim(), + usePiiRedaction, + }); + if (new_conversation_id) { + onClose(); + toast.success( + t`Retranscription started. New conversation will be available soon.`, + { + action: { + label: t`Go to new conversation`, + actionButtonStyle: { + color: "blue", + }, + onClick: () => { + navigate( + `/projects/${projectId}/conversations/${new_conversation_id}/transcript`, + ); + }, + }, + }, + ); + } + }; + + return ( + + {t`Retranscribe Conversation`} + + Experimental + + + } + > + {retranscribeMutation.isPending ? ( + + + + Please wait while we process your retranscription request. You + will be redirected to the new conversation when ready. + + + + + ) : ( + + + + This will create a new conversation with the same audio but a + fresh transcription. The original conversation will remain + unchanged. + + + setNewConversationName(e.currentTarget.value)} + required + /> + .`} + checked={usePiiRedaction} + onChange={(e) => setUsePiiRedaction(e.currentTarget.checked)} + /> + + + )} + + ); +}; diff --git a/echo/frontend/src/components/conversation/hooks/index.ts b/echo/frontend/src/components/conversation/hooks/index.ts index e57021ad..270134ed 100644 --- a/echo/frontend/src/components/conversation/hooks/index.ts +++ b/echo/frontend/src/components/conversation/hooks/index.ts @@ -1,974 +1,984 @@ -import { directus } from "@/lib/directus"; -import { - useInfiniteQuery, - useMutation, - useQuery, - useQueryClient, - UseQueryOptions, -} from "@tanstack/react-query"; import { - Query, - QueryFields, - aggregate, - createItems, - deleteItems, - readItem, - readItems, - updateItem, + aggregate, + createItems, + deleteItems, + type Query, + type QueryFields, + readItem, + readItems, + updateItem, } from "@directus/sdk"; -import { - addChatContext, - apiNoAuth, - deleteChatContext, - deleteConversationById, - getConversationChunkContentLink, - getConversationContentLink, - getConversationTranscriptString, - retranscribeConversation, -} from "@/lib/api"; -import { toast } from "@/components/common/Toaster"; +import { t } from "@lingui/core/macro"; import * as Sentry from "@sentry/react"; +import { + type UseQueryOptions, + useInfiniteQuery, + useMutation, + useQuery, + useQueryClient, +} from "@tanstack/react-query"; import { AxiosError } from "axios"; -import { t } from "@lingui/core/macro"; +import { toast } from "@/components/common/Toaster"; +import { + addChatContext, + apiNoAuth, + deleteChatContext, + deleteConversationById, + getConversationChunkContentLink, + getConversationContentLink, + getConversationTranscriptString, + retranscribeConversation, +} from "@/lib/api"; +import { directus } from "@/lib/directus"; export const useInfiniteConversationChunks = ( - conversationId: string, - options?: { - initialLimit?: number; - refetchInterval?: number | false; - }, + conversationId: string, + options?: { + initialLimit?: number; + refetchInterval?: number | false; + }, ) => { - const defaultOptions = { - initialLimit: 10, - refetchInterval: 30000, - }; - - const { initialLimit, refetchInterval } = { ...defaultOptions, ...options }; - - return useInfiniteQuery({ - queryKey: ["conversations", conversationId, "chunks", "infinite"], - queryFn: async ({ pageParam = 0 }) => { - const response = await directus.request( - readItems("conversation_chunk", { - filter: { - conversation_id: { - _eq: conversationId, - }, - }, - sort: ["timestamp"], - limit: initialLimit, - offset: pageParam * initialLimit, - fields: ["id", "conversation_id", "transcript", "path", "timestamp", "error", "diarization"], - }), - ); - - return { - chunks: response, - nextOffset: - response.length === initialLimit ? pageParam + 1 : undefined, - }; - }, - initialPageParam: 0, - getNextPageParam: (lastPage) => lastPage.nextOffset, - refetchInterval, - }); + const defaultOptions = { + initialLimit: 10, + refetchInterval: 30000, + }; + + const { initialLimit, refetchInterval } = { ...defaultOptions, ...options }; + + return useInfiniteQuery({ + queryKey: ["conversations", conversationId, "chunks", "infinite"], + queryFn: async ({ pageParam = 0 }) => { + const response = await directus.request( + readItems("conversation_chunk", { + filter: { + conversation_id: { + _eq: conversationId, + }, + }, + sort: ["timestamp"], + limit: initialLimit, + offset: pageParam * initialLimit, + fields: [ + "id", + "conversation_id", + "transcript", + "path", + "timestamp", + "error", + "diarization", + ], + }), + ); + + return { + chunks: response, + nextOffset: + response.length === initialLimit ? pageParam + 1 : undefined, + }; + }, + initialPageParam: 0, + getNextPageParam: (lastPage) => lastPage.nextOffset, + refetchInterval, + }); }; export const useUpdateConversationByIdMutation = () => { - const queryClient = useQueryClient(); - return useMutation({ - mutationFn: ({ - id, - payload, - }: { - id: string; - payload: Partial; - }) => - directus.request(updateItem("conversation", id, payload)), - onSuccess: (values, variables) => { - queryClient.setQueryData( - ["conversations", variables.id], - (oldData: Conversation | undefined) => { - return { - ...oldData, - ...values, - }; - }, - ); - queryClient.invalidateQueries({ - queryKey: ["conversations"], - }); - }, - }); + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ + id, + payload, + }: { + id: string; + payload: Partial; + }) => + directus.request(updateItem("conversation", id, payload)), + onSuccess: (values, variables) => { + queryClient.setQueryData( + ["conversations", variables.id], + (oldData: Conversation | undefined) => { + return { + ...oldData, + ...values, + }; + }, + ); + queryClient.invalidateQueries({ + queryKey: ["conversations"], + }); + }, + }); }; // you always need to provide all the tags export const useUpdateConversationTagsMutation = () => { - const queryClient = useQueryClient(); - - return useMutation({ - mutationFn: async ({ - conversationId, - projectId, - projectTagIdList, - }: { - projectId: string; - conversationId: string; - projectTagIdList: string[]; - }) => { - let validTagsIds: string[] = []; - try { - const validTags = await directus.request( - readItems("project_tag", { - filter: { - id: { - _in: projectTagIdList, - }, - project_id: { - _eq: projectId, - }, - }, - fields: ["*"], - }), - ); - - validTagsIds = validTags.map((tag) => tag.id); - } catch (error) { - validTagsIds = []; - } - - const tagsRequest = await directus.request( - readItems("conversation_project_tag", { - fields: [ - "id", - { - project_tag_id: ["id"], - }, - { - conversation_id: ["id"], - }, - ], - filter: { - conversation_id: { _eq: conversationId }, - }, - }), - ); - - const needToDelete = tagsRequest.filter( - (conversationProjectTag) => - conversationProjectTag.project_tag_id && - !validTagsIds.includes( - (conversationProjectTag.project_tag_id as ProjectTag).id, - ), - ); - - const needToCreate = validTagsIds.filter( - (tagId) => - !tagsRequest.some( - (conversationProjectTag) => - (conversationProjectTag.project_tag_id as ProjectTag).id === - tagId, - ), - ); - - // slightly esoteric, but basically we only want to delete if there are any tags to delete - // otherwise, directus doesn't accept an empty array - const deletePromise = - needToDelete.length > 0 - ? directus.request( - deleteItems( - "conversation_project_tag", - needToDelete.map((tag) => tag.id), - ), - ) - : Promise.resolve(); - - // same deal for creating - const createPromise = - needToCreate.length > 0 - ? directus.request( - createItems( - "conversation_project_tag", - needToCreate.map((tagId) => ({ - conversation_id: { - id: conversationId, - } as Conversation, - project_tag_id: { - id: tagId, - } as ProjectTag, - })), - ), - ) - : Promise.resolve(); - - // await both promises - await Promise.all([deletePromise, createPromise]); - - return directus.request( - readItem("conversation", conversationId, { - fields: ["*"], - }), - ); - }, - onSuccess: (_values, variables) => { - queryClient.invalidateQueries({ - queryKey: ["conversations", variables.conversationId], - }); - queryClient.invalidateQueries({ - queryKey: ["projects", variables.projectId], - }); - }, - }); + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async ({ + conversationId, + projectId, + projectTagIdList, + }: { + projectId: string; + conversationId: string; + projectTagIdList: string[]; + }) => { + let validTagsIds: string[] = []; + try { + const validTags = await directus.request( + readItems("project_tag", { + filter: { + id: { + _in: projectTagIdList, + }, + project_id: { + _eq: projectId, + }, + }, + fields: ["*"], + }), + ); + + validTagsIds = validTags.map((tag) => tag.id); + } catch (error) { + validTagsIds = []; + } + + const tagsRequest = await directus.request( + readItems("conversation_project_tag", { + fields: [ + "id", + { + project_tag_id: ["id"], + }, + { + conversation_id: ["id"], + }, + ], + filter: { + conversation_id: { _eq: conversationId }, + }, + }), + ); + + const needToDelete = tagsRequest.filter( + (conversationProjectTag) => + conversationProjectTag.project_tag_id && + !validTagsIds.includes( + (conversationProjectTag.project_tag_id as ProjectTag).id, + ), + ); + + const needToCreate = validTagsIds.filter( + (tagId) => + !tagsRequest.some( + (conversationProjectTag) => + (conversationProjectTag.project_tag_id as ProjectTag).id === + tagId, + ), + ); + + // slightly esoteric, but basically we only want to delete if there are any tags to delete + // otherwise, directus doesn't accept an empty array + const deletePromise = + needToDelete.length > 0 + ? directus.request( + deleteItems( + "conversation_project_tag", + needToDelete.map((tag) => tag.id), + ), + ) + : Promise.resolve(); + + // same deal for creating + const createPromise = + needToCreate.length > 0 + ? directus.request( + createItems( + "conversation_project_tag", + needToCreate.map((tagId) => ({ + conversation_id: { + id: conversationId, + } as Conversation, + project_tag_id: { + id: tagId, + } as ProjectTag, + })), + ), + ) + : Promise.resolve(); + + // await both promises + await Promise.all([deletePromise, createPromise]); + + return directus.request( + readItem("conversation", conversationId, { + fields: ["*"], + }), + ); + }, + onSuccess: (_values, variables) => { + queryClient.invalidateQueries({ + queryKey: ["conversations", variables.conversationId], + }); + queryClient.invalidateQueries({ + queryKey: ["projects", variables.projectId], + }); + }, + }); }; export const useDeleteConversationByIdMutation = () => { - const queryClient = useQueryClient(); - return useMutation({ - mutationFn: deleteConversationById, - onSuccess: () => { - queryClient.invalidateQueries({ - queryKey: ["projects"], - }); - queryClient.invalidateQueries({ - queryKey: ["conversations"], - }); - toast.success("Conversation deleted successfully"); - }, - onError: (error: Error) => { - toast.error(error.message); - }, - }); + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: deleteConversationById, + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: ["projects"], + }); + queryClient.invalidateQueries({ + queryKey: ["conversations"], + }); + toast.success("Conversation deleted successfully"); + }, + onError: (error: Error) => { + toast.error(error.message); + }, + }); }; export const useMoveConversationMutation = () => { - const queryClient = useQueryClient(); - return useMutation({ - mutationFn: async ({ - conversationId, - targetProjectId, - }: { - conversationId: string; - targetProjectId: string; - }) => { - try { - await directus.request( - updateItem("conversation", conversationId, { - project_id: targetProjectId, - }), - ); - } catch (error) { - toast.error("Failed to move conversation."); - } - }, - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ["conversations"] }); - queryClient.invalidateQueries({ queryKey: ["projects"] }); - toast.success("Conversation moved successfully"); - }, - onError: (error: Error) => { - toast.error("Failed to move conversation: " + error.message); - }, - }); + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ + conversationId, + targetProjectId, + }: { + conversationId: string; + targetProjectId: string; + }) => { + try { + await directus.request( + updateItem("conversation", conversationId, { + project_id: targetProjectId, + }), + ); + } catch (error) { + toast.error("Failed to move conversation."); + } + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["conversations"] }); + queryClient.invalidateQueries({ queryKey: ["projects"] }); + toast.success("Conversation moved successfully"); + }, + onError: (error: Error) => { + toast.error("Failed to move conversation: " + error.message); + }, + }); }; export const useAddChatContextMutation = () => { - const queryClient = useQueryClient(); - return useMutation({ - mutationFn: (payload: { - chatId: string; - conversationId?: string; - auto_select_bool?: boolean; - }) => - addChatContext( - payload.chatId, - payload.conversationId, - payload.auto_select_bool, - ), - onMutate: async (variables) => { - // Cancel any outgoing refetches - await queryClient.cancelQueries({ - queryKey: ["chats", "context", variables.chatId], - }); - - // Snapshot the previous value - const previousChatContext = queryClient.getQueryData([ - "chats", - "context", - variables.chatId, - ]); - - // Optimistically update the chat context - let optimisticId: string | undefined = undefined; - queryClient.setQueryData( - ["chats", "context", variables.chatId], - (oldData: TProjectChatContext | undefined) => { - if (!oldData) return oldData; - - // If conversationId is provided, add it to the conversations array - if (variables.conversationId) { - const existingConversation = oldData.conversations.find( - (conv) => conv.conversation_id === variables.conversationId, - ); - - if (!existingConversation) { - optimisticId = "optimistic-" + Date.now() + Math.random(); - return { - ...oldData, - conversations: [ - ...oldData.conversations, - { - conversation_id: variables.conversationId, - conversation_participant_name: t`Loading...`, - locked: false, - token_usage: 0, - optimisticId, - }, - ], - }; - } - } - - // If auto_select_bool is provided, update it - if (variables.auto_select_bool !== undefined) { - return { - ...oldData, - auto_select_bool: variables.auto_select_bool, - }; - } - - return oldData; - }, - ); - - // Return a context object with the snapshotted value - return { - previousChatContext, - optimisticId, - conversationId: variables.conversationId ?? undefined, - }; - }, - onError: (error, variables, context) => { - Sentry.captureException(error); - - // Only rollback the failed optimistic entry - if (context?.optimisticId && context?.conversationId) { - queryClient.setQueryData( - ["chats", "context", variables.chatId], - (oldData: TProjectChatContext | undefined) => { - if (!oldData) return oldData; - return { - ...oldData, - conversations: oldData.conversations.filter( - (conv) => - conv.conversation_id !== context.conversationId || - conv.optimisticId !== context.optimisticId, - ), - }; - }, - ); - } else if (context?.previousChatContext) { - // fallback: full rollback - queryClient.setQueryData( - ["chats", "context", variables.chatId], - context.previousChatContext, - ); - } - - if (error instanceof AxiosError) { - let errorMessage = t`Failed to add conversation to chat${ - error.response?.data?.detail ? `: ${error.response.data.detail}` : "" - }`; - if (variables.auto_select_bool) { - errorMessage = t`Failed to enable Auto Select for this chat`; - } - toast.error(errorMessage); - } else { - let errorMessage = t`Failed to add conversation to chat`; - if (variables.auto_select_bool) { - errorMessage = t`Failed to enable Auto Select for this chat`; - } - toast.error(errorMessage); - } - }, - onSettled: (_, __, variables) => { - queryClient.invalidateQueries({ - queryKey: ["chats", "context", variables.chatId], - }); - }, - onSuccess: (_, variables) => { - const message = variables.auto_select_bool - ? t`Auto-select enabled` - : t`Conversation added to chat`; - toast.success(message); - }, - }); + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (payload: { + chatId: string; + conversationId?: string; + auto_select_bool?: boolean; + }) => + addChatContext( + payload.chatId, + payload.conversationId, + payload.auto_select_bool, + ), + onMutate: async (variables) => { + // Cancel any outgoing refetches + await queryClient.cancelQueries({ + queryKey: ["chats", "context", variables.chatId], + }); + + // Snapshot the previous value + const previousChatContext = queryClient.getQueryData([ + "chats", + "context", + variables.chatId, + ]); + + // Optimistically update the chat context + let optimisticId: string | undefined; + queryClient.setQueryData( + ["chats", "context", variables.chatId], + (oldData: TProjectChatContext | undefined) => { + if (!oldData) return oldData; + + // If conversationId is provided, add it to the conversations array + if (variables.conversationId) { + const existingConversation = oldData.conversations.find( + (conv) => conv.conversation_id === variables.conversationId, + ); + + if (!existingConversation) { + optimisticId = "optimistic-" + Date.now() + Math.random(); + return { + ...oldData, + conversations: [ + ...oldData.conversations, + { + conversation_id: variables.conversationId, + conversation_participant_name: t`Loading...`, + locked: false, + token_usage: 0, + optimisticId, + }, + ], + }; + } + } + + // If auto_select_bool is provided, update it + if (variables.auto_select_bool !== undefined) { + return { + ...oldData, + auto_select_bool: variables.auto_select_bool, + }; + } + + return oldData; + }, + ); + + // Return a context object with the snapshotted value + return { + previousChatContext, + optimisticId, + conversationId: variables.conversationId ?? undefined, + }; + }, + onError: (error, variables, context) => { + Sentry.captureException(error); + + // Only rollback the failed optimistic entry + if (context?.optimisticId && context?.conversationId) { + queryClient.setQueryData( + ["chats", "context", variables.chatId], + (oldData: TProjectChatContext | undefined) => { + if (!oldData) return oldData; + return { + ...oldData, + conversations: oldData.conversations.filter( + (conv) => + conv.conversation_id !== context.conversationId || + conv.optimisticId !== context.optimisticId, + ), + }; + }, + ); + } else if (context?.previousChatContext) { + // fallback: full rollback + queryClient.setQueryData( + ["chats", "context", variables.chatId], + context.previousChatContext, + ); + } + + if (error instanceof AxiosError) { + let errorMessage = t`Failed to add conversation to chat${ + error.response?.data?.detail ? `: ${error.response.data.detail}` : "" + }`; + if (variables.auto_select_bool) { + errorMessage = t`Failed to enable Auto Select for this chat`; + } + toast.error(errorMessage); + } else { + let errorMessage = t`Failed to add conversation to chat`; + if (variables.auto_select_bool) { + errorMessage = t`Failed to enable Auto Select for this chat`; + } + toast.error(errorMessage); + } + }, + onSettled: (_, __, variables) => { + queryClient.invalidateQueries({ + queryKey: ["chats", "context", variables.chatId], + }); + }, + onSuccess: (_, variables) => { + const message = variables.auto_select_bool + ? t`Auto-select enabled` + : t`Conversation added to chat`; + toast.success(message); + }, + }); }; export const useDeleteChatContextMutation = () => { - const queryClient = useQueryClient(); - return useMutation({ - mutationFn: (payload: { - chatId: string; - conversationId?: string; - auto_select_bool?: boolean; - }) => - deleteChatContext( - payload.chatId, - payload.conversationId, - payload.auto_select_bool, - ), - onMutate: async (variables) => { - // Cancel any outgoing refetches - await queryClient.cancelQueries({ - queryKey: ["chats", "context", variables.chatId], - }); - - // Snapshot the previous value - const previousChatContext = queryClient.getQueryData([ - "chats", - "context", - variables.chatId, - ]); - - // Optimistically update the chat context - queryClient.setQueryData( - ["chats", "context", variables.chatId], - (oldData: TProjectChatContext | undefined) => { - if (!oldData) return oldData; - - // If conversationId is provided, remove it from the conversations array - if (variables.conversationId) { - const conversationToRemove = oldData.conversations.find( - (conv) => conv.conversation_id === variables.conversationId, - ); - - if (conversationToRemove) { - return { - ...oldData, - conversations: oldData.conversations.filter( - (conv) => conv.conversation_id !== variables.conversationId, - ), - }; - } - } - - // If auto_select_bool is provided, update it - if (variables.auto_select_bool !== undefined) { - return { - ...oldData, - auto_select_bool: variables.auto_select_bool, - }; - } - - return oldData; - }, - ); - - // Return a context object with the snapshotted value - return { - previousChatContext, - conversationId: variables.conversationId ?? undefined, - }; - }, - onError: (error, variables, context) => { - Sentry.captureException(error); - - // Only rollback the failed optimistic entry - if (context?.conversationId) { - queryClient.setQueryData( - ["chats", "context", variables.chatId], - (oldData: TProjectChatContext | undefined) => { - if (!oldData) return oldData; - - // Find the conversation that was removed optimistically - const previousContext = context.previousChatContext as - | TProjectChatContext - | undefined; - const removedConversation = previousContext?.conversations?.find( - (conv) => conv.conversation_id === context.conversationId, - ); - - if (removedConversation) { - return { - ...oldData, - conversations: [ - ...oldData.conversations, - { - ...removedConversation, - }, - ], - }; - } - - return oldData; - }, - ); - } else if (context?.previousChatContext) { - // fallback: full rollback - queryClient.setQueryData( - ["chats", "context", variables.chatId], - context.previousChatContext, - ); - } - - if (error instanceof AxiosError) { - let errorMessage = t`Failed to remove conversation from chat${ - error.response?.data?.detail ? `: ${error.response.data.detail}` : "" - }`; - if (variables.auto_select_bool === false) { - errorMessage = t`Failed to disable Auto Select for this chat`; - } - toast.error(errorMessage); - } else { - let errorMessage = t`Failed to remove conversation from chat`; - if (variables.auto_select_bool === false) { - errorMessage = t`Failed to disable Auto Select for this chat`; - } - toast.error(errorMessage); - } - }, - onSettled: (_, __, variables) => { - queryClient.invalidateQueries({ - queryKey: ["chats", "context", variables.chatId], - }); - }, - onSuccess: (_, variables) => { - const message = - variables.auto_select_bool === false - ? t`Auto-select disabled` - : t`Conversation removed from chat`; - toast.success(message); - }, - }); + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (payload: { + chatId: string; + conversationId?: string; + auto_select_bool?: boolean; + }) => + deleteChatContext( + payload.chatId, + payload.conversationId, + payload.auto_select_bool, + ), + onMutate: async (variables) => { + // Cancel any outgoing refetches + await queryClient.cancelQueries({ + queryKey: ["chats", "context", variables.chatId], + }); + + // Snapshot the previous value + const previousChatContext = queryClient.getQueryData([ + "chats", + "context", + variables.chatId, + ]); + + // Optimistically update the chat context + queryClient.setQueryData( + ["chats", "context", variables.chatId], + (oldData: TProjectChatContext | undefined) => { + if (!oldData) return oldData; + + // If conversationId is provided, remove it from the conversations array + if (variables.conversationId) { + const conversationToRemove = oldData.conversations.find( + (conv) => conv.conversation_id === variables.conversationId, + ); + + if (conversationToRemove) { + return { + ...oldData, + conversations: oldData.conversations.filter( + (conv) => conv.conversation_id !== variables.conversationId, + ), + }; + } + } + + // If auto_select_bool is provided, update it + if (variables.auto_select_bool !== undefined) { + return { + ...oldData, + auto_select_bool: variables.auto_select_bool, + }; + } + + return oldData; + }, + ); + + // Return a context object with the snapshotted value + return { + previousChatContext, + conversationId: variables.conversationId ?? undefined, + }; + }, + onError: (error, variables, context) => { + Sentry.captureException(error); + + // Only rollback the failed optimistic entry + if (context?.conversationId) { + queryClient.setQueryData( + ["chats", "context", variables.chatId], + (oldData: TProjectChatContext | undefined) => { + if (!oldData) return oldData; + + // Find the conversation that was removed optimistically + const previousContext = context.previousChatContext as + | TProjectChatContext + | undefined; + const removedConversation = previousContext?.conversations?.find( + (conv) => conv.conversation_id === context.conversationId, + ); + + if (removedConversation) { + return { + ...oldData, + conversations: [ + ...oldData.conversations, + { + ...removedConversation, + }, + ], + }; + } + + return oldData; + }, + ); + } else if (context?.previousChatContext) { + // fallback: full rollback + queryClient.setQueryData( + ["chats", "context", variables.chatId], + context.previousChatContext, + ); + } + + if (error instanceof AxiosError) { + let errorMessage = t`Failed to remove conversation from chat${ + error.response?.data?.detail ? `: ${error.response.data.detail}` : "" + }`; + if (variables.auto_select_bool === false) { + errorMessage = t`Failed to disable Auto Select for this chat`; + } + toast.error(errorMessage); + } else { + let errorMessage = t`Failed to remove conversation from chat`; + if (variables.auto_select_bool === false) { + errorMessage = t`Failed to disable Auto Select for this chat`; + } + toast.error(errorMessage); + } + }, + onSettled: (_, __, variables) => { + queryClient.invalidateQueries({ + queryKey: ["chats", "context", variables.chatId], + }); + }, + onSuccess: (_, variables) => { + const message = + variables.auto_select_bool === false + ? t`Auto-select disabled` + : t`Conversation removed from chat`; + toast.success(message); + }, + }); }; export const useConversationChunkContentUrl = ( - conversationId: string, - chunkId: string, - enabled: boolean = true, + conversationId: string, + chunkId: string, + enabled: boolean = true, ) => { - return useQuery({ - queryKey: ["conversation", conversationId, "chunk", chunkId, "audio-url"], - queryFn: async () => { - const url = getConversationChunkContentLink( - conversationId, - chunkId, - true, - ); - return apiNoAuth.get(url); - }, - enabled, - staleTime: 1000 * 60 * 30, // 30 minutes - gcTime: 1000 * 60 * 60, // 1 hour - }); + return useQuery({ + queryKey: ["conversation", conversationId, "chunk", chunkId, "audio-url"], + queryFn: async () => { + const url = getConversationChunkContentLink( + conversationId, + chunkId, + true, + ); + return apiNoAuth.get(url); + }, + enabled, + staleTime: 1000 * 60 * 30, // 30 minutes + gcTime: 1000 * 60 * 60, // 1 hour + }); }; export const useConversationContentUrl = ( - conversationId: string, - enabled: boolean = true, + conversationId: string, + enabled: boolean = true, ) => { - return useQuery({ - queryKey: ["conversation", conversationId, "merged-audio-url"], - queryFn: async () => { - const url = getConversationContentLink(conversationId, false); - return url - }, - enabled, - staleTime: 1000 * 60 * 30, // 30 minutes - gcTime: 1000 * 60 * 60, // 1 hour - }); + return useQuery({ + queryKey: ["conversation", conversationId, "merged-audio-url"], + queryFn: async () => { + const url = getConversationContentLink(conversationId, true); + return url; + }, + enabled, + staleTime: 1000 * 60 * 30, // 30 minutes + gcTime: 1000 * 60 * 60, // 1 hour + }); }; export const useRetranscribeConversationMutation = () => { - const queryClient = useQueryClient(); - - return useMutation({ - mutationFn: ({ - conversationId, - newConversationName, - }: { - conversationId: string; - newConversationName: string; - }) => retranscribeConversation(conversationId, newConversationName), - onSuccess: (_data) => { - // Invalidate all conversation related queries - queryClient.invalidateQueries({ - queryKey: ["conversations"], - }); - - // Toast success message - toast.success( - t`Retranscription started. New conversation will be available soon.`, - ); - }, - onError: (error) => { - toast.error(t`Failed to retranscribe conversation. Please try again.`); - console.error("Retranscribe error:", error); - }, - }); + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ + conversationId, + newConversationName, + usePiiRedaction, + }: { + conversationId: string; + newConversationName: string; + usePiiRedaction: boolean; + }) => + retranscribeConversation( + conversationId, + newConversationName, + usePiiRedaction, + ), + onSuccess: (_data) => { + // Invalidate all conversation related queries + queryClient.invalidateQueries({ + queryKey: ["conversations"], + }); + }, + onError: (error) => { + toast.error(t`Failed to retranscribe conversation. Please try again.`); + console.error("Retranscribe error:", error); + }, + }); }; -export const useConversationTranscriptString = (conversationId: string) => { - return useQuery({ - queryKey: ["conversations", conversationId, "transcript"], - queryFn: () => getConversationTranscriptString(conversationId), - }); +export const useGetConversationTranscriptStringMutation = () => { + return useMutation({ + mutationFn: (conversationId: string) => + getConversationTranscriptString(conversationId), + }); }; export const useConversationChunks = ( - conversationId: string, - refetchInterval: number = 10000, - fields: string[] = ["id"], + conversationId: string, + refetchInterval: number = 10000, + fields: string[] = ["id"], ) => { - return useQuery({ - queryKey: ["conversations", conversationId, "chunks"], - queryFn: () => - directus.request( - readItems("conversation_chunk", { - filter: { - conversation_id: { - _eq: conversationId, - }, - }, - fields: fields as any, - sort: "timestamp", - }), - ), - refetchInterval, - }); + return useQuery({ + queryKey: ["conversations", conversationId, "chunks"], + queryFn: () => + directus.request( + readItems("conversation_chunk", { + filter: { + conversation_id: { + _eq: conversationId, + }, + }, + fields: fields as any, + sort: "timestamp", + }), + ), + refetchInterval, + }); }; export const useConversationsByProjectId = ( - projectId: string, - loadChunks?: boolean, - // unused - loadWhereTranscriptExists?: boolean, - query?: Partial>, - filterBySource?: string[], + projectId: string, + loadChunks?: boolean, + // unused + loadWhereTranscriptExists?: boolean, + query?: Partial>, + filterBySource?: string[], ) => { - const TIME_INTERVAL_SECONDS = 40; - - return useQuery({ - queryKey: [ - "projects", - projectId, - "conversations", - loadChunks ? "chunks" : "no-chunks", - loadWhereTranscriptExists ? "transcript" : "no-transcript", - query, - filterBySource, - ], - queryFn: async () => { - const conversations = await directus.request( - readItems("conversation", { - sort: "-updated_at", - fields: [ - ...CONVERSATION_FIELDS_WITHOUT_PROCESSING_STATUS, - { - tags: [ - { - project_tag_id: ["id", "text", "created_at"], - }, - ], - }, - { chunks: ["*"] }, - ], - deep: { - // @ts-expect-error chunks is not typed - chunks: { - _limit: loadChunks ? 1000 : 1, - }, - }, - filter: { - project_id: { - _eq: projectId, - }, - chunks: { - ...(loadWhereTranscriptExists && { - _some: { - transcript: { - _nempty: true, - }, - }, - }), - }, - ...(filterBySource && { - source: { - _in: filterBySource, - }, - }), - }, - limit: 1000, - ...query, - }), - ); - - return conversations; - }, - select: (data) => { - // Add live field to each conversation based on recent chunk activity - const cutoffTime = new Date(Date.now() - TIME_INTERVAL_SECONDS * 1000); - - if (data.length === 0) return []; - - return data.map((conversation) => { - // Skip upload chunks - if (["upload", "clone"].includes(conversation.source ?? "")) - return { - ...conversation, - live: false, - }; - - if (conversation.chunks?.length === 0) - return { - ...conversation, - live: false, - }; - - const hasRecentChunks = conversation.chunks?.some((chunk: any) => { - // Check if chunk timestamp is recent - const chunkTime = new Date(chunk.timestamp || chunk.created_at || 0); - return chunkTime > cutoffTime; - }); - - return { - ...conversation, - live: hasRecentChunks || false, - }; - }); - }, - refetchInterval: 30000, - }); + const TIME_INTERVAL_SECONDS = 40; + + return useQuery({ + queryKey: [ + "projects", + projectId, + "conversations", + loadChunks ? "chunks" : "no-chunks", + loadWhereTranscriptExists ? "transcript" : "no-transcript", + query, + filterBySource, + ], + queryFn: async () => { + const conversations = await directus.request( + readItems("conversation", { + sort: "-updated_at", + fields: [ + ...CONVERSATION_FIELDS_WITHOUT_PROCESSING_STATUS, + { + tags: [ + { + project_tag_id: ["id", "text", "created_at"], + }, + ], + }, + { chunks: ["*"] }, + ], + deep: { + // @ts-expect-error chunks is not typed + chunks: { + _limit: loadChunks ? 1000 : 1, + }, + }, + filter: { + project_id: { + _eq: projectId, + }, + chunks: { + ...(loadWhereTranscriptExists && { + _some: { + transcript: { + _nempty: true, + }, + }, + }), + }, + ...(filterBySource && { + source: { + _in: filterBySource, + }, + }), + }, + limit: 1000, + ...query, + }), + ); + + return conversations; + }, + select: (data) => { + // Add live field to each conversation based on recent chunk activity + const cutoffTime = new Date(Date.now() - TIME_INTERVAL_SECONDS * 1000); + + if (data.length === 0) return []; + + return data.map((conversation) => { + // Skip upload chunks + if (["upload", "clone"].includes(conversation.source ?? "")) + return { + ...conversation, + live: false, + }; + + if (conversation.chunks?.length === 0) + return { + ...conversation, + live: false, + }; + + const hasRecentChunks = conversation.chunks?.some((chunk: any) => { + // Check if chunk timestamp is recent + const chunkTime = new Date(chunk.timestamp || chunk.created_at || 0); + return chunkTime > cutoffTime; + }); + + return { + ...conversation, + live: hasRecentChunks || false, + }; + }); + }, + refetchInterval: 30000, + }); }; export const CONVERSATION_FIELDS_WITHOUT_PROCESSING_STATUS: QueryFields< - CustomDirectusTypes, - Conversation + CustomDirectusTypes, + Conversation > = [ - "id", - "created_at", - "updated_at", - "project_id", - "participant_name", - "participant_email", - "participant_user_agent", - "tags", - "summary", - "source", - "chunks", - "project_chats", - "project_chat_messages", - "replies", - "conversation_segments", - "duration", - "merged_transcript", - "merged_audio_path", - "is_finished", - "is_audio_processing_finished", - "is_all_chunks_transcribed", - "linked_conversations", - "linking_conversations", + "id", + "created_at", + "updated_at", + "project_id", + "participant_name", + "participant_email", + "participant_user_agent", + "tags", + "summary", + "source", + "chunks", + "project_chats", + "project_chat_messages", + "replies", + "conversation_segments", + "duration", + "merged_transcript", + "merged_audio_path", + "is_finished", + "is_audio_processing_finished", + "is_all_chunks_transcribed", + "linked_conversations", + "linking_conversations", ]; export const useConversationById = ({ - conversationId, - loadConversationChunks = false, - query = {}, - useQueryOpts = { - refetchInterval: 10000, - }, + conversationId, + loadConversationChunks = false, + query = {}, + useQueryOpts = { + refetchInterval: 10000, + }, }: { - conversationId: string; - loadConversationChunks?: boolean; - // query overrides the default query and loadChunks - query?: Partial>; - useQueryOpts?: Partial>; + conversationId: string; + loadConversationChunks?: boolean; + // query overrides the default query and loadChunks + query?: Partial>; + useQueryOpts?: Partial>; }) => { - return useQuery({ - queryKey: ["conversations", conversationId, loadConversationChunks, query], - queryFn: () => - directus.request( - readItem("conversation", conversationId, { - fields: [ - ...CONVERSATION_FIELDS_WITHOUT_PROCESSING_STATUS, - { - linking_conversations: [ - "id", - { - source_conversation_id: ["id", "participant_name"], - }, - "link_type", - ], - }, - { - linked_conversations: [ - "id", - { - target_conversation_id: ["id", "participant_name"], - }, - "link_type", - ], - }, - { - tags: [ - { - project_tag_id: ["id", "text", "created_at"], - }, - ], - }, - ...(loadConversationChunks ? [{ chunks: ["*"] as any }] : []), - ], - ...query, - }), - ), - ...useQueryOpts, - }); + return useQuery({ + queryKey: ["conversations", conversationId, loadConversationChunks, query], + queryFn: () => + directus.request( + readItem("conversation", conversationId, { + fields: [ + ...CONVERSATION_FIELDS_WITHOUT_PROCESSING_STATUS, + { + linking_conversations: [ + "id", + { + source_conversation_id: ["id", "participant_name"], + }, + "link_type", + ], + }, + { + linked_conversations: [ + "id", + { + target_conversation_id: ["id", "participant_name"], + }, + "link_type", + ], + }, + { + tags: [ + { + project_tag_id: ["id", "text", "created_at"], + }, + ], + }, + ...(loadConversationChunks ? [{ chunks: ["*"] as any }] : []), + ], + ...query, + }), + ), + ...useQueryOpts, + }); }; export const useInfiniteConversationsByProjectId = ( - projectId: string, - loadChunks?: boolean, - // unused - loadWhereTranscriptExists?: boolean, - query?: Partial>, - filterBySource?: string[], - options?: { - initialLimit?: number; - }, + projectId: string, + loadChunks?: boolean, + // unused + loadWhereTranscriptExists?: boolean, + query?: Partial>, + filterBySource?: string[], + options?: { + initialLimit?: number; + }, ) => { - const { initialLimit = 15 } = options ?? {}; - const TIME_INTERVAL_SECONDS = 40; - - return useInfiniteQuery({ - queryKey: [ - "projects", - projectId, - "conversations", - "infinite", - loadChunks ? "chunks" : "no-chunks", - loadWhereTranscriptExists ? "transcript" : "no-transcript", - query, - filterBySource, - ], - queryFn: async ({ pageParam = 0 }) => { - const conversations = await directus.request( - readItems("conversation", { - sort: "-updated_at", - fields: [ - ...CONVERSATION_FIELDS_WITHOUT_PROCESSING_STATUS, - { - tags: [ - { - project_tag_id: ["id", "text", "created_at"], - }, - ], - }, - { chunks: ["*"] }, - ], - deep: { - // @ts-expect-error chunks is not typed - chunks: { - _limit: loadChunks ? 1000 : 1, - }, - }, - filter: { - project_id: { - _eq: projectId, - }, - chunks: { - ...(loadWhereTranscriptExists && { - _some: { - transcript: { - _nempty: true, - }, - }, - }), - }, - ...(filterBySource && { - source: { - _in: filterBySource, - }, - }), - }, - limit: initialLimit, - offset: pageParam * initialLimit, - ...query, - }), - ); - - return { - conversations: conversations, - nextOffset: - conversations.length === initialLimit ? pageParam + 1 : undefined, - }; - }, - select: (data) => { - // Add live field to each conversation based on recent chunk activity - const cutoffTime = new Date(Date.now() - TIME_INTERVAL_SECONDS * 1000); - - return { - ...data, - pages: data.pages.map((page) => ({ - ...page, - conversations: page.conversations.map((conversation) => { - // Skip upload chunks - if (["upload", "clone"].includes(conversation.source ?? "")) - return { - ...conversation, - live: false, - }; - - if (conversation.chunks?.length === 0) - return { - ...conversation, - live: false, - }; - - const hasRecentChunks = conversation.chunks?.some((chunk: any) => { - // Check if chunk timestamp is recent - const chunkTime = new Date( - chunk.timestamp || chunk.created_at || 0, - ); - return chunkTime > cutoffTime; - }); - - return { - ...conversation, - live: hasRecentChunks || false, - }; - }), - })), - }; - }, - initialPageParam: 0, - getNextPageParam: (lastPage) => lastPage.nextOffset, - refetchInterval: 30000, - }); + const { initialLimit = 15 } = options ?? {}; + const TIME_INTERVAL_SECONDS = 40; + + return useInfiniteQuery({ + queryKey: [ + "projects", + projectId, + "conversations", + "infinite", + loadChunks ? "chunks" : "no-chunks", + loadWhereTranscriptExists ? "transcript" : "no-transcript", + query, + filterBySource, + ], + queryFn: async ({ pageParam = 0 }) => { + const conversations = await directus.request( + readItems("conversation", { + sort: "-updated_at", + fields: [ + ...CONVERSATION_FIELDS_WITHOUT_PROCESSING_STATUS, + { + tags: [ + { + project_tag_id: ["id", "text", "created_at"], + }, + ], + }, + { chunks: ["*"] }, + ], + deep: { + // @ts-expect-error chunks is not typed + chunks: { + _limit: loadChunks ? 1000 : 1, + }, + }, + filter: { + project_id: { + _eq: projectId, + }, + chunks: { + ...(loadWhereTranscriptExists && { + _some: { + transcript: { + _nempty: true, + }, + }, + }), + }, + ...(filterBySource && { + source: { + _in: filterBySource, + }, + }), + }, + limit: initialLimit, + offset: pageParam * initialLimit, + ...query, + }), + ); + + return { + conversations: conversations, + nextOffset: + conversations.length === initialLimit ? pageParam + 1 : undefined, + }; + }, + select: (data) => { + // Add live field to each conversation based on recent chunk activity + const cutoffTime = new Date(Date.now() - TIME_INTERVAL_SECONDS * 1000); + + return { + ...data, + pages: data.pages.map((page) => ({ + ...page, + conversations: page.conversations.map((conversation) => { + // Skip upload chunks + if (["upload", "clone"].includes(conversation.source ?? "")) + return { + ...conversation, + live: false, + }; + + if (conversation.chunks?.length === 0) + return { + ...conversation, + live: false, + }; + + const hasRecentChunks = conversation.chunks?.some((chunk: any) => { + // Check if chunk timestamp is recent + const chunkTime = new Date( + chunk.timestamp || chunk.created_at || 0, + ); + return chunkTime > cutoffTime; + }); + + return { + ...conversation, + live: hasRecentChunks || false, + }; + }), + })), + }; + }, + initialPageParam: 0, + getNextPageParam: (lastPage) => lastPage.nextOffset, + refetchInterval: 30000, + }); }; export const useConversationsCountByProjectId = ( - projectId: string, - query?: Partial>, + projectId: string, + query?: Partial>, ) => { - return useQuery({ - queryKey: ["projects", projectId, "conversations", "count", query], - queryFn: async () => { - const response = await directus.request( - aggregate("conversation", { - aggregate: { - count: "*", - }, - query: { - filter: { - project_id: { - _eq: projectId, - }, - ...(query?.filter && query.filter), - }, - }, - }), - ); - return response[0].count; - }, - }); + return useQuery({ + queryKey: ["projects", projectId, "conversations", "count", query], + queryFn: async () => { + const response = await directus.request( + aggregate("conversation", { + aggregate: { + count: "*", + }, + query: { + filter: { + project_id: { + _eq: projectId, + }, + ...(query?.filter && query.filter), + }, + }, + }), + ); + return response[0].count; + }, + }); }; diff --git a/echo/frontend/src/components/project/ProjectBasicEdit.tsx b/echo/frontend/src/components/project/ProjectBasicEdit.tsx index 89ef2c7b..970c773a 100644 --- a/echo/frontend/src/components/project/ProjectBasicEdit.tsx +++ b/echo/frontend/src/components/project/ProjectBasicEdit.tsx @@ -1,17 +1,7 @@ import { t } from "@lingui/core/macro"; import { Trans } from "@lingui/react/macro"; import React, { useEffect } from "react"; -import { - Group, - Stack, - Text, - TextInput, - Textarea, - Title, - Switch, - Divider, - Badge, -} from "@mantine/core"; +import { Stack, TextInput, Textarea } from "@mantine/core"; import { useForm, Controller } from "react-hook-form"; import { useUpdateProjectByIdMutation } from "./hooks"; import { SaveStatus } from "../form/SaveStatus"; @@ -19,7 +9,7 @@ import { FormLabel } from "../form/FormLabel"; import { useAutoSave } from "@/hooks/useAutoSave"; import { z } from "zod"; import { zodResolver } from "@hookform/resolvers/zod"; -import { Logo } from "../common/Logo"; +import { ProjectSettingsSection } from "./ProjectSettingsSection"; type ProjectBasicEditProps = { project: Project; @@ -84,70 +74,66 @@ type TFormSchema = z.infer; }, [watch, dispatchAutoSave, trigger]); return ( - - - - - <Trans>Edit Project</Trans> - - Edit Project} + headerRight={ + + } + > +
{ + await triggerManualSave(values); + })} + > + + ( + + } + {...field} + /> + )} /> - - - { - await triggerManualSave(values); - })} - > - - ( - - } - {...field} - /> - )} - /> - ( -