diff --git a/apps/backend/src/migrations/0025-add-internal-projects-fields.ts b/apps/backend/src/migrations/0025-add-internal-projects-fields.ts new file mode 100644 index 00000000..5580dc03 --- /dev/null +++ b/apps/backend/src/migrations/0025-add-internal-projects-fields.ts @@ -0,0 +1,31 @@ +import type { Kysely } from 'kysely'; + +export async function up(db: Kysely): Promise { + await db.schema + .alterTable('projects_files') + .addColumn('message_id', 'uuid', col => col.references('messages.id').onDelete('restrict')) + .execute(); + + await db.schema + .createIndex('projects_files_message_id_index') + .on('projects_files') + .column('message_id') + .execute(); + + await db.schema + .alterTable('projects') + .addColumn('internal', 'boolean', col => col.notNull().defaultTo(false)) + .execute(); +} + +export async function down(db: Kysely): Promise { + await db.schema + .alterTable('projects') + .dropColumn('internal') + .execute(); + + await db.schema + .alterTable('projects_files') + .dropColumn('message_id') + .execute(); +} diff --git a/apps/backend/src/migrations/index.ts b/apps/backend/src/migrations/index.ts index 68b07027..c2071745 100644 --- a/apps/backend/src/migrations/index.ts +++ b/apps/backend/src/migrations/index.ts @@ -23,6 +23,7 @@ import * as dropUniqueNameFromS3Assets from './0021-drop-unique-name-from-s3-ass import * as addIdToProjectFilesTable from './0022-add-id-to-project-files-table'; import * as addProjectsEmbeddingsTable from './0023-add-projects-embeddings-table'; import * as dropUnusedImagesTable from './0024-drop-unused-images-table'; +import * as addInternalProjectsFields from './0025-add-internal-projects-fields'; export const DB_MIGRATIONS = { '0000-add-users-tables': addUsersTables, @@ -50,4 +51,5 @@ export const DB_MIGRATIONS = { '0022-add-id-to-project-files-table': addIdToProjectFilesTable, '0023-add-projects-embeddings-table': addProjectsEmbeddingsTable, '0024-drop-unused-images-table': dropUnusedImagesTable, + '0025-add-internal-projects-fields': addInternalProjectsFields, }; 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 94fc2408..258a8ca7 100644 --- a/apps/backend/src/modules/api/controllers/dashboard/chats.controller.ts +++ b/apps/backend/src/modules/api/controllers/dashboard/chats.controller.ts @@ -26,6 +26,7 @@ import { sdkSchemaValidator, serializeSdkResponseTE, } from '../../helpers'; +import { tryExtractFiles } from '../../helpers/try-extract-files'; import { AuthorizedController } from '../shared/authorized.controller'; @injectable() @@ -120,15 +121,22 @@ export class ChatsController extends AuthorizedController { ) .post( '/:id/messages', - sdkSchemaValidator('json', SdkCreateMessageInputV), async context => pipe( - context.req.valid('json'), - message => messagesService.asUser(context.var.jwt).create({ - message, + await context.req.parseBody(), + tryExtractFiles( + SdkCreateMessageInputV.omit({ + files: true, + }), + ), + TE.chainW(({ content, files }) => messagesService.asUser(context.var.jwt).create({ + files: [...files ?? []], + message: { + content, + }, chat: { id: context.req.param('id'), }, - }), + })), rejectUnsafeSdkErrors, serializeSdkResponseTE>(context), ), diff --git a/apps/backend/src/modules/api/controllers/dashboard/projects.controller.ts b/apps/backend/src/modules/api/controllers/dashboard/projects.controller.ts index 226c5942..aa28049b 100644 --- a/apps/backend/src/modules/api/controllers/dashboard/projects.controller.ts +++ b/apps/backend/src/modules/api/controllers/dashboard/projects.controller.ts @@ -67,7 +67,7 @@ export class ProjectsController extends AuthorizedController { TE.chainW(({ buffer, mimeType, fileName }) => projectsFilesService.asUser(context.var.jwt).uploadFile({ projectId: Number(context.req.param('projectId')), - name: fileName, + fileName, buffer, mimeType, }), diff --git a/apps/backend/src/modules/api/helpers/index.ts b/apps/backend/src/modules/api/helpers/index.ts index 5018686f..aaaa7331 100644 --- a/apps/backend/src/modules/api/helpers/index.ts +++ b/apps/backend/src/modules/api/helpers/index.ts @@ -3,4 +3,5 @@ export * from './reject-unsafe-sdk-errors'; export * from './respond-with-tagged-error'; export * from './sdk-hono-schema-validator'; export * from './serialize-sdk-response-te'; +export * from './try-extract-files'; export * from './try-extract-single-file'; diff --git a/apps/backend/src/modules/api/helpers/try-extract-files.ts b/apps/backend/src/modules/api/helpers/try-extract-files.ts new file mode 100644 index 00000000..f2946e80 --- /dev/null +++ b/apps/backend/src/modules/api/helpers/try-extract-files.ts @@ -0,0 +1,58 @@ +import { Buffer } from 'node:buffer'; + +import { either as E, taskEither as TE } from 'fp-ts'; +import { pipe } from 'fp-ts/lib/function'; +import { z } from 'zod'; + +import { tryParseUsingZodSchema } from '@llm/commons'; +import { SdkInvalidFileFormatError, SdkInvalidRequestError } from '@llm/sdk'; + +import type { ExtractedFile } from './try-extract-single-file'; + +export function tryExtractFiles( + schema?: z.ZodObject, +): (body: Record) => TE.TaskEither< + SdkInvalidFileFormatError | SdkInvalidRequestError, + z.infer> & { files: readonly ExtractedFile[]; } + > { + const baseSchema = z.object({}); + const finalSchema = schema + ? baseSchema.merge(schema) + : baseSchema; + + return (body: Record) => pipe( + TE.fromEither( + pipe( + body, + tryParseUsingZodSchema(finalSchema), + E.mapLeft(error => new SdkInvalidRequestError(error.context)), + ), + ), + TE.chainW(parsedPayload => pipe( + extractAllFilesFromObject(body), + TE.traverseArray(file => ( + TE.tryCatch( + async (): Promise => ({ + buffer: Buffer.from(await file.arrayBuffer()), + mimeType: file.type, + fileName: file.name, + }), + () => new SdkInvalidFileFormatError({ + name: file.name, + mimeType: file.type, + }), + )), + ), + TE.map(extractedFiles => ({ + ...parsedPayload as z.infer>, + files: extractedFiles, + })), + )), + ); +} + +function extractAllFilesFromObject(obj: Record) { + return Object + .values(obj) + .filter(value => value instanceof File); +} diff --git a/apps/backend/src/modules/chats/chats.repo.ts b/apps/backend/src/modules/chats/chats.repo.ts index 313eb01b..a15c067d 100644 --- a/apps/backend/src/modules/chats/chats.repo.ts +++ b/apps/backend/src/modules/chats/chats.repo.ts @@ -14,6 +14,7 @@ import { createUnarchiveRecordsQuery, DatabaseConnectionRepo, DatabaseError, + TableId, TableUuid, TransactionalAttrs, tryGetFirstOrNotExists, @@ -44,6 +45,8 @@ export class ChatsRepo extends createProtectedDatabaseRepo('chats') { unarchiveRecords = createUnarchiveRecordsQuery(this.baseRepo.queryFactoryAttrs); + findById = this.baseRepo.findById; + create = ( { forwardTransaction, @@ -145,6 +148,7 @@ export class ChatsRepo extends createProtectedDatabaseRepo('chats') { 'projects.id as project_id', 'projects.name as project_name', + 'projects.internal as project_internal', ]) .limit(ids.length) .execute(), @@ -169,6 +173,7 @@ export class ChatsRepo extends createProtectedDatabaseRepo('chats') { project_id: projectId, project_name: projectName, + project_internal: projectInternal, ...item }): ChatTableRowWithRelations => ({ @@ -177,6 +182,7 @@ export class ChatsRepo extends createProtectedDatabaseRepo('chats') { ? { id: projectId, name: projectName, + internal: !!projectInternal, } : null, organization: { @@ -203,6 +209,15 @@ export class ChatsRepo extends createProtectedDatabaseRepo('chats') { ); }; + assignToProject = ({ forwardTransaction, id, projectId }: TransactionalAttrs<{ id: TableUuid; projectId: TableId; }>) => + this.baseRepo.update({ + forwardTransaction, + id, + value: { + projectId, + }, + }); + update = ({ forwardTransaction, id, value }: TransactionalAttrs<{ id: TableUuid; value: SdkUpdateChatInputT; }>) => { const { summary } = value; diff --git a/apps/backend/src/modules/chats/chats.service.ts b/apps/backend/src/modules/chats/chats.service.ts index 811dee88..26c9a72e 100644 --- a/apps/backend/src/modules/chats/chats.service.ts +++ b/apps/backend/src/modules/chats/chats.service.ts @@ -12,7 +12,7 @@ import { } from '@llm/sdk'; import { WithAuthFirewall } from '../auth'; -import { TableRowWithUuid } from '../database'; +import { TableId, TableRowWithUuid, TableUuid } from '../database'; import { ChatsFirewall } from './chats.firewall'; import { ChatsRepo } from './chats.repo'; import { ChatsEsIndexRepo, ChatsEsSearchRepo } from './elasticsearch'; @@ -50,4 +50,9 @@ export class ChatsService implements WithAuthFirewall { this.repo.update({ id, value }), TE.tap(() => this.esIndexRepo.findAndIndexDocumentById(id)), ); + + assignToProject = (id: TableUuid, projectId: TableId) => pipe( + this.repo.assignToProject({ id, projectId }), + TE.tap(() => this.esIndexRepo.findAndIndexDocumentById(id)), + ); } diff --git a/apps/backend/src/modules/chats/chats.tables.ts b/apps/backend/src/modules/chats/chats.tables.ts index 416511f9..4fe387c7 100644 --- a/apps/backend/src/modules/chats/chats.tables.ts +++ b/apps/backend/src/modules/chats/chats.tables.ts @@ -20,7 +20,7 @@ export type ChatsTable = & { creator_user_id: ColumnType; organization_id: ColumnType; - project_id: ColumnType | null; + project_id: TableId | null; public: boolean; internal: boolean; }; @@ -31,11 +31,15 @@ type ChatSummaryTableRowRelation = DropTableRowAccessTime< Omit >; +type ChatProjectTableRowRelation = TableRowWithIdName & { + internal: boolean; +}; + export type ChatTableRowWithRelations = & Omit & { summary: ChatSummaryTableRowRelation; organization: TableRowWithIdName; - project: TableRowWithIdName | null; + project: ChatProjectTableRowRelation | null; creator: UserTableRowBaseRelation; }; 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 cceb70c5..8953ef62 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 @@ -29,9 +29,9 @@ const MessagesAbstractEsIndexRepo = createElasticsearchIndexRepo({ uuid: true, }), chat: createIdObjectMapping({}, 'keyword'), - repliedMessage: createIdObjectMapping({}, 'keyword'), + replied_message: createIdObjectMapping({}, 'keyword'), creator: createIdObjectMapping(), - aiModel: createIdObjectMapping(), + ai_model: createIdObjectMapping(), app: createIdObjectMapping(), content: { type: 'text', 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 f8bbbe1e..224ea4e7 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,7 +17,28 @@ export function createAttachAppAIMessage(app: AttachableApp): string { `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}.`, + '', + '--- MANDATORY RESPONSE FORMAT ---', + `EVERY response when using this app MUST start with "#app:${app.id}" tag`, + 'This rule applies to:', + '- Regular responses', + '- Error messages', + '- Debug mode outputs', + '- Any other type of response', + 'Format examples:', + `❌ WRONG: "Here's what I found..."`, + `✅ CORRECT: "#app:${app.id} Here's what I found..."`, + `❌ WRONG: "Sorry, I can't help with that"`, + `✅ CORRECT: "#app:${app.id} Sorry, I can't help with that"`, + '', + '--- FILE CONTEXT HANDLING ---', + 'When user asks about project files:', + '- Stay in character as the app while analyzing files', + '- Provide insights and explanations within the app\'s specific domain/purpose', + '- Do not break character or switch to a general assistant mode', + '- Keep using the app\'s designated response format and style', + '- If file analysis is outside app\'s scope, politely explain this while staying in character', + '- Continue using action buttons and app-specific formatting even when discussing files', '', '--- EXAMPLE BUTTONS: ENGLISH ---', 'For yes/no questions in English:', diff --git a/apps/backend/src/modules/messages/messages.repo.ts b/apps/backend/src/modules/messages/messages.repo.ts index cf0563ac..46772742 100644 --- a/apps/backend/src/modules/messages/messages.repo.ts +++ b/apps/backend/src/modules/messages/messages.repo.ts @@ -1,6 +1,8 @@ import camelcaseKeys from 'camelcase-keys'; import { array as A, taskEither as TE } from 'fp-ts'; import { pipe } from 'fp-ts/lib/function'; +import { sql } from 'kysely'; +import { jsonBuildObject } from 'kysely/helpers/postgres'; import { injectable } from 'tsyringe'; import { @@ -46,6 +48,38 @@ export class MessagesRepo extends createDatabaseRepo('messages') { 'reply_users.id as reply_message_creator_user_id', 'reply_users.email as reply_message_creator_email', + + eb => eb + .selectFrom('projects_files') + .leftJoin('s3_resources', 's3_resources.id', 'projects_files.s3_resource_id') + .leftJoin('s3_resources_buckets', 's3_resources_buckets.id', 's3_resources.bucket_id') + .where('projects_files.message_id', '=', eb.ref('messages.id')) + .select(eb => [ + eb.fn.coalesce( + jsonBuildObject({ + files: eb.fn.jsonAgg( + jsonBuildObject({ + id: eb.ref('projects_files.id'), + resource: jsonBuildObject({ + id: eb.ref('s3_resources.id').$notNull(), + name: eb.ref('s3_resources.name').$notNull(), + type: eb.ref('s3_resources.type').$notNull(), + s3Key: eb.ref('s3_resources.s3_key').$notNull(), + createdAt: eb.ref('s3_resources.created_at').$notNull(), + updatedAt: eb.ref('s3_resources.updated_at').$notNull(), + publicUrl: sql`${eb.ref('s3_resources_buckets.public_base_url')} || '/' || ${eb.ref('s3_resources.s3_key')}`, + bucket: jsonBuildObject({ + id: eb.ref('s3_resources_buckets.id').$notNull(), + name: eb.ref('s3_resources_buckets.name').$notNull(), + }), + }), + }), + ), + }), + sql`'[]'`, + ).as('files'), + ]) + .as('files_json'), ]) .selectAll('messages') .limit(ids.length) @@ -72,6 +106,8 @@ export class MessagesRepo extends createDatabaseRepo('messages') { app_id: appId, app_name: appName, + files_json: filesJson, + ...item }): MessageTableRowWithRelations => ({ ...camelcaseKeys(item), @@ -109,6 +145,7 @@ export class MessagesRepo extends createDatabaseRepo('messages') { name: appName, } : null, + files: filesJson?.files ?? [], })), ), ); diff --git a/apps/backend/src/modules/messages/messages.service.ts b/apps/backend/src/modules/messages/messages.service.ts index 4cd8d08c..69691877 100644 --- a/apps/backend/src/modules/messages/messages.service.ts +++ b/apps/backend/src/modules/messages/messages.service.ts @@ -12,12 +12,15 @@ import { type SdkRequestAIReplyInputT, } from '@llm/sdk'; +import type { ExtractedFile } from '../api/helpers'; import type { TableId, TableRowWithId, TableRowWithUuid, TableUuid } from '../database'; import { AIConnectorService } from '../ai-connector'; import { AppsService } from '../apps'; import { WithAuthFirewall } from '../auth'; +import { ProjectsService } from '../projects'; import { ProjectsEmbeddingsService } from '../projects-embeddings'; +import { ProjectsFilesService } from '../projects-files'; import { MessagesEsIndexRepo, MessagesEsSearchRepo } from './elasticsearch'; import { createActionButtonsPrompt, createAttachAppAIMessage, createReplyAiMessagePrefix } from './helpers'; import { MessagesFirewall } from './messages.firewall'; @@ -25,8 +28,9 @@ import { MessagesRepo } from './messages.repo'; export type CreateUserMessageInputT = { chat: TableRowWithUuid; - message: SdkCreateMessageInputT; + message: Omit; creator: TableRowWithId; + files?: ExtractedFile[]; }; export type AttachAppInputT = { @@ -44,6 +48,8 @@ export class MessagesService implements WithAuthFirewall { @inject(MessagesEsIndexRepo) private readonly esIndexRepo: MessagesEsIndexRepo, @inject(AIConnectorService) private readonly aiConnectorService: AIConnectorService, @inject(ProjectsEmbeddingsService) private readonly projectsEmbeddingsService: ProjectsEmbeddingsService, + @inject(ProjectsFilesService) private readonly projectsFilesService: ProjectsFilesService, + @inject(ProjectsService) private readonly projectsService: ProjectsService, ) {} asUser = (jwt: SdkJwtTokenT) => new MessagesFirewall(jwt, this); @@ -52,7 +58,7 @@ export class MessagesService implements WithAuthFirewall { searchByChatId = this.esSearchRepo.searchByChatId; - createUserMessage = ({ creator, chat, message }: CreateUserMessageInputT) => + createUserMessage = ({ creator, chat, message, files }: CreateUserMessageInputT) => pipe( this.repo.create({ value: { @@ -65,6 +71,23 @@ export class MessagesService implements WithAuthFirewall { role: 'user', }, }), + TE.tap(({ id }) => { + if (!files || files.length === 0) { + return TE.of(undefined); + } + + return pipe( + this.projectsService.ensureChatHasProjectOrCreateInternal(chat.id), + TE.chainW(project => pipe( + files, + TE.traverseArray(file => this.projectsFilesService.uploadFile({ + projectId: project.id, + messageId: id, + ...file, + })), + )), + ); + }), TE.tap(({ id }) => this.esIndexRepo.findAndIndexDocumentById(id)), ); diff --git a/apps/backend/src/modules/messages/messages.tables.ts b/apps/backend/src/modules/messages/messages.tables.ts index e880e21b..ca79a9e5 100644 --- a/apps/backend/src/modules/messages/messages.tables.ts +++ b/apps/backend/src/modules/messages/messages.tables.ts @@ -4,6 +4,7 @@ import type { SdkMessageRoleT } from '@llm/sdk'; import type { NormalizeSelectTableRow, TableId, + TableRowWithId, TableRowWithIdName, TableRowWithUuid, TableUuid, @@ -11,6 +12,7 @@ import type { TableWithUuidColumn, } from '~/modules/database'; +import type { S3ResourcesTableRowWithRelations } from '../s3'; import type { UserTableRowBaseRelation } from '../users'; export type MessagesTable = @@ -35,6 +37,12 @@ type RepliedMessageTableRelationRow = creator: UserTableRowBaseRelation | null; }; +export type MessageFileTableRelationRow = + & TableRowWithId + & { + resource: S3ResourcesTableRowWithRelations; + }; + export type MessageTableRowWithRelations = & Omit & { @@ -43,4 +51,5 @@ export type MessageTableRowWithRelations = creator: UserTableRowBaseRelation | null; aiModel: TableRowWithIdName | null; app: TableRowWithIdName | null; + files: MessageFileTableRelationRow[]; }; diff --git a/apps/backend/src/modules/projects-embeddings/helpers/create-relevant-embeddings-prompt.ts b/apps/backend/src/modules/projects-embeddings/helpers/create-relevant-embeddings-prompt.ts index ee1394b2..3be77bd8 100644 --- a/apps/backend/src/modules/projects-embeddings/helpers/create-relevant-embeddings-prompt.ts +++ b/apps/backend/src/modules/projects-embeddings/helpers/create-relevant-embeddings-prompt.ts @@ -66,22 +66,23 @@ export function createRelevantEmbeddingsPrompt(message: string, embeddings: EsMa '- For file queries: Provide file name and one-sentence description unless more details requested', '- If relevant file found: Suggest its potential usefulness', '- If context not relevant: Provide general response', - '- When mentioning files, ALWAYS add relevant action buttons such as:', - ' CRITICAL: Button labels and actions MUST be in user\'s language!', - ' For English users:', - ' * [action:Details|Tell me more about the content in {filename}]', - ' * [action:Examples|Show me examples from {filename}]', - ' * [action:Related|Show me content related to {filename}]', - ' For Polish users:', - ' * [action:Szczegóły|Powiedz mi więcej o zawartości {filename}]', - ' * [action:Przykłady|Pokaż mi przykłady z {filename}]', - ' * [action:Powiązane|Pokaż mi treści powiązane z {filename}]', - ' * For code files:', - ' - [action:Funkcje|Jakie są główne funkcje w {filename}?]', - ' - [action:Testy|Pokaż mi testy dla {filename}]', - ' * For documentation:', - ' - [action:Podsumowanie|Daj mi podsumowanie {filename}]', - ' - [action:Przykłady|Pokaż przykłady z {filename}]', + '- When mentioning files, add RELEVANT action buttons based on context:', + ' CRITICAL: Button labels and actions:', + ' - MUST be in user\'s language', + ' - MUST be relevant to the content type and context', + ' - MUST be specific to what would be useful for that content', + ' - Format: [action:Label|Question about the content]', + ' Examples (DO NOT COPY DIRECTLY - create contextual ones instead):', + ' For English users - choose relevant actions like:', + ' * Ask for summary of a long document', + ' * Request key points from a meeting notes', + ' * Ask about specific topics in the content', + ' * Request related information', + ' For Polish users - choose relevant actions like:', + ' * Prośba o podsumowanie długiego dokumentu', + ' * Pytanie o kluczowe punkty z notatek', + ' * Pytanie o konkretne tematy w treści', + ' * Prośba o powiązane informacje', '', 'Note: Context is grouped by files for better codebase structure understanding.', ].join('\n'); diff --git a/apps/backend/src/modules/projects-embeddings/projects-embeddings.service.ts b/apps/backend/src/modules/projects-embeddings/projects-embeddings.service.ts index 1581285b..feefacb2 100644 --- a/apps/backend/src/modules/projects-embeddings/projects-embeddings.service.ts +++ b/apps/backend/src/modules/projects-embeddings/projects-embeddings.service.ts @@ -5,7 +5,14 @@ import isValidUTF8 from 'utf-8-validate'; import type { SdkJwtTokenT } from '@llm/sdk'; -import { isNil } from '@llm/commons'; +import { + isImageMimetype, + isLegacyExcelMimetype, + isLegacyWordMimetype, + isNil, + isPDFMimeType, + isXmlOfficeMimetype, +} from '@llm/commons'; import type { WithAuthFirewall } from '../auth'; import type { TableId, TableRowWithUuid } from '../database'; @@ -28,13 +35,6 @@ import { XlsAIEmbeddingGenerator, } from './generators'; import { createRelevantEmbeddingsPrompt, formatVector } from './helpers'; -import { - isImageMimetype, - isLegacyExcelMimetype, - isLegacyWordMimetype, - isPDFMimeType, - isXmlOfficeMimetype, -} from './mimetypes'; import { ProjectsEmbeddingsFirewall } from './projects-embeddings.firewall'; import { ProjectsEmbeddingsRepo } from './projects-embeddings.repo'; import { ProjectEmbeddingsInsertTableRow } from './projects-embeddings.tables'; diff --git a/apps/backend/src/modules/projects-files/elasticsearch/projects-files-es-index.repo.ts b/apps/backend/src/modules/projects-files/elasticsearch/projects-files-es-index.repo.ts index e3641c80..8d7fb846 100644 --- a/apps/backend/src/modules/projects-files/elasticsearch/projects-files-es-index.repo.ts +++ b/apps/backend/src/modules/projects-files/elasticsearch/projects-files-es-index.repo.ts @@ -4,9 +4,8 @@ import snakecaseKeys from 'snakecase-keys'; import { inject, injectable } from 'tsyringe'; import type { TableId } from '~/modules/database'; -import type { S3ResourcesTableRowWithRelations } from '~/modules/s3'; -import { CamelCaseToSnakeCaseObject, tryOrThrowTE } from '@llm/commons'; +import { tryOrThrowTE } from '@llm/commons'; import { createAutocompleteFieldAnalyzeSettings, createBaseDatedRecordMappings, @@ -38,13 +37,7 @@ const ProjectsFilesAbstractEsIndexRepo = createElasticsearchIndexRepo({ }, }); -export type ProjectFileEsDocument = - & Omit, 'resource'> - & { - resource: Omit, 's_3_key'> & { - s3_key: string; - }; - }; +export type ProjectFileEsDocument = EsDocument; @injectable() export class ProjectsFilesEsIndexRepo extends ProjectsFilesAbstractEsIndexRepo { diff --git a/apps/backend/src/modules/projects-files/projects-files.repo.ts b/apps/backend/src/modules/projects-files/projects-files.repo.ts index 789e4dd0..2044bcbc 100644 --- a/apps/backend/src/modules/projects-files/projects-files.repo.ts +++ b/apps/backend/src/modules/projects-files/projects-files.repo.ts @@ -97,6 +97,8 @@ export class ProjectsFilesRepo extends createDatabaseRepo('projects_files') { s3_resource_created_at: s3ResourceCreatedAt, s3_resource_updated_at: s3ResourceUpdatedAt, + message_id: messageId, + ...item }): ProjectFileTableRowWithRelations => ({ ...camelcaseKeys(item), @@ -114,11 +116,15 @@ export class ProjectsFilesRepo extends createDatabaseRepo('projects_files') { name: bucketName, }, }, - project: { id: projectId, name: projectName, }, + message: messageId + ? { + id: messageId, + } + : null, })), ), ); diff --git a/apps/backend/src/modules/projects-files/projects-files.service.ts b/apps/backend/src/modules/projects-files/projects-files.service.ts index 483a8e52..ddc274aa 100644 --- a/apps/backend/src/modules/projects-files/projects-files.service.ts +++ b/apps/backend/src/modules/projects-files/projects-files.service.ts @@ -7,7 +7,7 @@ import type { SdkJwtTokenT } from '@llm/sdk'; import { type PartialBy, tapTaskEitherTE } from '@llm/commons'; import type { WithAuthFirewall } from '../auth'; -import type { TableId } from '../database'; +import type { TableId, TableUuid } from '../database'; import { ProjectsEmbeddingsService } from '../projects-embeddings/projects-embeddings.service'; import { ProjectsRepo } from '../projects/projects.repo'; @@ -35,9 +35,11 @@ export class ProjectsFilesService implements WithAuthFirewall & { projectId: TableId; + messageId?: TableUuid; }, ) => { return pipe( @@ -53,6 +55,7 @@ export class ProjectsFilesService implements WithAuthFirewall this.projectsFilesRepo.create({ value: { projectId, + messageId, s3ResourceId: s3File.id, }, })), @@ -60,7 +63,7 @@ export class ProjectsFilesService implements WithAuthFirewall this.projectsFilesEsIndexRepo.reindexAllProjectFiles(projectId)), diff --git a/apps/backend/src/modules/projects-files/projects-files.tables.ts b/apps/backend/src/modules/projects-files/projects-files.tables.ts index 167ffcae..c9799eb5 100644 --- a/apps/backend/src/modules/projects-files/projects-files.tables.ts +++ b/apps/backend/src/modules/projects-files/projects-files.tables.ts @@ -5,6 +5,8 @@ import type { NormalizeSelectTableRow, TableId, TableRowWithIdName, + TableRowWithUuid, + TableUuid, TableWithDefaultColumns, } from '../database'; import type { S3ResourcesTableRowWithRelations } from '../s3'; @@ -14,6 +16,7 @@ export type ProjectsFilesTable = & { project_id: ColumnType; s3_resource_id: ColumnType; + message_id: ColumnType; }; export type ProjectFileTableRow = NormalizeSelectTableRow; @@ -21,9 +24,10 @@ export type ProjectFileTableRow = NormalizeSelectTableRow; export type ProjectFileTableInsertRow = NormalizeInsertTableRow; export type ProjectFileTableRowWithRelations = - & Omit + & Omit & { resource: S3ResourcesTableRowWithRelations; project: TableRowWithIdName; + message: TableRowWithUuid | null; description: string | null; }; diff --git a/apps/backend/src/modules/projects/elasticsearch/projects-es-index.repo.ts b/apps/backend/src/modules/projects/elasticsearch/projects-es-index.repo.ts index 0c99438c..17f7e068 100644 --- a/apps/backend/src/modules/projects/elasticsearch/projects-es-index.repo.ts +++ b/apps/backend/src/modules/projects/elasticsearch/projects-es-index.repo.ts @@ -29,6 +29,9 @@ const ProjectsAbstractEsIndexRepo = createElasticsearchIndexRepo({ ...createBaseAutocompleteFieldMappings(), ...createArchivedRecordMappings(), organization: createIdNameObjectMapping(), + internal: { + type: 'keyword', + }, description: { type: 'text', analyzer: 'folded_lowercase_analyzer', diff --git a/apps/backend/src/modules/projects/elasticsearch/projects-es-search.repo.ts b/apps/backend/src/modules/projects/elasticsearch/projects-es-search.repo.ts index 89d29821..591f6825 100644 --- a/apps/backend/src/modules/projects/elasticsearch/projects-es-search.repo.ts +++ b/apps/backend/src/modules/projects/elasticsearch/projects-es-search.repo.ts @@ -20,6 +20,10 @@ import { ProjectsEsIndexRepo, } from './projects-es-index.repo'; +type InternalSearchProjectsInputT = SdKSearchProjectsInputT & { + excludeInternal?: boolean; +}; + @injectable() export class ProjectsEsSearchRepo { constructor( @@ -31,7 +35,7 @@ export class ProjectsEsSearchRepo { TE.map(ProjectsEsSearchRepo.mapOutputHit), ); - search = (dto: SdKSearchProjectsInputT) => + search = (dto: InternalSearchProjectsInputT) => pipe( this.indexRepo.search( ProjectsEsSearchRepo.createEsRequestSearchBody(dto).toJSON(), @@ -53,11 +57,12 @@ export class ProjectsEsSearchRepo { private static createEsRequestSearchFilters = ( { + excludeInternal = true, phrase, ids, organizationIds, archived, - }: SdKSearchProjectsInputT, + }: InternalSearchProjectsInputT, ): esb.Query => esb.boolQuery().must( rejectFalsyItems([ @@ -73,6 +78,7 @@ export class ProjectsEsSearchRepo { .minimumShouldMatch(1) ), !isNil(archived) && esb.termQuery('archived', archived), + excludeInternal && esb.termQuery('internal', false), ]), ); diff --git a/apps/backend/src/modules/projects/projects.service.ts b/apps/backend/src/modules/projects/projects.service.ts index 6d4d15c9..0cd289fa 100644 --- a/apps/backend/src/modules/projects/projects.service.ts +++ b/apps/backend/src/modules/projects/projects.service.ts @@ -5,7 +5,6 @@ import { inject, injectable } from 'tsyringe'; import type { SdkCreateProjectInputT, SdkJwtTokenT, - SdkTableRowIdT, SdkUpdateProjectInputT, } from '@llm/sdk'; @@ -17,8 +16,9 @@ import { } from '@llm/commons'; import type { WithAuthFirewall } from '../auth'; -import type { TableId, TableRowWithId } from '../database'; +import type { TableId, TableRowWithId, TableUuid } from '../database'; +import { ChatsService } from '../chats/chats.service'; import { ProjectsEsIndexRepo, ProjectsEsSearchRepo } from './elasticsearch'; import { ProjectsFirewall } from './projects.firewall'; import { ProjectsRepo } from './projects.repo'; @@ -29,23 +29,43 @@ export class ProjectsService implements WithAuthFirewall { @inject(ProjectsRepo) private readonly repo: ProjectsRepo, @inject(ProjectsEsSearchRepo) private readonly esSearchRepo: ProjectsEsSearchRepo, @inject(ProjectsEsIndexRepo) private readonly esIndexRepo: ProjectsEsIndexRepo, + @inject(ChatsService) private readonly chatsService: ChatsService, ) {} asUser = (jwt: SdkJwtTokenT) => new ProjectsFirewall(jwt, this); get = this.esSearchRepo.get; - unarchive = (id: SdkTableRowIdT) => pipe( + unarchive = (id: TableId) => pipe( this.repo.unarchive({ id }), TE.tap(() => this.esIndexRepo.findAndIndexDocumentById(id)), ); - archive = (id: SdkTableRowIdT) => pipe( + archive = (id: TableId) => pipe( this.repo.archive({ id }), TE.tap(() => this.esIndexRepo.findAndIndexDocumentById(id)), ); - archiveSeqByOrganizationId = (organizationId: SdkTableRowIdT) => TE.fromTask( + ensureChatHasProjectOrCreateInternal = (chatId: TableUuid) => pipe( + this.chatsService.get(chatId), + TE.chainW((chat) => { + if (chat.project) { + return TE.right(chat.project); + } + + return pipe( + this.create({ + internal: true, + organization: chat.organization, + name: `Unnamed Project - ${Date.now()}`, + description: null, + }), + TE.tap(project => this.chatsService.assignToProject(chatId, project.id)), + ); + }), + ); + + archiveSeqByOrganizationId = (organizationId: TableId) => TE.fromTask( pipe( this.repo.createIdsIterator({ where: [['organizationId', '=', organizationId]], @@ -76,10 +96,11 @@ export class ProjectsService implements WithAuthFirewall { search = this.esSearchRepo.search; - create = ({ organization, ...values }: SdkCreateProjectInputT) => pipe( + create = ({ internal, organization, ...values }: SdkCreateProjectInputT & { internal?: boolean; }) => pipe( this.repo.create({ value: { ...values, + internal: !!internal, organizationId: organization.id, }, }), diff --git a/apps/backend/src/modules/projects/projects.tables.ts b/apps/backend/src/modules/projects/projects.tables.ts index e7e4d1d9..38042cc5 100644 --- a/apps/backend/src/modules/projects/projects.tables.ts +++ b/apps/backend/src/modules/projects/projects.tables.ts @@ -15,6 +15,7 @@ export type ProjectsTable = organization_id: ColumnType; name: string; description: string | null; + internal: boolean; }; export type ProjectTableRow = NormalizeSelectTableRow; diff --git a/apps/backend/src/modules/s3/s3.service.ts b/apps/backend/src/modules/s3/s3.service.ts index aa19e77a..05ac13ae 100644 --- a/apps/backend/src/modules/s3/s3.service.ts +++ b/apps/backend/src/modules/s3/s3.service.ts @@ -43,7 +43,7 @@ export class S3Service { { bucketId, buffer, - name, + fileName, mimeType, s3Dir = '', }: UploadFileAttrs, @@ -68,7 +68,7 @@ export class S3Service { value: { bucketId, s3Key, - name, + name: fileName, type: 'other', }, })), @@ -116,6 +116,6 @@ export type UploadFileAttrs = { bucketId: TableId; buffer: UploadFilePayload; mimeType: string; - name: string; + fileName: string; s3Dir?: string; }; diff --git a/apps/chat/src/i18n/packs/i18n-lang-en.ts b/apps/chat/src/i18n/packs/i18n-lang-en.ts index 9dcad630..07ed8821 100644 --- a/apps/chat/src/i18n/packs/i18n-lang-en.ts +++ b/apps/chat/src/i18n/packs/i18n-lang-en.ts @@ -220,7 +220,9 @@ export const I18N_PACK_EN = deepmerge(I18N_FORWARDED_EN_PACK, { less: 'Show less', }, send: 'Send', + cancel: 'Cancel', submitOnEnter: 'Submit on Enter', + attachFile: 'Attach file', refresh: 'Refresh response', reply: 'Reply to this message', addApp: 'Add app', diff --git a/apps/chat/src/i18n/packs/i18n-lang-pl.ts b/apps/chat/src/i18n/packs/i18n-lang-pl.ts index 8767e497..f72fd25d 100644 --- a/apps/chat/src/i18n/packs/i18n-lang-pl.ts +++ b/apps/chat/src/i18n/packs/i18n-lang-pl.ts @@ -218,7 +218,9 @@ export const I18N_PACK_PL: I18nLangPack = deepmerge(I18N_FORWARDED_PL_PACK, { }, actions: { send: 'Wyślij', + cancel: 'Anuluj', submitOnEnter: 'Wyślij po naciśnięciu Enter', + attachFile: 'Załącz plik', refresh: 'Odśwież odpowiedź', reply: 'Odpowiedz na tę wiadomość', expand: { diff --git a/apps/chat/src/modules/chats/conversation/chat-conversation-panel.tsx b/apps/chat/src/modules/chats/conversation/chat-conversation-panel.tsx index 50d8e3b8..ebb6397a 100644 --- a/apps/chat/src/modules/chats/conversation/chat-conversation-panel.tsx +++ b/apps/chat/src/modules/chats/conversation/chat-conversation-panel.tsx @@ -191,7 +191,11 @@ export const ChatConversationPanel = memo(({ ref, chat, initialMessages, classNa ref={messagesContainerRef} className={clsx( 'relative z-10 flex-1 [&::-webkit-scrollbar]:hidden p-4 [-ms-overflow-style:none] overflow-y-scroll [scrollbar-width:none]', - flickeringIndicator.visible ? 'opacity-100' : 'opacity-0', // Avoid scroll flickering on first render + + // Avoid scroll flickering on first render + flickeringIndicator.visible + ? 'opacity-100' + : 'opacity-0', )} onLoad={scrollConversation} > diff --git a/apps/chat/src/modules/chats/conversation/files/file-card.tsx b/apps/chat/src/modules/chats/conversation/files/file-card.tsx new file mode 100644 index 00000000..e8dd4af8 --- /dev/null +++ b/apps/chat/src/modules/chats/conversation/files/file-card.tsx @@ -0,0 +1,149 @@ +import clsx from 'clsx'; +import { + FileAxis3DIcon, + FileIcon, + FileSpreadsheetIcon, + FileTextIcon, + ImageIcon, + XIcon, +} from 'lucide-react'; +import { useMemo } from 'react'; + +import { + isImageFileUrl, + isPDFFileUrl, + isSpreadsheetFileUrl, + isWordFileUrl, +} from '@llm/commons'; +import { useI18n } from '~/i18n'; + +export type FileCardFile = + | File + | { name: string; publicUrl: string; }; + +export type FileCardProps = { + file: FileCardFile; + limitWidth?: boolean; + withBackground?: boolean; + onRemove?: () => void; +}; + +export function FileCard({ file, withBackground, limitWidth = true, onRemove }: FileCardProps) { + const { pack } = useI18n(); + const { type, bgColor, icon: IconComponent } = getFileTypeAndColor(file.name); + + const isImage = isImageFileUrl(file.name); + const fileUrl = useMemo(() => { + if ('publicUrl' in file) { + return file.publicUrl; + } + + return URL.createObjectURL(file); + }, [file]); + + const onDownload = () => { + if (fileUrl) { + window.open(fileUrl, '_blank'); + } + }; + + return ( +
+ {onRemove && ( + + )} + + {isImage && fileUrl + ? ( + {file.name} + ) + : ( +
+
+ +
+ +
+ + {file.name} + + + + {type} + +
+
+ )} +
+ ); +} + +function getFileTypeAndColor(url: string) { + if (isSpreadsheetFileUrl(url)) { + return { + type: 'Excel', + bgColor: 'bg-[#217346]', + icon: FileSpreadsheetIcon, + }; + } + + if (isWordFileUrl(url)) { + return { + type: 'Word', + bgColor: 'bg-[#2B579A]', + icon: FileTextIcon, + }; + } + + if (isPDFFileUrl(url)) { + return { + type: 'PDF', + bgColor: 'bg-[#D93F3F]', + icon: FileAxis3DIcon, + }; + } + + if (isImageFileUrl(url)) { + return { + type: 'Image', + bgColor: 'bg-purple-500', + icon: ImageIcon, + }; + } + + return { + type: 'TXT', + bgColor: 'bg-gray-400', + icon: FileIcon, + }; +} diff --git a/apps/chat/src/modules/chats/conversation/files/files-cards-controlled-list.tsx b/apps/chat/src/modules/chats/conversation/files/files-cards-controlled-list.tsx new file mode 100644 index 00000000..f369fe3d --- /dev/null +++ b/apps/chat/src/modules/chats/conversation/files/files-cards-controlled-list.tsx @@ -0,0 +1,40 @@ +import { controlled } from '@under-control/forms'; +import clsx from 'clsx'; + +import { without } from '@llm/commons'; + +import type { FileCardFile } from './file-card'; + +import { FilesCardsList } from './files-cards-list'; + +type Props = { + className?: string; +}; + +export const FilesCardsControlledList = controlled(({ className, control: { value, setValue } }) => { + const handleRemove = (file: FileCardFile) => { + setValue({ + value: without([file])(value), + }); + }; + + if (!value?.length) { + return null; + } + + return ( +
+ ({ + onRemove: () => handleRemove(file), + })} + /> +
+ ); +}); diff --git a/apps/chat/src/modules/chats/conversation/files/files-cards-list.tsx b/apps/chat/src/modules/chats/conversation/files/files-cards-list.tsx new file mode 100644 index 00000000..00320c71 --- /dev/null +++ b/apps/chat/src/modules/chats/conversation/files/files-cards-list.tsx @@ -0,0 +1,58 @@ +import clsx from 'clsx'; +import { PaperclipIcon } from 'lucide-react'; +import { useMemo } from 'react'; +import { v4 } from 'uuid'; + +import { FileCard, type FileCardFile, type FileCardProps } from './file-card'; + +type Props = { + className?: string; + items: FileCardFile[]; + itemPropsFn?: (file: FileCardFile) => Omit; + withListIcon?: boolean; +}; + +export function FilesCardsList({ className, itemPropsFn, items, withListIcon }: Props) { + const mappedFiles = useMemo(() => { + const fileNames = new Set(); + const duplicateNames = new Set(); + + items.forEach((file) => { + if (fileNames.has(file.name)) { + duplicateNames.add(file.name); + } + + fileNames.add(file.name); + }); + + return items.map(file => ({ + id: duplicateNames.has(file.name) ? v4() : file.name, + file, + })); + }, [items]); + + if (!mappedFiles) { + return null; + } + + return ( +
+ {withListIcon && ( + + )} + + {mappedFiles.map(({ file, id }) => ( + + ))} +
+ ); +} diff --git a/apps/chat/src/modules/chats/conversation/files/index.ts b/apps/chat/src/modules/chats/conversation/files/index.ts new file mode 100644 index 00000000..eb2450b5 --- /dev/null +++ b/apps/chat/src/modules/chats/conversation/files/index.ts @@ -0,0 +1,3 @@ +export * from './files-cards-controlled-list'; +export * from './files-cards-list'; +export * from './select-chat-file'; diff --git a/apps/chat/src/modules/chats/conversation/files/select-chat-file.tsx b/apps/chat/src/modules/chats/conversation/files/select-chat-file.tsx new file mode 100644 index 00000000..30022227 --- /dev/null +++ b/apps/chat/src/modules/chats/conversation/files/select-chat-file.tsx @@ -0,0 +1,3 @@ +import { selectFile } from '@llm/commons'; + +export const selectChatFile = selectFile('.pdf,.csv,.doc,.docx,.xls,.xlsx,.png,.jpg,.jpeg,.bmp,.txt,.html,.md'); 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 b9273283..a9fe027a 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 @@ -4,6 +4,7 @@ import type { Overwrite } from '@llm/commons'; import { type SdkCreateMessageInputT, + type SdkMessageFileT, type SdkMessageT, type SdkTableRowWithIdNameT, useSdkForLoggedIn, @@ -28,13 +29,14 @@ export function useOptimisticResponseCreator() { }); return { - user: (message: SdkCreateMessageInputT): OptimisticMessageOutputT => ({ + user: ({ content, files }: SdkCreateMessageInputT): OptimisticMessageOutputT => ({ ...createBaseMessageFields(), - content: message.content, + content, role: 'user', aiModel: null, repliedMessage: null, app: null, + files: (files ?? []).map(createOptimisticResponseFile), creator: { id: token.sub, email: token.email, @@ -46,6 +48,7 @@ export function useOptimisticResponseCreator() { observable: AIStreamObservable, ): OptimisticMessageOutputT => ({ ...createBaseMessageFields(), + files: [], content: observable, role: 'assistant', aiModel, @@ -56,6 +59,7 @@ export function useOptimisticResponseCreator() { app: (app: SdkTableRowWithIdNameT): OptimisticMessageOutputT => ({ ...createBaseMessageFields(), + files: [], content: 'System message', role: 'assistant', creator: null, @@ -66,6 +70,24 @@ export function useOptimisticResponseCreator() { }; } +function createOptimisticResponseFile(file: File): SdkMessageFileT { + return { + id: Date.now() + Math.random(), + resource: { + bucket: { + id: -1, + name: 'temp', + }, + createdAt: new Date(), + updatedAt: new Date(), + id: -1, + name: file.name, + publicUrl: URL.createObjectURL(file), + type: 'other', + }, + }; +} + export function extractOptimisticMessageContent( message: Pick, ): string { diff --git a/apps/chat/src/modules/chats/conversation/hooks/use-reply-conversation-handler.tsx b/apps/chat/src/modules/chats/conversation/hooks/use-reply-conversation-handler.tsx index c4299dfc..75ecbe9f 100644 --- a/apps/chat/src/modules/chats/conversation/hooks/use-reply-conversation-handler.tsx +++ b/apps/chat/src/modules/chats/conversation/hooks/use-reply-conversation-handler.tsx @@ -54,6 +54,7 @@ export function useReplyConversationHandler({ initialMessages, chat }: Attrs) { replyObservable, { aiModel, + files, content, replyToMessage, }: Overwrite createMessage( chat.id, { + files, content, replyToMessage, }, @@ -104,14 +106,14 @@ export function useReplyConversationHandler({ initialMessages, chat }: Attrs) { optimistic: ({ before: replyObservable, result: { items, total }, - args: [{ content, aiModel, replyToMessage }], + args: [{ content, aiModel, files, replyToMessage }], }) => ({ replyObservable, total: total + 1, items: [ ...items, { - ...createOptimisticResponse.user({ content }), + ...createOptimisticResponse.user({ content, files }), repliedMessage: replyToMessage || null, }, createOptimisticResponse.bot(aiModel, replyObservable), diff --git a/apps/chat/src/modules/chats/conversation/index.ts b/apps/chat/src/modules/chats/conversation/index.ts index 392cc47b..1545b869 100644 --- a/apps/chat/src/modules/chats/conversation/index.ts +++ b/apps/chat/src/modules/chats/conversation/index.ts @@ -1 +1,10 @@ +export * from './chat-attached-app'; +export * from './chat-background'; +export * from './chat-conversation-panel'; export * from './chat-conversation-with-sidebar'; +export * from './config-panel'; +export * from './content-parse'; +export * from './files'; +export * from './hooks'; +export * from './input-toolbar'; +export * from './messages'; 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 62664ee4..78718696 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 @@ -2,9 +2,10 @@ import type { KeyboardEventHandler, MouseEventHandler } from 'react'; import { type CanBePromise, suppressEvent, useControlStrict, useForm } from '@under-control/forms'; import clsx from 'clsx'; -import { CircleStopIcon, MessageCircle, SendIcon } from 'lucide-react'; +import { pipe } from 'fp-ts/function'; +import { CircleStopIcon, PaperclipIcon, SendIcon } from 'lucide-react'; -import { StrictBooleanV } from '@llm/commons'; +import { StrictBooleanV, tapTaskOption } from '@llm/commons'; import { useAfterMount, useLocalStorageObject } from '@llm/commons-front'; import { getSdkAppMentionInChat, type SdkCreateMessageInputT, type SdkTableRowWithIdNameT } from '@llm/sdk'; import { Checkbox } from '@llm/ui'; @@ -12,6 +13,7 @@ import { useI18n } from '~/i18n'; import type { SdkRepeatedMessageItemT } from '../messages'; +import { FilesCardsControlledList, selectChatFile } from '../files'; import { ChatChooseAppButton } from './chat-choose-app-button'; import { ChatReplyMessage } from './chat-reply-message'; import { ChatSelectApp } from './chat-select-app'; @@ -68,11 +70,13 @@ export function ChatInputToolbar( } = useForm({ defaultValue: { content: '', + files: [], }, onSubmit: (newValue) => { setValue({ value: { content: '', + files: [], }, }); @@ -91,7 +95,7 @@ export function ChatInputToolbar( const isTypingDisabled = disabled || replying; - const handleKeyDown: KeyboardEventHandler = (event) => { + const handleKeyDown: KeyboardEventHandler = (event) => { if (submitOnEnterStorage.getOrNull() && event.key === 'Enter' && !event.shiftKey) { event.preventDefault(); @@ -106,6 +110,18 @@ export function ChatInputToolbar( onCancelSubmit?.(); }; + const onAttachFile = pipe( + selectChatFile, + tapTaskOption((file) => { + setValue({ + value: { + ...value, + files: [...(value.files ?? []), file], + }, + }); + }), + ); + useAfterMount(() => { if (apps.length) { selectedApp.setValue({ @@ -115,10 +131,7 @@ export function ChatInputToolbar( }); return ( -
+ {replyToMessage && ( )} -
-
- +
+ + +