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, }; 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/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.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/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 090080cf..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( @@ -50,6 +55,7 @@ export class AppsEsSearchRepo { { phrase, ids, + excludeIds, organizationIds, archived, }: SdKSearchAppsInputT, @@ -57,6 +63,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/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/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/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..5499924f --- /dev/null +++ b/apps/backend/src/modules/messages/helpers/create-attach-app-ai-message.ts @@ -0,0 +1,25 @@ +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([ + '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).', + '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 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, + ]).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.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.service.ts b/apps/backend/src/modules/messages/messages.service.ts index 51c35a75..df7701eb 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,23 @@ 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, + appId: app.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/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/apps/chat/src/i18n/packs/i18n-lang-en.ts b/apps/chat/src/i18n/packs/i18n-lang-en.ts index f9580e87..0c5bb627 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: 'Could you briefly explain what %{mention} does and how to use it?', + }, generating: { title: 'Generating title...', description: 'Generating description...', @@ -222,6 +225,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..17560569 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: 'Wyjaśnij krótko, do czego służy aplikacja %{mention} i jak jej używać.', + }, generating: { title: 'Generowanie tytułu...', description: 'Generowanie opisu...', @@ -224,6 +227,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/chat/app-chat-badge.tsx b/apps/chat/src/modules/apps/chat/app-chat-badge.tsx new file mode 100644 index 00000000..8f87edf0 --- /dev/null +++ b/apps/chat/src/modules/apps/chat/app-chat-badge.tsx @@ -0,0 +1,61 @@ +import clsx from 'clsx'; +import { pipe } from 'fp-ts/lib/function'; +import { CheckIcon, 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; + selected?: boolean; + onClick?: () => void; + disabled?: boolean; +}; + +export const AppChatBadge = memo(({ id, darkMode, selected, onClick, disabled }: 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 ( + + ); +}); 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..6fc861b6 --- /dev/null +++ b/apps/chat/src/modules/apps/chat/hydrate-with-app-chat-badges.tsx @@ -0,0 +1,26 @@ +import { Fragment, 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/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..178f84e9 --- /dev/null +++ b/apps/chat/src/modules/apps/favorite/use-favorite-apps.tsx @@ -0,0 +1,37 @@ +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 hasFavorites = appsIds.length > 0; + + 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 { + total: appsIds.length, + ids: appsIds, + isFavorite, + hasFavorites, + 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..628f755e 100644 --- a/apps/chat/src/modules/apps/grid/app-card.tsx +++ b/apps/chat/src/modules/apps/grid/app-card.tsx @@ -1,9 +1,13 @@ +import clsx from 'clsx'; import { ExternalLinkIcon, StarIcon, WandSparklesIcon } from 'lucide-react'; 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'; type AppCardProps = { app: SdkAppT; @@ -11,28 +15,39 @@ type AppCardProps = { export function AppCard({ app }: AppCardProps) { const t = useI18n().pack; - const favorite = app.name.includes('Analyzer'); + const { isFavorite, toggle } = useFavoriteApps(); + + const favorite = isFavorite(app); + const createApp = useCreateChatWithInitialApp(); return (
- + {!app.archived && ( + + )}
@@ -50,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/apps/grid/apps-container.tsx b/apps/chat/src/modules/apps/grid/apps-container.tsx new file mode 100644 index 00000000..7035c2de --- /dev/null +++ b/apps/chat/src/modules/apps/grid/apps-container.tsx @@ -0,0 +1,131 @@ +import { useMemo } from 'react'; + +import { + SdKSearchAppsInputV, + useSdkForLoggedIn, +} from '@llm/sdk'; +import { + ArchiveFilterTabs, + FavoriteFiltersTabs, + PaginatedList, + PaginationSearchToolbarItem, + PaginationToolbar, + useDebouncedPaginatedSearch, +} from '@llm/ui'; +import { useWorkspaceOrganizationOrThrow } from '~/modules/workspace'; + +import { useFavoriteApps } from '../favorite'; +import { AppCard } from './app-card'; +import { AppsPlaceholder } from './apps-placeholder'; + +export function AppsContainer() { + const favorites = useFavoriteApps(); + const { organization } = useWorkspaceOrganizationOrThrow(); + + const { sdks } = useSdkForLoggedIn(); + const { loading, pagination, result } = useDebouncedPaginatedSearch({ + storeDataInUrl: false, + schema: SdKSearchAppsInputV, + fallbackSearchParams: { + limit: 12, + + ...favorites.hasFavorites && { + ids: [...favorites.ids], + }, + }, + fetchResultsTask: filters => sdks.dashboard.apps.search({ + ...filters, + organizationIds: [organization.id], + }), + }); + + 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 ( +
+ + + + + + )} + > + ({ + ...newGlobalValue, + sort: newControlValue ? 'score:desc' : 'createdAt:asc', + }), + })} + /> + + + + {({ items, total }) => { + if (!total) { + return ; + } + + return ( +
+ {items.map(item => ( + + ))} +
+ ); + }} +
+
+ ); +} diff --git a/apps/chat/src/modules/apps/grid/apps-placeholder.tsx b/apps/chat/src/modules/apps/grid/apps-placeholder.tsx new file mode 100644 index 00000000..46e60314 --- /dev/null +++ b/apps/chat/src/modules/apps/grid/apps-placeholder.tsx @@ -0,0 +1,10 @@ +import { useI18n } from '~/i18n'; +import { GhostPlaceholder } from '~/modules/shared'; + +export function AppsPlaceholder() { + const t = useI18n().pack.apps.grid; + + return ( + {t.placeholder} + ); +} diff --git a/apps/chat/src/modules/apps/grid/index.ts b/apps/chat/src/modules/apps/grid/index.ts index ede86c32..6b64c4c0 100644 --- a/apps/chat/src/modules/apps/grid/index.ts +++ b/apps/chat/src/modules/apps/grid/index.ts @@ -1,2 +1,4 @@ export * from './app-card'; +export * from './apps-container'; export * from './apps-grid'; +export * from './apps-placeholder'; 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..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, @@ -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 { @@ -40,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), @@ -80,6 +86,25 @@ export const ChatConversation = memo(({ chat, initialMessages }: Props) => { }); }; + const renderMessage = (message: SdkRepeatedMessageItemT, index: number) => { + if (message.app) { + return ( + + ); + } + + return ( + + ); + }; + useSendInitialMessage(onReply); useUpdateEffect(focusInput, [messages, replyToMessage]); @@ -92,21 +117,12 @@ 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 && ( 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..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 @@ -1,15 +1,26 @@ +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, SdkTableRowWithIdNameV } from '@llm/sdk'; + +const InitialChatMessageV = SdkCreateMessageInputV + .omit({ + replyToMessage: true, + }) + .extend({ + aiModel: SdkTableRowWithIdNameV, + }); -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/apps/chat/src/modules/chats/conversation/input-toolbar/chat-input-toolbar.tsx b/apps/chat/src/modules/chats/conversation/input-toolbar/chat-input-toolbar.tsx index 10d1baa0..55bde84e 100644 --- a/apps/chat/src/modules/chats/conversation/input-toolbar/chat-input-toolbar.tsx +++ b/apps/chat/src/modules/chats/conversation/input-toolbar/chat-input-toolbar.tsx @@ -1,27 +1,31 @@ import type { KeyboardEventHandler, MouseEventHandler } from 'react'; -import { type CanBePromise, suppressEvent, useForm } from '@under-control/forms'; +import { type CanBePromise, suppressEvent, useControlStrict, useForm } from '@under-control/forms'; import clsx from 'clsx'; import { CircleStopIcon, MessageCircle, SendIcon } from 'lucide-react'; -import type { SdkCreateMessageInputT } from '@llm/sdk'; - import { StrictBooleanV } from '@llm/commons'; -import { useLocalStorageObject } from '@llm/commons-front'; +import { useAfterMount, useLocalStorageObject } from '@llm/commons-front'; +import { getSdkAppMentionInChat, type SdkCreateMessageInputT, type SdkTableRowWithIdNameT } from '@llm/sdk'; import { Checkbox } from '@llm/ui'; import { useI18n } from '~/i18n'; import type { SdkRepeatedMessageItemT } from '../messages'; import { ChatReplyMessage } from './chat-reply-message'; +import { ChatSelectApp } from './chat-select-app'; export type ChatInputValue = Omit; 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, + }); + }} + /> + ); + })} +
+ ); + }, +); 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/apps/chat/src/modules/chats/grid/chat-history-placeholder.tsx b/apps/chat/src/modules/chats/grid/chat-history-placeholder.tsx index 8d8bf888..19f1b665 100644 --- a/apps/chat/src/modules/chats/grid/chat-history-placeholder.tsx +++ b/apps/chat/src/modules/chats/grid/chat-history-placeholder.tsx @@ -1,17 +1,10 @@ -import { GhostIcon } from 'lucide-react'; - import { useI18n } from '~/i18n'; +import { GhostPlaceholder } from '~/modules/shared'; export function ChatHistoryPlaceholder() { const t = useI18n().pack.chats.history; return ( -
-
- -
- -
{t.placeholder}
-
+ {t.placeholder} ); } diff --git a/apps/chat/src/modules/shared/ghost-placeholder.tsx b/apps/chat/src/modules/shared/ghost-placeholder.tsx new file mode 100644 index 00000000..e7a5beae --- /dev/null +++ b/apps/chat/src/modules/shared/ghost-placeholder.tsx @@ -0,0 +1,15 @@ +import type { PropsWithChildren } from 'react'; + +import { GhostIcon } from 'lucide-react'; + +export function GhostPlaceholder({ children }: PropsWithChildren) { + return ( +
+
+ +
+ +
{children}
+
+ ); +} diff --git a/apps/chat/src/modules/shared/index.ts b/apps/chat/src/modules/shared/index.ts new file mode 100644 index 00000000..1bcf42a7 --- /dev/null +++ b/apps/chat/src/modules/shared/index.ts @@ -0,0 +1 @@ +export * from './ghost-placeholder'; diff --git a/apps/chat/src/routes/apps/apps.route.tsx b/apps/chat/src/routes/apps/apps.route.tsx index 618b7253..f76402c1 100644 --- a/apps/chat/src/routes/apps/apps.route.tsx +++ b/apps/chat/src/routes/apps/apps.route.tsx @@ -1,74 +1,10 @@ -import { CreateButton, PaginationSearchToolbarItem, PaginationToolbar } from '@llm/ui'; import { useI18n } from '~/i18n'; import { LayoutHeader, PageWithNavigationLayout } from '~/layouts'; -import { AppsGrid } from '~/modules'; +import { AppsContainer } from '~/modules'; import { RouteMetaTags } from '~/routes/shared'; import { AppsTutorial } from './apps-tutorial'; -const SAMPLE_APPS = [ - { - id: '1', - name: 'AI Image Generator', - description: 'Create stunning artwork using advanced AI algorithms', - updatedAt: new Date('2024-01-15'), - }, - { - id: '2', - name: 'Code Analyzer', - description: 'Static code analysis tool with security vulnerability detection', - updatedAt: new Date('2024-01-10'), - }, - { - id: '3', - name: 'Virtual Assistant', - description: 'Smart AI assistant for task automation and scheduling', - updatedAt: new Date('2024-01-05'), - }, - { - id: '4', - name: 'Data Visualizer', - description: 'Interactive charts and graphs for complex data analysis', - updatedAt: new Date('2024-01-01'), - }, - { - id: '5', - name: 'Language Translator', - description: 'Real-time translation tool supporting 50+ languages', - updatedAt: new Date('2023-12-25'), - }, - { - id: '6', - name: 'Smart Home Controller', - description: 'IoT device management and automation platform', - updatedAt: new Date('2023-12-20'), - }, - { - id: '7', - name: 'Video Editor', - description: 'Cloud-based video editing with AI-powered features', - updatedAt: new Date('2023-12-15'), - }, - { - id: '8', - name: 'Password Manager', - description: 'Secure password storage with encryption and sync', - updatedAt: new Date('2023-12-10'), - }, - { - id: '9', - name: 'Note Taking App', - description: 'Collaborative note-taking with markdown support', - updatedAt: new Date('2023-12-05'), - }, - { - id: '10', - name: 'Stock Analyzer', - description: 'Financial market analysis with predictive algorithms', - updatedAt: new Date('2023-12-01'), - }, -] as any; - export function AppsRoute() { const t = useI18n().pack.routes.apps; @@ -82,18 +18,7 @@ export function AppsRoute() { -
- - )} - > - - - - -
+ ); } 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 28b92f7c..590e7169 100644 --- a/packages/commons-front/src/hooks/use-sync-storage-object.ts +++ b/packages/commons-front/src/hooks/use-sync-storage-object.ts @@ -1,5 +1,6 @@ import type { z } from 'zod'; +import deepEq from 'fast-deep-equal'; import { flow, identity, pipe } from 'fp-ts/lib/function'; import * as O from 'fp-ts/lib/Option'; import { useRef } from 'react'; @@ -7,6 +8,7 @@ import { useRef } from 'react'; import { tryParseJSON, tryParseUsingZodSchema } from '@llm/commons'; import { useForceRerender } from './use-force-rerender'; +import { useWindowListener } from './use-window-listener'; type AbstractSyncStorage = { removeItem: (key: string) => void; @@ -44,8 +46,8 @@ export function useSyncStorageObject>( } }; - const get = () => { - if (O.isSome(cache.current)) { + const get = (ignoreCache: boolean = false) => { + if (!ignoreCache && O.isSome(cache.current)) { return cache.current; } @@ -64,12 +66,27 @@ export function useSyncStorageObject>( const set = (value: z.infer) => { storage.setItem(name, JSON.stringify(value)); cache.current = O.some(value); + window.dispatchEvent(new Event('storage')); if (rerenderOnSet) { forceRerender(); } }; + useWindowListener({ + storage: () => { + const newValue = get(true); + + if (!deepEq(newValue, cache.current)) { + cache.current = newValue; + + if (rerenderOnSet) { + forceRerender(); + } + } + }, + }); + if (readBeforeMount) { cache.current = get(); } 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'), diff --git a/packages/sdk/src/modules/dashboard/apps/dto/sdk-search-apps.dto.ts b/packages/sdk/src/modules/dashboard/apps/dto/sdk-search-apps.dto.ts index daea8b16..946171f8 100644 --- a/packages/sdk/src/modules/dashboard/apps/dto/sdk-search-apps.dto.ts +++ b/packages/sdk/src/modules/dashboard/apps/dto/sdk-search-apps.dto.ts @@ -3,6 +3,7 @@ import type { z } from 'zod'; import { SdkArchivedFiltersInputV, SdkDefaultSortInputV, + SdkExcludeIdsFiltersInputV, SdkFilteredPhraseInputV, SdkIdsArrayV, SdkIdsFiltersInputV, @@ -23,6 +24,7 @@ export const SdKSearchAppsInputV = SdkOffsetPaginationInputV .merge(SdkDefaultSortInputV) .merge(SdkArchivedFiltersInputV) .merge(SdkIdsFiltersInputV) + .merge(SdkExcludeIdsFiltersInputV) .merge(SdkFilteredPhraseInputV); export type SdKSearchAppsInputT = z.infer; 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/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) 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'; 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 (