From 81e89657b85e5c7824288110b658762973cefff7 Mon Sep 17 00:00:00 2001 From: Mateusz Baginski Date: Sun, 1 Dec 2024 13:25:05 +0100 Subject: [PATCH 01/10] feat(chat): connect favorite chats to backend, add star toggling --- apps/chat/src/i18n/packs/i18n-lang-en.ts | 3 + apps/chat/src/i18n/packs/i18n-lang-pl.ts | 3 + apps/chat/src/modules/apps/favorite/index.ts | 1 + .../apps/favorite/use-favorite-apps.tsx | 34 ++++++++ apps/chat/src/modules/apps/grid/app-card.tsx | 19 ++++- .../src/modules/apps/grid/apps-container.tsx | 80 +++++++++++++++++++ .../modules/apps/grid/apps-placeholder.tsx | 10 +++ apps/chat/src/modules/apps/grid/index.ts | 2 + .../chats/grid/chat-history-placeholder.tsx | 11 +-- .../src/modules/shared/ghost-placeholder.tsx | 15 ++++ apps/chat/src/modules/shared/index.ts | 1 + apps/chat/src/routes/apps/apps.route.tsx | 79 +----------------- .../src/hooks/use-sync-storage-object.ts | 21 ++++- packages/ui/src/components/tutorial-box.tsx | 8 +- 14 files changed, 189 insertions(+), 98 deletions(-) create mode 100644 apps/chat/src/modules/apps/favorite/index.ts create mode 100644 apps/chat/src/modules/apps/favorite/use-favorite-apps.tsx create mode 100644 apps/chat/src/modules/apps/grid/apps-container.tsx create mode 100644 apps/chat/src/modules/apps/grid/apps-placeholder.tsx create mode 100644 apps/chat/src/modules/shared/ghost-placeholder.tsx create mode 100644 apps/chat/src/modules/shared/index.ts diff --git a/apps/chat/src/i18n/packs/i18n-lang-en.ts b/apps/chat/src/i18n/packs/i18n-lang-en.ts index f9580e87..59cef339 100644 --- a/apps/chat/src/i18n/packs/i18n-lang-en.ts +++ b/apps/chat/src/i18n/packs/i18n-lang-en.ts @@ -222,6 +222,9 @@ export const I18N_PACK_EN = deepmerge(I18N_FORWARDED_EN_PACK, { add: 'Add to favorites', remove: 'Remove from favorites', }, + grid: { + placeholder: 'No apps yet. Stay tuned!', + }, }, experts: { favorites: { diff --git a/apps/chat/src/i18n/packs/i18n-lang-pl.ts b/apps/chat/src/i18n/packs/i18n-lang-pl.ts index 344b9dc9..142779ca 100644 --- a/apps/chat/src/i18n/packs/i18n-lang-pl.ts +++ b/apps/chat/src/i18n/packs/i18n-lang-pl.ts @@ -224,6 +224,9 @@ export const I18N_PACK_PL: I18nLangPack = deepmerge(I18N_FORWARDED_PL_PACK, { add: 'Dodaj do ulubionych', remove: 'Usuń z ulubionych', }, + grid: { + placeholder: 'Brak aplikacji!', + }, }, experts: { favorites: { diff --git a/apps/chat/src/modules/apps/favorite/index.ts b/apps/chat/src/modules/apps/favorite/index.ts new file mode 100644 index 00000000..6519963a --- /dev/null +++ b/apps/chat/src/modules/apps/favorite/index.ts @@ -0,0 +1 @@ +export * from './use-favorite-apps'; diff --git a/apps/chat/src/modules/apps/favorite/use-favorite-apps.tsx b/apps/chat/src/modules/apps/favorite/use-favorite-apps.tsx new file mode 100644 index 00000000..9edf6963 --- /dev/null +++ b/apps/chat/src/modules/apps/favorite/use-favorite-apps.tsx @@ -0,0 +1,34 @@ +import { useMemo } from 'react'; +import { z } from 'zod'; + +import { without } from '@llm/commons'; +import { useLocalStorageObject } from '@llm/commons-front'; +import { SdkTableRowIdV, type SdkTableRowWithIdT } from '@llm/sdk'; + +export function useFavoriteApps() { + const storage = useLocalStorageObject('favorite-apps', { + readBeforeMount: true, + rerenderOnSet: true, + schema: z.array(SdkTableRowIdV).catch([]), + }); + + const appsIds = useMemo(() => storage.getOrNull() || [], [storage.revision]); + + const isFavorite = (app: SdkTableRowWithIdT) => appsIds.includes(app.id); + + const toggle = (app: SdkTableRowWithIdT) => { + const appsWithoutApp = without([app.id])(appsIds); + + if (!isFavorite(app)) { + appsWithoutApp.push(app.id); + } + + storage.set(appsWithoutApp); + }; + + return { + ids: appsIds, + isFavorite, + toggle, + }; +} diff --git a/apps/chat/src/modules/apps/grid/app-card.tsx b/apps/chat/src/modules/apps/grid/app-card.tsx index b313a0d8..40729c06 100644 --- a/apps/chat/src/modules/apps/grid/app-card.tsx +++ b/apps/chat/src/modules/apps/grid/app-card.tsx @@ -1,3 +1,4 @@ +import clsx from 'clsx'; import { ExternalLinkIcon, StarIcon, WandSparklesIcon } from 'lucide-react'; import type { SdkAppT } from '@llm/sdk'; @@ -5,23 +6,33 @@ import type { SdkAppT } from '@llm/sdk'; import { formatDate } from '@llm/commons'; import { useI18n } from '~/i18n'; +import { useFavoriteApps } from '../favorite'; + type AppCardProps = { app: SdkAppT; }; export function AppCard({ app }: AppCardProps) { const t = useI18n().pack; - const favorite = app.name.includes('Analyzer'); + const { isFavorite, toggle } = useFavoriteApps(); + + const favorite = isFavorite(app); return (
+ {!app.archived && ( + + )}
diff --git a/packages/commons-front/src/hooks/use-sync-storage-object.ts b/packages/commons-front/src/hooks/use-sync-storage-object.ts index e7a9a7d7..590e7169 100644 --- a/packages/commons-front/src/hooks/use-sync-storage-object.ts +++ b/packages/commons-front/src/hooks/use-sync-storage-object.ts @@ -6,9 +6,9 @@ import * as O from 'fp-ts/lib/Option'; import { useRef } from 'react'; import { tryParseJSON, tryParseUsingZodSchema } from '@llm/commons'; -import { useWindowListener } from '@llm/commons-front'; import { useForceRerender } from './use-force-rerender'; +import { useWindowListener } from './use-window-listener'; type AbstractSyncStorage = { removeItem: (key: string) => void; From ea7816aeef4f26684a890c3e5849bb397dc5fdde Mon Sep 17 00:00:00 2001 From: Mateusz Baginski Date: Sun, 1 Dec 2024 14:18:02 +0100 Subject: [PATCH 03/10] feat(chat): favorite filters tabs --- .../apps/elasticsearch/apps-es-search.repo.ts | 2 + .../apps/favorite/use-favorite-apps.tsx | 3 + .../src/modules/apps/grid/apps-container.tsx | 65 +++++++++++++++-- .../dashboard/apps/dto/sdk-search-apps.dto.ts | 2 + .../src/shared/dto/sdk-search-filters.dto.ts | 4 ++ .../predefined/tabs/archive-filter-tabs.tsx | 16 +++-- .../predefined/tabs/favorite-filter-tabs.tsx | 71 +++++++++++++++++++ .../src/components/predefined/tabs/index.ts | 1 + packages/ui/src/components/tabs/tabs.tsx | 27 +++++-- .../src/i18n/packs/i18n-forwarded-en-pack.tsx | 5 ++ .../src/i18n/packs/i18n-forwarded-pl-pack.tsx | 5 ++ 11 files changed, 185 insertions(+), 16 deletions(-) create mode 100644 packages/ui/src/components/predefined/tabs/favorite-filter-tabs.tsx diff --git a/apps/backend/src/modules/apps/elasticsearch/apps-es-search.repo.ts b/apps/backend/src/modules/apps/elasticsearch/apps-es-search.repo.ts index 090080cf..5350e347 100644 --- a/apps/backend/src/modules/apps/elasticsearch/apps-es-search.repo.ts +++ b/apps/backend/src/modules/apps/elasticsearch/apps-es-search.repo.ts @@ -50,6 +50,7 @@ export class AppsEsSearchRepo { { phrase, ids, + excludeIds, organizationIds, archived, }: SdKSearchAppsInputT, @@ -57,6 +58,7 @@ export class AppsEsSearchRepo { esb.boolQuery().must( rejectFalsyItems([ !!ids?.length && esb.termsQuery('id', ids), + !!excludeIds?.length && esb.boolQuery().mustNot(esb.termsQuery('id', excludeIds)), !!organizationIds?.length && esb.termsQuery('organization.id', organizationIds), !!phrase && ( esb diff --git a/apps/chat/src/modules/apps/favorite/use-favorite-apps.tsx b/apps/chat/src/modules/apps/favorite/use-favorite-apps.tsx index 9edf6963..178f84e9 100644 --- a/apps/chat/src/modules/apps/favorite/use-favorite-apps.tsx +++ b/apps/chat/src/modules/apps/favorite/use-favorite-apps.tsx @@ -13,6 +13,7 @@ export function useFavoriteApps() { }); const appsIds = useMemo(() => storage.getOrNull() || [], [storage.revision]); + const hasFavorites = appsIds.length > 0; const isFavorite = (app: SdkTableRowWithIdT) => appsIds.includes(app.id); @@ -27,8 +28,10 @@ export function useFavoriteApps() { }; return { + total: appsIds.length, ids: appsIds, isFavorite, + hasFavorites, toggle, }; } diff --git a/apps/chat/src/modules/apps/grid/apps-container.tsx b/apps/chat/src/modules/apps/grid/apps-container.tsx index f7858255..7035c2de 100644 --- a/apps/chat/src/modules/apps/grid/apps-container.tsx +++ b/apps/chat/src/modules/apps/grid/apps-container.tsx @@ -1,9 +1,12 @@ +import { useMemo } from 'react'; + import { SdKSearchAppsInputV, useSdkForLoggedIn, } from '@llm/sdk'; import { ArchiveFilterTabs, + FavoriteFiltersTabs, PaginatedList, PaginationSearchToolbarItem, PaginationToolbar, @@ -11,22 +14,24 @@ import { } from '@llm/ui'; import { useWorkspaceOrganizationOrThrow } from '~/modules/workspace'; +import { useFavoriteApps } from '../favorite'; import { AppCard } from './app-card'; import { AppsPlaceholder } from './apps-placeholder'; -type Props = { - storeDataInUrl?: boolean; -}; - -export function AppsContainer({ storeDataInUrl = false }: Props) { +export function AppsContainer() { + const favorites = useFavoriteApps(); const { organization } = useWorkspaceOrganizationOrThrow(); const { sdks } = useSdkForLoggedIn(); const { loading, pagination, result } = useDebouncedPaginatedSearch({ - storeDataInUrl, + storeDataInUrl: false, schema: SdKSearchAppsInputV, fallbackSearchParams: { limit: 12, + + ...favorites.hasFavorites && { + ids: [...favorites.ids], + }, }, fetchResultsTask: filters => sdks.dashboard.apps.search({ ...filters, @@ -34,12 +39,58 @@ export function AppsContainer({ storeDataInUrl = false }: Props) { }), }); + const favoritesFilter = useMemo( + () => { + if (!favorites.hasFavorites) { + return null; + } + + if (favorites.ids.every(id => pagination.value.ids?.includes(id))) { + return true; + } + + if (favorites.ids.every(id => pagination.value.excludeIds?.includes(id))) { + return false; + } + + return null; + }, + [favorites.ids, pagination.value], + ); + + const onToggleFavoriteFilter = (value: boolean | null) => { + pagination.setValue({ + merge: true, + value: { + ids: undefined, + excludeIds: undefined, + ...value && { + ids: favorites.ids, + }, + ...value === false && { + excludeIds: favorites.ids, + }, + }, + }); + }; + return (
+ <> + + + + )} > ; diff --git a/packages/sdk/src/shared/dto/sdk-search-filters.dto.ts b/packages/sdk/src/shared/dto/sdk-search-filters.dto.ts index 033a3e2a..fe0a1970 100644 --- a/packages/sdk/src/shared/dto/sdk-search-filters.dto.ts +++ b/packages/sdk/src/shared/dto/sdk-search-filters.dto.ts @@ -13,6 +13,10 @@ export const SdkIdsFiltersInputV = z.object({ ids: SdkIdsArrayV.optional(), }); +export const SdkExcludeIdsFiltersInputV = z.object({ + excludeIds: SdkIdsArrayV.optional(), +}); + export const SdkUuidsFiltersInputV = z.object({ ids: SdkUuidsArrayV.optional(), }); diff --git a/packages/ui/src/components/predefined/tabs/archive-filter-tabs.tsx b/packages/ui/src/components/predefined/tabs/archive-filter-tabs.tsx index cb02a476..40480ce7 100644 --- a/packages/ui/src/components/predefined/tabs/archive-filter-tabs.tsx +++ b/packages/ui/src/components/predefined/tabs/archive-filter-tabs.tsx @@ -1,33 +1,41 @@ import { controlled, type OmitControlStateAttrs } from '@under-control/forms'; import clsx from 'clsx'; +import { Activity, Archive, LayoutGrid } from 'lucide-react'; +import { rejectFalsyItems } from '@llm/commons'; import { Tabs, type TabsProps } from '~/components/tabs'; import { useForwardedI18n } from '~/i18n'; -type Props = Omit, 'tabs'>; +type Props = Omit, 'tabs'> & { + withAll?: boolean; +}; export const ArchiveFilterTabs = controlled(( { control: { value, setValue }, + withAll = true, className, ...props }, ) => { const t = useForwardedI18n().pack.tabs.archiveFilters; - const tabs = [ + const tabs = rejectFalsyItems([ { id: false, name: t.active, + icon: , }, { id: true, name: t.archived, + icon: , }, - { + withAll && { id: -1, name: t.all, + icon: , }, - ]; + ]); return ( , 'tabs'> & { + withAll?: boolean; + totalFavorites: number; +}; + +export const FavoriteFiltersTabs = controlled(( + { + control: { value, setValue }, + withAll = true, + totalFavorites, + className, + ...props + }, +) => { + const t = useForwardedI18n().pack.tabs.favoriteFilters; + const tabs = rejectFalsyItems([ + { + id: true, + name: ( +
+ {t.favorite} + {totalFavorites > 0 && ( + + {totalFavorites} + + )} +
+ ), + icon: , + disabled: !totalFavorites, + }, + { + id: false, + name: t.rest, + icon: , + disabled: !totalFavorites, + }, + withAll && { + id: -1, + name: t.all, + icon: , + }, + ]); + + return ( + { + setValue({ + value: newValue === -1 ? null : Boolean(newValue), + }); + }} + tabs={tabs} + /> + ); +}); diff --git a/packages/ui/src/components/predefined/tabs/index.ts b/packages/ui/src/components/predefined/tabs/index.ts index bb51e3ed..796f83ed 100644 --- a/packages/ui/src/components/predefined/tabs/index.ts +++ b/packages/ui/src/components/predefined/tabs/index.ts @@ -1 +1,2 @@ export * from './archive-filter-tabs'; +export * from './favorite-filter-tabs'; diff --git a/packages/ui/src/components/tabs/tabs.tsx b/packages/ui/src/components/tabs/tabs.tsx index c9862df9..2e99a829 100644 --- a/packages/ui/src/components/tabs/tabs.tsx +++ b/packages/ui/src/components/tabs/tabs.tsx @@ -1,3 +1,5 @@ +import type { ReactNode } from 'react'; + import { type ControlBindProps, controlled } from '@under-control/forms'; import clsx from 'clsx'; @@ -5,7 +7,9 @@ type TabId = string | number | boolean; type TabItem = { id: TabId; - name: string; + name: ReactNode; + icon?: ReactNode; + disabled?: boolean; }; export type TabsProps = ControlBindProps & { @@ -22,25 +26,38 @@ export const Tabs = controlled(( ) => { return (
    - {tabs.map(({ id, name }) => ( + {tabs.map(({ id, name, icon, disabled }) => (
  • { event.preventDefault(); - setValue({ - value: id, - }); + if (!disabled) { + setValue({ + value: id, + }); + } }} + aria-disabled={disabled} > + {icon && ( + + {icon} + + )} {name}
  • diff --git a/packages/ui/src/i18n/packs/i18n-forwarded-en-pack.tsx b/packages/ui/src/i18n/packs/i18n-forwarded-en-pack.tsx index 2a63bc72..c3b7216c 100644 --- a/packages/ui/src/i18n/packs/i18n-forwarded-en-pack.tsx +++ b/packages/ui/src/i18n/packs/i18n-forwarded-en-pack.tsx @@ -107,5 +107,10 @@ export const I18N_FORWARDED_EN_PACK = { active: 'Active', archived: 'Archived', }, + favoriteFilters: { + all: 'All', + favorite: 'Favorite', + rest: 'Rest', + }, }, }; diff --git a/packages/ui/src/i18n/packs/i18n-forwarded-pl-pack.tsx b/packages/ui/src/i18n/packs/i18n-forwarded-pl-pack.tsx index 1bf64132..418e6bf4 100644 --- a/packages/ui/src/i18n/packs/i18n-forwarded-pl-pack.tsx +++ b/packages/ui/src/i18n/packs/i18n-forwarded-pl-pack.tsx @@ -109,5 +109,10 @@ export const I18N_FORWARDED_PL_PACK: typeof I18N_FORWARDED_EN_PACK = { active: 'Aktywne', archived: 'Zarchiwizowane', }, + favoriteFilters: { + all: 'Wszystkie', + favorite: 'Ulubione', + rest: 'Reszta', + }, }, }; From 783dc548f65230d265c7586828773380ef6b0add Mon Sep 17 00:00:00 2001 From: Mateusz Baginski Date: Sun, 1 Dec 2024 15:03:17 +0100 Subject: [PATCH 04/10] feat(backend): add `app_id` relation to messages --- .../0015-add-attached-app-id-to-messages.ts | 15 +++++++++++++++ apps/backend/src/migrations/index.ts | 2 ++ 2 files changed, 17 insertions(+) create mode 100644 apps/backend/src/migrations/0015-add-attached-app-id-to-messages.ts diff --git a/apps/backend/src/migrations/0015-add-attached-app-id-to-messages.ts b/apps/backend/src/migrations/0015-add-attached-app-id-to-messages.ts new file mode 100644 index 00000000..b6439085 --- /dev/null +++ b/apps/backend/src/migrations/0015-add-attached-app-id-to-messages.ts @@ -0,0 +1,15 @@ +import type { Kysely } from 'kysely'; + +export async function up(db: Kysely): Promise { + await db.schema + .alterTable('messages') + .addColumn('app_id', 'integer', col => col.references('apps.id').onDelete('restrict')) + .execute(); +} + +export async function down(db: Kysely): Promise { + await db.schema + .alterTable('messages') + .dropColumn('app_id') + .execute(); +} diff --git a/apps/backend/src/migrations/index.ts b/apps/backend/src/migrations/index.ts index 1ba808ac..8653f78e 100644 --- a/apps/backend/src/migrations/index.ts +++ b/apps/backend/src/migrations/index.ts @@ -13,6 +13,7 @@ import * as addNameToChatSummary from './0011-add-name-to-chat-summary'; import * as addCreatorToMessagesTable from './0012-add-creator-to-messages-table'; import * as addRepliedMessageId from './0013-add-replied-message-id'; import * as addLastSummarizedMessage from './0014-add-last-summarized-message'; +import * as addAttachedAppIdToMessages from './0015-add-attached-app-id-to-messages'; export const DB_MIGRATIONS = { '0000-add-users-tables': addUsersTables, @@ -30,4 +31,5 @@ export const DB_MIGRATIONS = { '0012-add-creator-to-messages-table': addCreatorToMessagesTable, '0013-add-replied-message-id': addRepliedMessageId, '0014-add-last-summarized-message': addLastSummarizedMessage, + '0015-add-attached-app-id-to-messages': addAttachedAppIdToMessages, }; From 8ccc8426a6b543241125a1f8943a3b6f6dcfa9bc Mon Sep 17 00:00:00 2001 From: Mateusz Baginski Date: Sun, 1 Dec 2024 15:06:04 +0100 Subject: [PATCH 05/10] feat(backend): add `app` to message type --- .../elasticsearch/messages-es-index.repo.ts | 1 + apps/backend/src/modules/messages/messages.repo.ts | 13 +++++++++++++ .../backend/src/modules/messages/messages.tables.ts | 4 +++- .../dashboard/messages/dto/sdk-message.dto.ts | 1 + 4 files changed, 18 insertions(+), 1 deletion(-) diff --git a/apps/backend/src/modules/messages/elasticsearch/messages-es-index.repo.ts b/apps/backend/src/modules/messages/elasticsearch/messages-es-index.repo.ts index 2562acaa..cceb70c5 100644 --- a/apps/backend/src/modules/messages/elasticsearch/messages-es-index.repo.ts +++ b/apps/backend/src/modules/messages/elasticsearch/messages-es-index.repo.ts @@ -32,6 +32,7 @@ const MessagesAbstractEsIndexRepo = createElasticsearchIndexRepo({ repliedMessage: createIdObjectMapping({}, 'keyword'), creator: createIdObjectMapping(), aiModel: createIdObjectMapping(), + app: createIdObjectMapping(), content: { type: 'text', analyzer: 'folded_lowercase_analyzer', diff --git a/apps/backend/src/modules/messages/messages.repo.ts b/apps/backend/src/modules/messages/messages.repo.ts index 9209a86e..cf0563ac 100644 --- a/apps/backend/src/modules/messages/messages.repo.ts +++ b/apps/backend/src/modules/messages/messages.repo.ts @@ -26,6 +26,7 @@ export class MessagesRepo extends createDatabaseRepo('messages') { .where('messages.id', 'in', ids) .leftJoin('users', 'users.id', 'messages.creator_user_id') .leftJoin('ai_models', 'ai_models.id', 'messages.ai_model_id') + .leftJoin('apps', 'apps.id', 'messages.app_id') .leftJoin('messages as reply_messages', 'reply_messages.id', 'messages.replied_message_id') .leftJoin('users as reply_users', 'reply_users.id', 'reply_messages.creator_user_id') @@ -37,6 +38,9 @@ export class MessagesRepo extends createDatabaseRepo('messages') { 'ai_models.id as ai_model_id', 'ai_models.name as ai_model_name', + 'apps.id as app_id', + 'apps.name as app_name', + 'reply_messages.role as reply_message_role', 'reply_messages.content as reply_message_content', @@ -65,6 +69,9 @@ export class MessagesRepo extends createDatabaseRepo('messages') { reply_message_creator_user_id: replyMessageCreatorUserId, reply_message_creator_email: replyMessageCreatorEmail, + app_id: appId, + app_name: appName, + ...item }): MessageTableRowWithRelations => ({ ...camelcaseKeys(item), @@ -96,6 +103,12 @@ export class MessagesRepo extends createDatabaseRepo('messages') { : null, } : null, + app: appId && appName + ? { + id: appId, + name: appName, + } + : null, })), ), ); diff --git a/apps/backend/src/modules/messages/messages.tables.ts b/apps/backend/src/modules/messages/messages.tables.ts index 764bb628..e880e21b 100644 --- a/apps/backend/src/modules/messages/messages.tables.ts +++ b/apps/backend/src/modules/messages/messages.tables.ts @@ -23,6 +23,7 @@ export type MessagesTable = role: SdkMessageRoleT; metadata: Record; ai_model_id: ColumnType; + app_id: ColumnType; replied_message_id: ColumnType; }; @@ -35,10 +36,11 @@ type RepliedMessageTableRelationRow = }; export type MessageTableRowWithRelations = - & Omit + & Omit & { chat: TableRowWithUuid; repliedMessage: RepliedMessageTableRelationRow | null; creator: UserTableRowBaseRelation | null; aiModel: TableRowWithIdName | null; + app: TableRowWithIdName | null; }; diff --git a/packages/sdk/src/modules/dashboard/messages/dto/sdk-message.dto.ts b/packages/sdk/src/modules/dashboard/messages/dto/sdk-message.dto.ts index f50cf5a2..d376f40a 100644 --- a/packages/sdk/src/modules/dashboard/messages/dto/sdk-message.dto.ts +++ b/packages/sdk/src/modules/dashboard/messages/dto/sdk-message.dto.ts @@ -28,6 +28,7 @@ export const SdkMessageV = z role: SdkMessageRoleV, creator: SdkUserListItemV.nullable(), aiModel: SdkTableRowWithIdNameV.nullable(), + app: SdkTableRowWithIdNameV.nullable(), repliedMessage: SdkRepliedMessageV.nullable(), }) .merge(SdkTableRowWithUuidV) From 50cef1298e1cd7832502db33c1104fd914841c6b Mon Sep 17 00:00:00 2001 From: Mateusz Baginski Date: Sun, 1 Dec 2024 16:04:00 +0100 Subject: [PATCH 06/10] feat(backend): add apps logic --- .../controllers/dashboard/chats.controller.ts | 16 ++++++ apps/backend/src/modules/apps/apps.service.ts | 2 + .../apps/elasticsearch/apps-es-search.repo.ts | 7 ++- .../helpers/create-attach-app-ai-message.ts | 24 +++++++++ .../src/modules/messages/helpers/index.ts | 1 + .../src/modules/messages/messages.firewall.ts | 27 +++++++++- .../src/modules/messages/messages.service.ts | 26 ++++++++- apps/chat/src/i18n/packs/i18n-lang-en.ts | 3 ++ apps/chat/src/i18n/packs/i18n-lang-pl.ts | 3 ++ apps/chat/src/modules/apps/grid/app-card.tsx | 11 +++- .../modules/chats/conversation/hooks/index.ts | 1 + .../use-create-chat-with-initial-app.tsx | 54 +++++++++++++++++++ .../hooks/use-optimistic-response-creator.tsx | 2 + .../hooks/use-send-initial-message.tsx | 13 +++-- .../src/modules/dashboard/chats/chats.sdk.ts | 7 +++ .../modules/dashboard/messages/dto/index.ts | 1 + .../messages/dto/sdk-attach-app.dto.ts | 9 ++++ .../helpers/get-sdk-app-mention-in-chat.ts | 5 ++ .../dashboard/messages/helpers/index.ts | 1 + 19 files changed, 205 insertions(+), 8 deletions(-) create mode 100644 apps/backend/src/modules/messages/helpers/create-attach-app-ai-message.ts create mode 100644 apps/chat/src/modules/chats/conversation/hooks/use-create-chat-with-initial-app.tsx create mode 100644 packages/sdk/src/modules/dashboard/messages/dto/sdk-attach-app.dto.ts create mode 100644 packages/sdk/src/modules/dashboard/messages/helpers/get-sdk-app-mention-in-chat.ts diff --git a/apps/backend/src/modules/api/controllers/dashboard/chats.controller.ts b/apps/backend/src/modules/api/controllers/dashboard/chats.controller.ts index e88c61fd..8e28a606 100644 --- a/apps/backend/src/modules/api/controllers/dashboard/chats.controller.ts +++ b/apps/backend/src/modules/api/controllers/dashboard/chats.controller.ts @@ -6,6 +6,7 @@ import { inject, injectable } from 'tsyringe'; import { runTask } from '@llm/commons'; import { type ChatsSdk, + SdkAttachAppInputV, SdkCreateChatInputV, SdkCreateMessageInputV, SdkRequestAIReplyInputV, @@ -133,6 +134,21 @@ export class ChatsController extends AuthorizedController { serializeSdkResponseTE>(context), ), ) + .post( + '/:id/messages/attach-app', + sdkSchemaValidator('json', SdkAttachAppInputV), + async context => pipe( + context.req.valid('json'), + payload => messagesService.asUser(context.var.jwt).attachApp({ + ...payload, + chat: { + id: context.req.param('id'), + }, + }), + rejectUnsafeSdkErrors, + serializeSdkResponseTE>(context), + ), + ) .post( '/:id/messages/:messageId/ai-reply', sdkSchemaValidator('json', SdkRequestAIReplyInputV), diff --git a/apps/backend/src/modules/apps/apps.service.ts b/apps/backend/src/modules/apps/apps.service.ts index d606c4db..65b17667 100644 --- a/apps/backend/src/modules/apps/apps.service.ts +++ b/apps/backend/src/modules/apps/apps.service.ts @@ -33,6 +33,8 @@ export class AppsService implements WithAuthFirewall { asUser = (jwt: SdkJwtTokenT) => new AppsFirewall(jwt, this); + get = this.esSearchRepo.get; + archiveSeqByOrganizationId = (organizationId: SdkTableRowIdT) => TE.fromTask( pipe( this.repo.createIdsIterator({ diff --git a/apps/backend/src/modules/apps/elasticsearch/apps-es-search.repo.ts b/apps/backend/src/modules/apps/elasticsearch/apps-es-search.repo.ts index 5350e347..0e2dde7b 100644 --- a/apps/backend/src/modules/apps/elasticsearch/apps-es-search.repo.ts +++ b/apps/backend/src/modules/apps/elasticsearch/apps-es-search.repo.ts @@ -1,6 +1,6 @@ import esb from 'elastic-builder'; import { array as A, taskEither as TE } from 'fp-ts'; -import { pipe } from 'fp-ts/lib/function'; +import { flow, pipe } from 'fp-ts/lib/function'; import { inject, injectable } from 'tsyringe'; import type { @@ -26,6 +26,11 @@ export class AppsEsSearchRepo { @inject(AppsEsIndexRepo) private readonly indexRepo: AppsEsIndexRepo, ) {} + get = flow( + this.indexRepo.getDocument, + TE.map(AppsEsSearchRepo.mapOutputHit), + ); + search = (dto: SdKSearchAppsInputT) => pipe( this.indexRepo.search( diff --git a/apps/backend/src/modules/messages/helpers/create-attach-app-ai-message.ts b/apps/backend/src/modules/messages/helpers/create-attach-app-ai-message.ts new file mode 100644 index 00000000..05c6944f --- /dev/null +++ b/apps/backend/src/modules/messages/helpers/create-attach-app-ai-message.ts @@ -0,0 +1,24 @@ +import type { AppTableRowWithRelations } from '~/modules/apps'; + +import { rejectFalsyItems } from '@llm/commons'; + +type AttachableApp = Pick< + AppTableRowWithRelations, + 'id' | 'name' | 'chatContext' | 'description' +>; + +export function createAttachAppAIMessage(app: AttachableApp): string { + return rejectFalsyItems([ + 'App is:', + '* Task-Specific Tool: Used for a specific task like email or calendar.', + '* Reusable Component: Use App across different projects for efficiency.', + 'Please use this app to help the user with their query, but use it only if user passed ', + `#app:${app.id} in the message. Otherwise do not use it.`, + app.description && `App description: ${app.description}.`, + 'Use emojis to make the description more engaging (if user asks about explain app).', + `User has attached app ${app.name} to the chat.`, + 'Behavior of the app:', + '', + app.chatContext, + ]).join('\n'); +} diff --git a/apps/backend/src/modules/messages/helpers/index.ts b/apps/backend/src/modules/messages/helpers/index.ts index 593e6b2b..4c9d94f6 100644 --- a/apps/backend/src/modules/messages/helpers/index.ts +++ b/apps/backend/src/modules/messages/helpers/index.ts @@ -1 +1,2 @@ +export * from './create-attach-app-ai-message'; export * from './create-reply-ai-message-prefix'; diff --git a/apps/backend/src/modules/messages/messages.firewall.ts b/apps/backend/src/modules/messages/messages.firewall.ts index 714d1d5b..9f1e619c 100644 --- a/apps/backend/src/modules/messages/messages.firewall.ts +++ b/apps/backend/src/modules/messages/messages.firewall.ts @@ -1,8 +1,9 @@ import { flow, pipe } from 'fp-ts/lib/function'; +import * as TE from 'fp-ts/lib/TaskEither'; -import type { SdkJwtTokenT } from '@llm/sdk'; +import type { SdkJwtTokenT, SdkSearchMessageItemT } from '@llm/sdk'; -import type { CreateUserMessageInputT, MessagesService } from './messages.service'; +import type { AttachAppInputT, CreateUserMessageInputT, MessagesService } from './messages.service'; import { AuthFirewallService } from '../auth'; @@ -18,6 +19,10 @@ export class MessagesFirewall extends AuthFirewallService { search = flow( this.messagesService.search, this.tryTEIfUser.is.root, + TE.map(({ items, ...attrs }) => ({ + ...attrs, + items: MessagesFirewall.hideSystemMessages(items), + })), ); // TODO: Add belongs checks @@ -41,4 +46,22 @@ export class MessagesFirewall extends AuthFirewallService { this.messagesService.aiReply, this.tryTEIfUser.is.root, ); + + // TODO: Add belongs checks + attachApp = (dto: Omit) => + pipe( + this.messagesService.attachApp({ + ...dto, + creator: this.userIdRow, + }), + this.tryTEIfUser.is.root, + ); + + private static hideSystemMessages = (messages: Array) => + messages.map(message => ({ + ...message, + ...message.role === 'system' && { + content: 'System message', + }, + })); } diff --git a/apps/backend/src/modules/messages/messages.service.ts b/apps/backend/src/modules/messages/messages.service.ts index 51c35a75..55b4b5c3 100644 --- a/apps/backend/src/modules/messages/messages.service.ts +++ b/apps/backend/src/modules/messages/messages.service.ts @@ -15,9 +15,10 @@ import { import type { TableId, TableRowWithId, TableRowWithUuid, TableUuid } from '../database'; import { AIConnectorService } from '../ai-connector'; +import { AppsService } from '../apps'; import { WithAuthFirewall } from '../auth'; import { MessagesEsIndexRepo, MessagesEsSearchRepo } from './elasticsearch'; -import { createReplyAiMessagePrefix } from './helpers'; +import { createAttachAppAIMessage, createReplyAiMessagePrefix } from './helpers'; import { MessagesFirewall } from './messages.firewall'; import { MessagesRepo } from './messages.repo'; @@ -27,10 +28,17 @@ export type CreateUserMessageInputT = { creator: TableRowWithId; }; +export type AttachAppInputT = { + chat: TableRowWithUuid; + app: TableRowWithId; + creator: TableRowWithId; +}; + @injectable() export class MessagesService implements WithAuthFirewall { constructor( @inject(MessagesRepo) private readonly repo: MessagesRepo, + @inject(AppsService) private readonly appsService: AppsService, @inject(MessagesEsSearchRepo) private readonly esSearchRepo: MessagesEsSearchRepo, @inject(MessagesEsIndexRepo) private readonly esIndexRepo: MessagesEsIndexRepo, @inject(AIConnectorService) private readonly aiConnectorService: AIConnectorService, @@ -58,6 +66,22 @@ export class MessagesService implements WithAuthFirewall { TE.tap(({ id }) => this.esIndexRepo.findAndIndexDocumentById(id)), ); + attachApp = ({ chat, app, creator }: AttachAppInputT) => + pipe( + this.appsService.get(app.id), + TE.chainW(app => this.repo.create({ + value: { + chatId: chat.id, + content: createAttachAppAIMessage(app), + metadata: {}, + aiModelId: null, + creatorUserId: creator.id, + role: 'system', + }, + })), + TE.tap(({ id }) => this.esIndexRepo.findAndIndexDocumentById(id)), + ); + aiReply = ( { id, aiModel }: TableRowWithUuid & SdkRequestAIReplyInputT, signal?: AbortSignal, diff --git a/apps/chat/src/i18n/packs/i18n-lang-en.ts b/apps/chat/src/i18n/packs/i18n-lang-en.ts index 59cef339..e811e433 100644 --- a/apps/chat/src/i18n/packs/i18n-lang-en.ts +++ b/apps/chat/src/i18n/packs/i18n-lang-en.ts @@ -173,6 +173,9 @@ export const I18N_PACK_EN = deepmerge(I18N_FORWARDED_EN_PACK, { you: 'You', ai: 'AI', }, + prompts: { + explainApp: 'Please explain me what %{mention} does.', + }, generating: { title: 'Generating title...', description: 'Generating description...', diff --git a/apps/chat/src/i18n/packs/i18n-lang-pl.ts b/apps/chat/src/i18n/packs/i18n-lang-pl.ts index 142779ca..ba3800a8 100644 --- a/apps/chat/src/i18n/packs/i18n-lang-pl.ts +++ b/apps/chat/src/i18n/packs/i18n-lang-pl.ts @@ -175,6 +175,9 @@ export const I18N_PACK_PL: I18nLangPack = deepmerge(I18N_FORWARDED_PL_PACK, { you: 'Ty', ai: 'AI', }, + prompts: { + explainApp: 'Proszę wyjaśnij mi, co robi aplikacja %{mention}.', + }, generating: { title: 'Generowanie tytułu...', description: 'Generowanie opisu...', diff --git a/apps/chat/src/modules/apps/grid/app-card.tsx b/apps/chat/src/modules/apps/grid/app-card.tsx index 4aeda1a5..628f755e 100644 --- a/apps/chat/src/modules/apps/grid/app-card.tsx +++ b/apps/chat/src/modules/apps/grid/app-card.tsx @@ -5,6 +5,7 @@ import type { SdkAppT } from '@llm/sdk'; import { formatDate } from '@llm/commons'; import { useI18n } from '~/i18n'; +import { useCreateChatWithInitialApp } from '~/modules/chats/conversation/hooks'; import { useFavoriteApps } from '../favorite'; @@ -17,6 +18,7 @@ export function AppCard({ app }: AppCardProps) { const { isFavorite, toggle } = useFavoriteApps(); const favorite = isFavorite(app); + const createApp = useCreateChatWithInitialApp(); return (
    @@ -63,7 +65,14 @@ export function AppCard({ app }: AppCardProps) { {formatDate(app.updatedAt)}
    - + { + e.preventDefault(); + void createApp(app)(); + }} + > {t.buttons.open} diff --git a/apps/chat/src/modules/chats/conversation/hooks/index.ts b/apps/chat/src/modules/chats/conversation/hooks/index.ts index 084993ed..d496cceb 100644 --- a/apps/chat/src/modules/chats/conversation/hooks/index.ts +++ b/apps/chat/src/modules/chats/conversation/hooks/index.ts @@ -1,5 +1,6 @@ export * from './use-ai-response-observable'; export * from './use-autofocus-conversation-input'; +export * from './use-create-chat-with-initial-app'; export * from './use-optimistic-response-creator'; export * from './use-reply-conversation-handler'; export * from './use-send-initial-message'; diff --git a/apps/chat/src/modules/chats/conversation/hooks/use-create-chat-with-initial-app.tsx b/apps/chat/src/modules/chats/conversation/hooks/use-create-chat-with-initial-app.tsx new file mode 100644 index 00000000..c5b3229d --- /dev/null +++ b/apps/chat/src/modules/chats/conversation/hooks/use-create-chat-with-initial-app.tsx @@ -0,0 +1,54 @@ +import { pipe } from 'fp-ts/lib/function'; +import * as TE from 'fp-ts/lib/TaskEither'; +import { useLocation } from 'wouter'; + +import { format, tapTaskEither } from '@llm/commons'; +import { getSdkAppMentionInChat, type SdkTableRowWithIdT, useSdkForLoggedIn } from '@llm/sdk'; +import { useSaveErrorNotification } from '@llm/ui'; +import { useI18n } from '~/i18n'; +import { useWorkspaceOrganizationOrThrow } from '~/modules/workspace'; +import { useSitemap } from '~/routes'; + +import type { InitialChatMessageT } from './use-send-initial-message'; + +export function useCreateChatWithInitialApp() { + const [, navigate] = useLocation(); + const sitemap = useSitemap(); + const { organization, assignWorkspaceOrganization } = useWorkspaceOrganizationOrThrow(); + const { sdks } = useSdkForLoggedIn(); + const showErrorNotification = useSaveErrorNotification(); + const { prompts } = useI18n().pack.chat; + + return (app: SdkTableRowWithIdT) => pipe( + TE.Do, + TE.bind('aiModel', () => sdks.dashboard.aiModels.getDefault(organization.id)), + TE.bindW('chat', () => sdks.dashboard.chats.create( + assignWorkspaceOrganization({ + public: false, + }), + )), + TE.bindW('app', ({ chat }) => sdks.dashboard.chats.attachApp(chat.id, { + app, + })), + tapTaskEither( + ({ chat, aiModel }) => { + const initialPrompt = format(prompts.explainApp, { + mention: getSdkAppMentionInChat(app), + }); + + navigate( + sitemap.chat.generate({ pathParams: { id: chat.id } }), + { + state: { + message: { + aiModel, + content: initialPrompt, + } satisfies InitialChatMessageT, + }, + }, + ); + }, + showErrorNotification, + ), + ); +}; diff --git a/apps/chat/src/modules/chats/conversation/hooks/use-optimistic-response-creator.tsx b/apps/chat/src/modules/chats/conversation/hooks/use-optimistic-response-creator.tsx index 49a91526..88f06004 100644 --- a/apps/chat/src/modules/chats/conversation/hooks/use-optimistic-response-creator.tsx +++ b/apps/chat/src/modules/chats/conversation/hooks/use-optimistic-response-creator.tsx @@ -34,6 +34,7 @@ export function useOptimisticResponseCreator() { role: 'user', aiModel: null, repliedMessage: null, + app: null, creator: { id: token.sub, email: token.email, @@ -50,6 +51,7 @@ export function useOptimisticResponseCreator() { aiModel, creator: null, repliedMessage: null, + app: null, }), }; } diff --git a/apps/chat/src/modules/chats/conversation/hooks/use-send-initial-message.tsx b/apps/chat/src/modules/chats/conversation/hooks/use-send-initial-message.tsx index 90356c3d..87a2402d 100644 --- a/apps/chat/src/modules/chats/conversation/hooks/use-send-initial-message.tsx +++ b/apps/chat/src/modules/chats/conversation/hooks/use-send-initial-message.tsx @@ -1,15 +1,22 @@ +import type { z } from 'zod'; + import { pipe } from 'fp-ts/lib/function'; import { tapEither, tryParseUsingZodSchema } from '@llm/commons'; import { useAfterMount } from '@llm/commons-front'; +import { SdkCreateMessageInputV, SdkTableRowWithIdV } from '@llm/sdk'; + +const InitialChatMessageV = SdkCreateMessageInputV.extend({ + aiModel: SdkTableRowWithIdV, +}); -import { type StartChatFormValueT, StartChatFormValueV } from '../../start-chat/use-start-chat-form'; +export type InitialChatMessageT = z.TypeOf; -export function useSendInitialMessage(onReply: (input: Omit) => unknown) { +export function useSendInitialMessage(onReply: (input: InitialChatMessageT) => unknown) { useAfterMount(() => { pipe( history.state?.message, - tryParseUsingZodSchema(StartChatFormValueV), + tryParseUsingZodSchema(InitialChatMessageV), tapEither((data) => { history.replaceState(undefined, '', location.pathname); onReply(data); diff --git a/packages/sdk/src/modules/dashboard/chats/chats.sdk.ts b/packages/sdk/src/modules/dashboard/chats/chats.sdk.ts index e85c2d27..07b40a72 100644 --- a/packages/sdk/src/modules/dashboard/chats/chats.sdk.ts +++ b/packages/sdk/src/modules/dashboard/chats/chats.sdk.ts @@ -18,6 +18,7 @@ import { } from '~/shared'; import type { + SdkAttachAppInputT, SdkCreateMessageInputT, SdkRequestAIReplyInputT, SdKSearchMessagesInputT, @@ -105,6 +106,12 @@ export class ChatsSdk extends AbstractNestedSdkWithAuth { options: postPayload(data), }); + attachApp = (chatId: SdkTableRowUuidT, data: SdkAttachAppInputT) => + this.fetch({ + url: this.endpoint(`/${chatId}/messages/attach-app`), + options: postPayload(data), + }); + requestAIReply = ( { abortController, diff --git a/packages/sdk/src/modules/dashboard/messages/dto/index.ts b/packages/sdk/src/modules/dashboard/messages/dto/index.ts index 6f41a82b..431db1ca 100644 --- a/packages/sdk/src/modules/dashboard/messages/dto/index.ts +++ b/packages/sdk/src/modules/dashboard/messages/dto/index.ts @@ -1,3 +1,4 @@ +export * from './sdk-attach-app.dto'; export * from './sdk-create-message.dto'; export * from './sdk-message.dto'; export * from './sdk-request-ai-reply.dto'; diff --git a/packages/sdk/src/modules/dashboard/messages/dto/sdk-attach-app.dto.ts b/packages/sdk/src/modules/dashboard/messages/dto/sdk-attach-app.dto.ts new file mode 100644 index 00000000..42b7e0b8 --- /dev/null +++ b/packages/sdk/src/modules/dashboard/messages/dto/sdk-attach-app.dto.ts @@ -0,0 +1,9 @@ +import { z } from 'zod'; + +import { SdkTableRowWithIdV } from '~/shared'; + +export const SdkAttachAppInputV = z.object({ + app: SdkTableRowWithIdV, +}); + +export type SdkAttachAppInputT = z.infer; diff --git a/packages/sdk/src/modules/dashboard/messages/helpers/get-sdk-app-mention-in-chat.ts b/packages/sdk/src/modules/dashboard/messages/helpers/get-sdk-app-mention-in-chat.ts new file mode 100644 index 00000000..30447543 --- /dev/null +++ b/packages/sdk/src/modules/dashboard/messages/helpers/get-sdk-app-mention-in-chat.ts @@ -0,0 +1,5 @@ +import type { SdkTableRowWithIdT } from '~/shared'; + +export function getSdkAppMentionInChat(app: SdkTableRowWithIdT): string { + return `#app:${app.id}`; +} diff --git a/packages/sdk/src/modules/dashboard/messages/helpers/index.ts b/packages/sdk/src/modules/dashboard/messages/helpers/index.ts index 919a2c2e..c99192ab 100644 --- a/packages/sdk/src/modules/dashboard/messages/helpers/index.ts +++ b/packages/sdk/src/modules/dashboard/messages/helpers/index.ts @@ -1,2 +1,3 @@ export * from './get-last-used-sdk-messages-ai-model'; +export * from './get-sdk-app-mention-in-chat'; export * from './group-sdk-ai-messages-by-repeats'; From a9142c36e348a1a3e5fc2e540186fe25e9e3d92b Mon Sep 17 00:00:00 2001 From: Mateusz Baginski Date: Sun, 1 Dec 2024 16:52:19 +0100 Subject: [PATCH 07/10] feat(frontend): add apps to chat view --- .../controllers/dashboard/apps.controller.ts | 11 +++++ .../backend/src/modules/apps/apps.firewall.ts | 5 ++ .../chats-summaries.service.ts | 3 +- .../helpers/create-attach-app-ai-message.ts | 1 + .../src/modules/messages/messages.service.ts | 1 + apps/chat/src/i18n/packs/i18n-lang-en.ts | 2 +- apps/chat/src/i18n/packs/i18n-lang-pl.ts | 2 +- .../src/modules/apps/chat/app-chat-badge.tsx | 49 +++++++++++++++++++ .../chat/hydrate-with-app-chat-badges.tsx | 20 ++++++++ apps/chat/src/modules/apps/chat/index.ts | 2 + apps/chat/src/modules/apps/index.ts | 2 + .../chats/conversation/chat-attached-app.tsx | 27 ++++++++++ .../chats/conversation/chat-conversation.tsx | 32 +++++++----- .../hooks/use-send-initial-message.tsx | 12 +++-- .../messages/chat-message-content.tsx | 11 ++++- .../conversation/messages/chat-message.tsx | 8 ++- .../src/modules/dashboard/apps/apps.sdk.ts | 7 +++ 17 files changed, 173 insertions(+), 22 deletions(-) create mode 100644 apps/chat/src/modules/apps/chat/app-chat-badge.tsx create mode 100644 apps/chat/src/modules/apps/chat/hydrate-with-app-chat-badges.tsx create mode 100644 apps/chat/src/modules/apps/chat/index.ts create mode 100644 apps/chat/src/modules/chats/conversation/chat-attached-app.tsx diff --git a/apps/backend/src/modules/api/controllers/dashboard/apps.controller.ts b/apps/backend/src/modules/api/controllers/dashboard/apps.controller.ts index 9a6c9276..258aff24 100644 --- a/apps/backend/src/modules/api/controllers/dashboard/apps.controller.ts +++ b/apps/backend/src/modules/api/controllers/dashboard/apps.controller.ts @@ -13,6 +13,7 @@ import { ConfigService } from '~/modules/config'; import { mapDbRecordAlreadyExistsToSdkError, mapDbRecordNotFoundToSdkError, + mapEsDocumentNotFoundToSdkError, rejectUnsafeSdkErrors, sdkSchemaValidator, serializeSdkResponseTE, @@ -38,6 +39,16 @@ export class AppsController extends AuthorizedController { serializeSdkResponseTE>(context), ), ) + .get( + '/:id', + async context => pipe( + Number(context.req.param().id), + appsService.asUser(context.var.jwt).get, + mapEsDocumentNotFoundToSdkError, + rejectUnsafeSdkErrors, + serializeSdkResponseTE>(context), + ), + ) .post( '/', sdkSchemaValidator('json', SdkCreateAppInputV), diff --git a/apps/backend/src/modules/apps/apps.firewall.ts b/apps/backend/src/modules/apps/apps.firewall.ts index 8c6b048e..f4ccba26 100644 --- a/apps/backend/src/modules/apps/apps.firewall.ts +++ b/apps/backend/src/modules/apps/apps.firewall.ts @@ -38,4 +38,9 @@ export class AppsFirewall extends AuthFirewallService { this.appsService.search, this.tryTEIfUser.is.root, ); + + get = flow( + this.appsService.get, + this.tryTEIfUser.is.root, + ); } diff --git a/apps/backend/src/modules/chats-summaries/chats-summaries.service.ts b/apps/backend/src/modules/chats-summaries/chats-summaries.service.ts index 24316521..43c8121e 100644 --- a/apps/backend/src/modules/chats-summaries/chats-summaries.service.ts +++ b/apps/backend/src/modules/chats-summaries/chats-summaries.service.ts @@ -83,7 +83,8 @@ export class ChatsSummariesService { history: chat.items, message: 'Summarize this chat, create short title and description in the language of this chat.' - + 'Keep description compact to store in on the chat. You can use emojis in title and description.', + + 'Keep description compact to store in on the chat. You can use emojis in title and description.' + + 'Do not summarize the messages about describing app (these ones defined in chat).', schema: z.object({ title: z.string(), description: z.string(), diff --git a/apps/backend/src/modules/messages/helpers/create-attach-app-ai-message.ts b/apps/backend/src/modules/messages/helpers/create-attach-app-ai-message.ts index 05c6944f..c12d4c11 100644 --- a/apps/backend/src/modules/messages/helpers/create-attach-app-ai-message.ts +++ b/apps/backend/src/modules/messages/helpers/create-attach-app-ai-message.ts @@ -17,6 +17,7 @@ export function createAttachAppAIMessage(app: AttachableApp): string { app.description && `App description: ${app.description}.`, 'Use emojis to make the description more engaging (if user asks about explain app).', `User has attached app ${app.name} to the chat.`, + 'Do not include any information about adding this app in summarize.', 'Behavior of the app:', '', app.chatContext, diff --git a/apps/backend/src/modules/messages/messages.service.ts b/apps/backend/src/modules/messages/messages.service.ts index 55b4b5c3..df7701eb 100644 --- a/apps/backend/src/modules/messages/messages.service.ts +++ b/apps/backend/src/modules/messages/messages.service.ts @@ -72,6 +72,7 @@ export class MessagesService implements WithAuthFirewall { TE.chainW(app => this.repo.create({ value: { chatId: chat.id, + appId: app.id, content: createAttachAppAIMessage(app), metadata: {}, aiModelId: null, diff --git a/apps/chat/src/i18n/packs/i18n-lang-en.ts b/apps/chat/src/i18n/packs/i18n-lang-en.ts index e811e433..0c5bb627 100644 --- a/apps/chat/src/i18n/packs/i18n-lang-en.ts +++ b/apps/chat/src/i18n/packs/i18n-lang-en.ts @@ -174,7 +174,7 @@ export const I18N_PACK_EN = deepmerge(I18N_FORWARDED_EN_PACK, { ai: 'AI', }, prompts: { - explainApp: 'Please explain me what %{mention} does.', + explainApp: 'Could you briefly explain what %{mention} does and how to use it?', }, generating: { title: 'Generating title...', diff --git a/apps/chat/src/i18n/packs/i18n-lang-pl.ts b/apps/chat/src/i18n/packs/i18n-lang-pl.ts index ba3800a8..17560569 100644 --- a/apps/chat/src/i18n/packs/i18n-lang-pl.ts +++ b/apps/chat/src/i18n/packs/i18n-lang-pl.ts @@ -176,7 +176,7 @@ export const I18N_PACK_PL: I18nLangPack = deepmerge(I18N_FORWARDED_PL_PACK, { ai: 'AI', }, prompts: { - explainApp: 'Proszę wyjaśnij mi, co robi aplikacja %{mention}.', + explainApp: 'Wyjaśnij krótko, do czego służy aplikacja %{mention} i jak jej używać.', }, generating: { title: 'Generowanie tytułu...', diff --git a/apps/chat/src/modules/apps/chat/app-chat-badge.tsx b/apps/chat/src/modules/apps/chat/app-chat-badge.tsx new file mode 100644 index 00000000..f39ab02d --- /dev/null +++ b/apps/chat/src/modules/apps/chat/app-chat-badge.tsx @@ -0,0 +1,49 @@ +import clsx from 'clsx'; +import { pipe } from 'fp-ts/lib/function'; +import { WandSparklesIcon } from 'lucide-react'; +import { memo } from 'react'; + +import { tryOrThrowTE } from '@llm/commons'; +import { useAsyncValue } from '@llm/commons-front'; +import { type SdkAppT, type SdkTableRowIdT, useSdkForLoggedIn } from '@llm/sdk'; + +const APP_CHATS_BADGES_CACHE = new Map>(); + +export type AppChatBadgeProps = { + id: SdkTableRowIdT; + darkMode?: boolean; +}; + +export const AppChatBadge = memo(({ id, darkMode }: AppChatBadgeProps) => { + const { sdks } = useSdkForLoggedIn(); + const value = useAsyncValue( + async () => { + if (APP_CHATS_BADGES_CACHE.has(id)) { + return APP_CHATS_BADGES_CACHE.get(id); + } + + const promise = pipe( + sdks.dashboard.apps.get(id), + tryOrThrowTE, + )(); + + APP_CHATS_BADGES_CACHE.set(id, promise); + return promise; + }, + [id], + ); + + return ( + + + {value.status === 'success' ? value.data?.name : '...'} + + ); +}); diff --git a/apps/chat/src/modules/apps/chat/hydrate-with-app-chat-badges.tsx b/apps/chat/src/modules/apps/chat/hydrate-with-app-chat-badges.tsx new file mode 100644 index 00000000..3c3a1395 --- /dev/null +++ b/apps/chat/src/modules/apps/chat/hydrate-with-app-chat-badges.tsx @@ -0,0 +1,20 @@ +import type { ReactNode } from 'react'; + +import { AppChatBadge, type AppChatBadgeProps } from './app-chat-badge'; + +export function hydrateWithAppChatBadges( + content: string, + props: Omit = {}, +): ReactNode { + const tokens = content.split(/(#app:\d+)/); + + return tokens.map((token) => { + const match = token.match(/#app:(\d+)/); + if (!match?.length) { + return token; + } + + const [, id] = match; + return ; + }); +} diff --git a/apps/chat/src/modules/apps/chat/index.ts b/apps/chat/src/modules/apps/chat/index.ts new file mode 100644 index 00000000..8b55add8 --- /dev/null +++ b/apps/chat/src/modules/apps/chat/index.ts @@ -0,0 +1,2 @@ +export * from './app-chat-badge'; +export * from './hydrate-with-app-chat-badges'; diff --git a/apps/chat/src/modules/apps/index.ts b/apps/chat/src/modules/apps/index.ts index d24d1bdc..d94bbc73 100644 --- a/apps/chat/src/modules/apps/index.ts +++ b/apps/chat/src/modules/apps/index.ts @@ -1 +1,3 @@ +export * from './chat'; +export * from './favorite'; export * from './grid'; diff --git a/apps/chat/src/modules/chats/conversation/chat-attached-app.tsx b/apps/chat/src/modules/chats/conversation/chat-attached-app.tsx new file mode 100644 index 00000000..22addb9a --- /dev/null +++ b/apps/chat/src/modules/chats/conversation/chat-attached-app.tsx @@ -0,0 +1,27 @@ +import { Bot } from 'lucide-react'; +import { memo } from 'react'; + +import type { SdkTableRowWithIdNameT } from '@llm/sdk'; + +import { AppChatBadge } from '../../apps/chat/app-chat-badge'; + +type ChatAttachedAppProps = { + app: SdkTableRowWithIdNameT; +}; + +export const ChatAttachedApp = memo(({ app }: ChatAttachedAppProps) => { + return ( +
    +
    + +
    + +
    +
    + Attached app: + +
    +
    +
    + ); +}); diff --git a/apps/chat/src/modules/chats/conversation/chat-conversation.tsx b/apps/chat/src/modules/chats/conversation/chat-conversation.tsx index b1702c9b..3e4d1e4a 100644 --- a/apps/chat/src/modules/chats/conversation/chat-conversation.tsx +++ b/apps/chat/src/modules/chats/conversation/chat-conversation.tsx @@ -11,6 +11,7 @@ import { import type { SdkRepeatedMessageItemT } from './messages/chat-message'; +import { ChatAttachedApp } from './chat-attached-app'; import { ChatBackground } from './chat-background'; import { ChatConfigPanel } from './config-panel'; import { @@ -83,6 +84,25 @@ export const ChatConversation = memo(({ chat, initialMessages }: Props) => { useSendInitialMessage(onReply); useUpdateEffect(focusInput, [messages, replyToMessage]); + const renderMessage = (message: SdkRepeatedMessageItemT, index: number) => { + if (message.app) { + return ( + + ); + } + + return ( + + ); + }; + return (
    @@ -92,17 +112,7 @@ export const ChatConversation = memo(({ chat, initialMessages }: Props) => { ref={messagesContainerRef} className="relative z-10 flex-1 [&::-webkit-scrollbar]:hidden p-4 [-ms-overflow-style:none] overflow-y-scroll [scrollbar-width:none]" > - {groupedMessages.map((message, index) => ( - - ))} + {groupedMessages.map(renderMessage)}
    {!chat.archived && ( diff --git a/apps/chat/src/modules/chats/conversation/hooks/use-send-initial-message.tsx b/apps/chat/src/modules/chats/conversation/hooks/use-send-initial-message.tsx index 87a2402d..c63cee69 100644 --- a/apps/chat/src/modules/chats/conversation/hooks/use-send-initial-message.tsx +++ b/apps/chat/src/modules/chats/conversation/hooks/use-send-initial-message.tsx @@ -4,11 +4,15 @@ import { pipe } from 'fp-ts/lib/function'; import { tapEither, tryParseUsingZodSchema } from '@llm/commons'; import { useAfterMount } from '@llm/commons-front'; -import { SdkCreateMessageInputV, SdkTableRowWithIdV } from '@llm/sdk'; +import { SdkCreateMessageInputV, SdkTableRowWithIdNameV } from '@llm/sdk'; -const InitialChatMessageV = SdkCreateMessageInputV.extend({ - aiModel: SdkTableRowWithIdV, -}); +const InitialChatMessageV = SdkCreateMessageInputV + .omit({ + replyToMessage: true, + }) + .extend({ + aiModel: SdkTableRowWithIdNameV, + }); export type InitialChatMessageT = z.TypeOf; diff --git a/apps/chat/src/modules/chats/conversation/messages/chat-message-content.tsx b/apps/chat/src/modules/chats/conversation/messages/chat-message-content.tsx index 0dba13ec..8957c3b8 100644 --- a/apps/chat/src/modules/chats/conversation/messages/chat-message-content.tsx +++ b/apps/chat/src/modules/chats/conversation/messages/chat-message-content.tsx @@ -2,15 +2,17 @@ import { memo, useMemo, useSyncExternalStore } from 'react'; import sanitizeHtml from 'sanitize-html'; import { createStoreSubscriber, truncateText } from '@llm/commons'; +import { hydrateWithAppChatBadges } from '~/modules/apps'; import type { AIStreamContent, AIStreamObservable } from '../hooks'; type Props = { content: string | AIStreamObservable; truncate?: number; + darkMode?: boolean; }; -export const ChatMessageContent = memo(({ content, truncate }: Props) => { +export const ChatMessageContent = memo(({ content, truncate, darkMode }: Props) => { const observable = useMemo(() => { if (typeof content === 'string') { return createStoreSubscriber({ @@ -41,10 +43,15 @@ export const ChatMessageContent = memo(({ content, truncate }: Props) => { return html; }, [stream, truncate]); + const hydratedContent = useMemo( + () => hydrateWithAppChatBadges(sanitizedContent, { darkMode }), + [sanitizedContent, darkMode], + ); + return ( <>

    - {sanitizedContent} + {hydratedContent}

    {!stream.done && ( diff --git a/apps/chat/src/modules/chats/conversation/messages/chat-message.tsx b/apps/chat/src/modules/chats/conversation/messages/chat-message.tsx index 42075474..bfdca5be 100644 --- a/apps/chat/src/modules/chats/conversation/messages/chat-message.tsx +++ b/apps/chat/src/modules/chats/conversation/messages/chat-message.tsx @@ -58,7 +58,7 @@ export function ChatMessage({ message, isLast, readOnly, onRefreshResponse, onRe 'flex items-start gap-2', 'animate-slideIn', { - 'mb-6': !repeats.length, + 'mb-5': !repeats.length, 'mb-10': repeats.length, 'flex-row': isAI, 'flex-row-reverse': !isAI, @@ -100,7 +100,11 @@ export function ChatMessage({ message, isLast, readOnly, onRefreshResponse, onRe /> )} - +
    diff --git a/packages/sdk/src/modules/dashboard/apps/apps.sdk.ts b/packages/sdk/src/modules/dashboard/apps/apps.sdk.ts index ecb75974..847272be 100644 --- a/packages/sdk/src/modules/dashboard/apps/apps.sdk.ts +++ b/packages/sdk/src/modules/dashboard/apps/apps.sdk.ts @@ -11,6 +11,7 @@ import { } from '~/shared'; import type { + SdkAppT, SdkCreateAppInputT, SdkCreateAppOutputT, SdKSearchAppsInputT, @@ -22,6 +23,12 @@ import type { export class AppsSdk extends AbstractNestedSdkWithAuth { protected endpointPrefix = '/dashboard/apps'; + get = (id: SdkTableRowIdT) => + this.fetch({ + url: this.endpoint(`/${id}`), + options: getPayload(), + }); + search = (data: SdKSearchAppsInputT) => this.fetch({ url: this.endpoint('/search'), From 73caac9d0f2471a183598d586b6fd54c59947e0b Mon Sep 17 00:00:00 2001 From: Mateusz Baginski Date: Sun, 1 Dec 2024 16:59:33 +0100 Subject: [PATCH 08/10] feat(backend): improve app context --- .../modules/messages/helpers/create-attach-app-ai-message.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/backend/src/modules/messages/helpers/create-attach-app-ai-message.ts b/apps/backend/src/modules/messages/helpers/create-attach-app-ai-message.ts index c12d4c11..c9856fae 100644 --- a/apps/backend/src/modules/messages/helpers/create-attach-app-ai-message.ts +++ b/apps/backend/src/modules/messages/helpers/create-attach-app-ai-message.ts @@ -13,11 +13,14 @@ export function createAttachAppAIMessage(app: AttachableApp): string { '* Task-Specific Tool: Used for a specific task like email or calendar.', '* Reusable Component: Use App across different projects for efficiency.', 'Please use this app to help the user with their query, but use it only if user passed ', - `#app:${app.id} in the message. Otherwise do not use it.`, + `#app:${app.id} in the message. Otherwise do not use it and forget what you read about app.`, + 'Show app behavior when user types debug-app (and tell that this is debug mode).', app.description && `App description: ${app.description}.`, 'Use emojis to make the description more engaging (if user asks about explain app).', `User has attached app ${app.name} to the chat.`, 'Do not include any information about adding this app in summarize.', + `Remember: DO NOT ACTIVATE THIS APP IF USER DID NOT PASS #app:${app.id} IN THE MESSAGE.`, + `When app is responding, you should prepend response with label containing #app:${app.id}.`, 'Behavior of the app:', '', app.chatContext, From efe21a9b141f2744d5d43ba38f1cf351811be771 Mon Sep 17 00:00:00 2001 From: Mateusz Baginski Date: Sun, 1 Dec 2024 17:22:45 +0100 Subject: [PATCH 09/10] feat(chat): improve UI --- .../src/modules/apps/chat/app-chat-badge.tsx | 32 +++++++++----- .../chat/hydrate-with-app-chat-badges.tsx | 10 ++++- .../chats/conversation/chat-conversation.tsx | 14 ++++-- .../input-toolbar/chat-input-toolbar.tsx | 44 ++++++++++++++++--- .../input-toolbar/chat-select-app.tsx | 40 +++++++++++++++++ 5 files changed, 118 insertions(+), 22 deletions(-) create mode 100644 apps/chat/src/modules/chats/conversation/input-toolbar/chat-select-app.tsx diff --git a/apps/chat/src/modules/apps/chat/app-chat-badge.tsx b/apps/chat/src/modules/apps/chat/app-chat-badge.tsx index f39ab02d..8f87edf0 100644 --- a/apps/chat/src/modules/apps/chat/app-chat-badge.tsx +++ b/apps/chat/src/modules/apps/chat/app-chat-badge.tsx @@ -1,6 +1,6 @@ import clsx from 'clsx'; import { pipe } from 'fp-ts/lib/function'; -import { WandSparklesIcon } from 'lucide-react'; +import { CheckIcon, WandSparklesIcon } from 'lucide-react'; import { memo } from 'react'; import { tryOrThrowTE } from '@llm/commons'; @@ -12,9 +12,12 @@ const APP_CHATS_BADGES_CACHE = new Map>(); export type AppChatBadgeProps = { id: SdkTableRowIdT; darkMode?: boolean; + selected?: boolean; + onClick?: () => void; + disabled?: boolean; }; -export const AppChatBadge = memo(({ id, darkMode }: AppChatBadgeProps) => { +export const AppChatBadge = memo(({ id, darkMode, selected, onClick, disabled }: AppChatBadgeProps) => { const { sdks } = useSdkForLoggedIn(); const value = useAsyncValue( async () => { @@ -34,16 +37,25 @@ export const AppChatBadge = memo(({ id, darkMode }: AppChatBadgeProps) => { ); return ( - {value.status === 'success' ? value.data?.name : '...'} - + {selected && } + ); }); diff --git a/apps/chat/src/modules/apps/chat/hydrate-with-app-chat-badges.tsx b/apps/chat/src/modules/apps/chat/hydrate-with-app-chat-badges.tsx index 3c3a1395..6fc861b6 100644 --- a/apps/chat/src/modules/apps/chat/hydrate-with-app-chat-badges.tsx +++ b/apps/chat/src/modules/apps/chat/hydrate-with-app-chat-badges.tsx @@ -1,4 +1,4 @@ -import type { ReactNode } from 'react'; +import { Fragment, type ReactNode } from 'react'; import { AppChatBadge, type AppChatBadgeProps } from './app-chat-badge'; @@ -15,6 +15,12 @@ export function hydrateWithAppChatBadges( } const [, id] = match; - return ; + return ( + +   + +   + + ); }); } diff --git a/apps/chat/src/modules/chats/conversation/chat-conversation.tsx b/apps/chat/src/modules/chats/conversation/chat-conversation.tsx index 3e4d1e4a..08a4f7fb 100644 --- a/apps/chat/src/modules/chats/conversation/chat-conversation.tsx +++ b/apps/chat/src/modules/chats/conversation/chat-conversation.tsx @@ -1,6 +1,6 @@ import { memo, useMemo, useState } from 'react'; -import { findItemById } from '@llm/commons'; +import { findItemById, rejectFalsyItems } from '@llm/commons'; import { useUpdateEffect } from '@llm/commons-front'; import { getLastUsedSdkMessagesAIModel, @@ -41,6 +41,11 @@ export const ChatConversation = memo(({ chat, initialMessages }: Props) => { initialMessages, }); + const apps = useMemo( + () => rejectFalsyItems(messages.items.map(({ app }) => app)), + [messages.items], + ); + const { groupedMessages, aiModel } = useMemo( () => ({ groupedMessages: groupSdkAIMessagesByRepeats(messages.items), @@ -81,9 +86,6 @@ export const ChatConversation = memo(({ chat, initialMessages }: Props) => { }); }; - useSendInitialMessage(onReply); - useUpdateEffect(focusInput, [messages, replyToMessage]); - const renderMessage = (message: SdkRepeatedMessageItemT, index: number) => { if (message.app) { return ( @@ -103,6 +105,9 @@ export const ChatConversation = memo(({ chat, initialMessages }: Props) => { ); }; + useSendInitialMessage(onReply); + useUpdateEffect(focusInput, [messages, replyToMessage]); + return (
    @@ -117,6 +122,7 @@ export const ChatConversation = memo(({ chat, initialMessages }: Props) => { {!chat.archived && ( ; type Props = { + apps: Array; + replying: boolean; replyToMessage?: SdkRepeatedMessageItemT | null; + disabled?: boolean; inputRef?: React.RefObject; + onSubmit: (message: ChatInputValue) => CanBePromise; onCancelSubmit: VoidFunction; onCancelReplyToMessage: VoidFunction; @@ -36,6 +40,7 @@ export function ChatInputToolbar( onSubmit, onCancelSubmit, onCancelReplyToMessage, + apps, }: Props, ) { const t = useI18n().pack.chat; @@ -46,6 +51,10 @@ export function ChatInputToolbar( readBeforeMount: true, }); + const selectedApp = useControlStrict({ + defaultValue: null, + }); + const { bind, value, @@ -63,7 +72,16 @@ export function ChatInputToolbar( }, }); - return onSubmit(newValue); + let mappedContent = newValue.content.trim(); + + if (selectedApp.value) { + mappedContent = `${getSdkAppMentionInChat(selectedApp.value)} ${mappedContent}`; + } + + return onSubmit({ + ...newValue, + content: mappedContent, + }); }, }); @@ -84,6 +102,14 @@ export function ChatInputToolbar( onCancelSubmit?.(); }; + useAfterMount(() => { + if (apps.length) { + selectedApp.setValue({ + value: apps[0], + }); + } + }); + return (
    -
    +
    {t.actions.submitOnEnter} + +
    ); diff --git a/apps/chat/src/modules/chats/conversation/input-toolbar/chat-select-app.tsx b/apps/chat/src/modules/chats/conversation/input-toolbar/chat-select-app.tsx new file mode 100644 index 00000000..d94b0962 --- /dev/null +++ b/apps/chat/src/modules/chats/conversation/input-toolbar/chat-select-app.tsx @@ -0,0 +1,40 @@ +import { controlled } from '@under-control/forms'; + +import type { SdkTableRowWithIdNameT } from '@llm/sdk'; + +import { AppChatBadge } from '~/modules/apps/chat/app-chat-badge'; + +type Props = { + apps: SdkTableRowWithIdNameT[]; + disabled?: boolean; +}; + +export const ChatSelectApp = controlled( + ({ apps, disabled, control: { value, setValue } }) => { + if (!apps.length) { + return null; + } + + return ( +
    + {apps.map((app) => { + const isSelected = value?.id === app.id; + + return ( + { + setValue({ + value: isSelected ? null : app, + }); + }} + /> + ); + })} +
    + ); + }, +); From 08f85a526c2a4c7aa4c3bd1b7ba15b66b6935028 Mon Sep 17 00:00:00 2001 From: Mateusz Baginski Date: Sun, 1 Dec 2024 17:28:52 +0100 Subject: [PATCH 10/10] feat(chat): better apps support --- .../messages/helpers/create-attach-app-ai-message.ts | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/apps/backend/src/modules/messages/helpers/create-attach-app-ai-message.ts b/apps/backend/src/modules/messages/helpers/create-attach-app-ai-message.ts index c9856fae..5499924f 100644 --- a/apps/backend/src/modules/messages/helpers/create-attach-app-ai-message.ts +++ b/apps/backend/src/modules/messages/helpers/create-attach-app-ai-message.ts @@ -9,18 +9,15 @@ type AttachableApp = Pick< export function createAttachAppAIMessage(app: AttachableApp): string { return rejectFalsyItems([ - 'App is:', - '* Task-Specific Tool: Used for a specific task like email or calendar.', - '* Reusable Component: Use App across different projects for efficiency.', - 'Please use this app to help the user with their query, but use it only if user passed ', - `#app:${app.id} in the message. Otherwise do not use it and forget what you read about app.`, + 'Please use this app to help the user with their query, but use it only if user starts the message with ', + `#app:${app.id}. Otherwise do not use it and forget what you read about app.`, 'Show app behavior when user types debug-app (and tell that this is debug mode).', - app.description && `App description: ${app.description}.`, 'Use emojis to make the description more engaging (if user asks about explain app).', `User has attached app ${app.name} to the chat.`, 'Do not include any information about adding this app in summarize.', - `Remember: DO NOT ACTIVATE THIS APP IF USER DID NOT PASS #app:${app.id} IN THE MESSAGE.`, + `Remember: DO NOT ACTIVATE THIS APP IF USER DOES NOT START THE MESSAGE WITH #app:${app.id}.`, `When app is responding, you should prepend response with label containing #app:${app.id}.`, + app.description && `App description:\n${app.description}.`, 'Behavior of the app:', '', app.chatContext,