From 8a57bd974dc3e3f32651a66904f20559d2377bae Mon Sep 17 00:00:00 2001 From: Rob Royce Date: Mon, 15 Jan 2024 10:18:09 -0800 Subject: [PATCH 01/37] feat(Import): new URL import dialog for importing multiple URLs at a time. --- src/kc_angular/src/app/app.module.ts | 4 +- .../app/components/shared/create.component.ts | 18 +- .../components/shared/web.import.component.ts | 228 ++++++++++++++++++ .../ingest-services/ingest.service.ts | 9 +- 4 files changed, 251 insertions(+), 8 deletions(-) create mode 100644 src/kc_angular/src/app/components/shared/web.import.component.ts diff --git a/src/kc_angular/src/app/app.module.ts b/src/kc_angular/src/app/app.module.ts index 659c51cb..7144a1af 100755 --- a/src/kc_angular/src/app/app.module.ts +++ b/src/kc_angular/src/app/app.module.ts @@ -1,5 +1,5 @@ /* - * Copyright (c) 2022-2023 Rob Royce + * Copyright (c) 2022-2024 Rob Royce * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -162,6 +162,7 @@ import { SourceNotesComponent } from '@components/source-components/source.notes import { ProTipDirective } from './directives/pro-tip.directive'; import { ProTipsComponent } from '@components/shared/pro-tips.component'; import { ChatInputComponent } from '@components/chat-components/chat.input.component'; +import { WebImportComponent } from '@components/shared/web.import.component'; @NgModule({ declarations: [ @@ -240,6 +241,7 @@ import { ChatInputComponent } from '@components/chat-components/chat.input.compo SanitizeHtmlPipe, ProTipDirective, ProTipsComponent, + WebImportComponent, ], imports: [ A11yModule, diff --git a/src/kc_angular/src/app/components/shared/create.component.ts b/src/kc_angular/src/app/components/shared/create.component.ts index 6e4508ce..a2d29ac2 100755 --- a/src/kc_angular/src/app/components/shared/create.component.ts +++ b/src/kc_angular/src/app/components/shared/create.component.ts @@ -1,5 +1,5 @@ /* - * Copyright (c) 2022-2023 Rob Royce + * Copyright (c) 2022-2024 Rob Royce * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -77,18 +77,16 @@ import { ConfirmationService } from 'primeng/api'; (change)="onNewFile($event)" /> @@ -132,6 +130,14 @@ import { ConfirmationService } from 'primeng/api'; + + + + `, styles: [], }) @@ -142,6 +148,8 @@ export class CreateComponent { files: any[] = []; + showImportDialog = false; + constructor( private dialog: DialogService, private confirm: ConfirmationService, diff --git a/src/kc_angular/src/app/components/shared/web.import.component.ts b/src/kc_angular/src/app/components/shared/web.import.component.ts new file mode 100644 index 00000000..cb16273a --- /dev/null +++ b/src/kc_angular/src/app/components/shared/web.import.component.ts @@ -0,0 +1,228 @@ +/* + * Copyright (c) 2024 Rob Royce + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Component, HostListener, ViewChild } from '@angular/core'; +import { FormBuilder, FormGroup, Validators } from '@angular/forms'; +import { + KnowledgeSourceFactoryRequest, + KsFactoryService, +} from '@services/factory-services/ks-factory.service'; +import { KnowledgeSource } from '@app/models/knowledge.source.model'; +import { KsCommandService } from '@services/command-services/ks-command.service'; +import { IngestService } from '@services/ingest-services/ingest.service'; +import { ProjectService } from '@services/factory-services/project.service'; +import { tap } from 'rxjs'; + +@Component({ + selector: 'app-import-web', + template: ` +
+ +
+ +
+
+ + + + + Title + + + + + + + + + + + + + + + + + + + +
+ +
+ + +
+
+ `, +}) +export class WebImportComponent { + // Handle for form input + @ViewChild('linkInput', { static: false }) linkInput: any; + + linkForm: FormGroup; + + sources: KnowledgeSource[] = []; + + destinations: { name: string; code: string }[] = [ + { name: 'Inbox', code: '1' }, + { name: 'Project', code: '2' }, + ]; + + destination: { name: string; code: string } = this.destinations[0]; + + constructor( + private formBuilder: FormBuilder, + private factory: KsFactoryService, + private command: KsCommandService, + private ingest: IngestService, + private projects: ProjectService + ) { + this.linkForm = formBuilder.group({ + url: ['', [Validators.required, Validators.pattern('https?://.+[.]+.+')]], + }); + + projects.currentProject + .pipe( + tap((project) => { + if (project) { + this.destinations = [ + { name: 'Inbox', code: '1' }, + { name: `Project (${project.name})`, code: '2' }, + ]; + } + }) + ) + .subscribe(); + } + + // HostListener for pasting text + @HostListener('paste', ['$event']) + onPaste(event: ClipboardEvent) { + const text = event.clipboardData?.getData('text'); + + // Get all URLs in text + const regex = /https?:\/\/[^\s]+/g; + const matches = text?.match(regex); + + // Add URLs to array + if (matches) { + const urls: string[] = []; + matches.forEach((match) => { + // Remove commas from end of URLs + if (match.endsWith(',')) { + match = match.slice(0, -1); + } + urls.push(match); + }); + this.createSource(urls); + } + + // Clear form by resetting URL field, making sure the value is updated + setTimeout(() => { + this.linkForm.setValue({ + url: '', + }); + this.linkForm.reset(); + }); + } + + onSubmit(value: string) {} + + createSource(urls: string[]) { + const requests: KnowledgeSourceFactoryRequest = { + ingestType: 'website', + links: urls, + }; + + this.factory.many(requests).then((sources) => { + for (const source of sources) { + this.sources.push(source); + } + }); + } + + addFlag(source: KnowledgeSource) { + source.flagged = !source.flagged; + } + + removeSource(source: KnowledgeSource) { + this.sources = this.sources.filter((s) => s !== source); + } + + openSource(source: KnowledgeSource) { + this.command.open(source); + } + + previewSource(source: KnowledgeSource) { + this.command.preview(source); + } + + import(destination: string) { + console.log('Importing to ' + destination); + + if (destination === 'Inbox') { + this.ingest.enqueue(this.sources); + } else { + // TODO: Implement project import + } + + this.linkForm.reset(); + this.sources = []; + } +} diff --git a/src/kc_angular/src/app/services/ingest-services/ingest.service.ts b/src/kc_angular/src/app/services/ingest-services/ingest.service.ts index fe854f01..149eb03b 100755 --- a/src/kc_angular/src/app/services/ingest-services/ingest.service.ts +++ b/src/kc_angular/src/app/services/ingest-services/ingest.service.ts @@ -1,5 +1,5 @@ /* - * Copyright (c) 2023 Rob Royce + * Copyright (c) 2023-2024 Rob Royce * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -85,7 +85,8 @@ export class IngestService implements OnDestroy { this.notify.warn( 'Ingest Service', 'Ignoring Duplicate', - `Source: ${ks.title}` + `Source: ${ks.title}`, + 'toast' ); continue; } @@ -93,6 +94,10 @@ export class IngestService implements OnDestroy { ksNext.push(ks); } + if (ksNext.length <= 0) { + return; + } + ksQueue = ksQueue.concat(ksNext); this._queue.next(ksQueue); From 31d36e91fcfd7f6ac7e9a7c9cf7f6ab8e85e39c6 Mon Sep 17 00:00:00 2001 From: Rob Royce Date: Mon, 15 Jan 2024 10:27:02 -0800 Subject: [PATCH 02/37] feat(Import): implement import directly to project. --- .../app/components/shared/create.component.ts | 2 +- .../components/shared/web.import.component.ts | 22 ++++++++++++++++--- .../ingest-services/ingest.service.ts | 2 +- 3 files changed, 21 insertions(+), 5 deletions(-) diff --git a/src/kc_angular/src/app/components/shared/create.component.ts b/src/kc_angular/src/app/components/shared/create.component.ts index a2d29ac2..bc4fb1da 100755 --- a/src/kc_angular/src/app/components/shared/create.component.ts +++ b/src/kc_angular/src/app/components/shared/create.component.ts @@ -136,7 +136,7 @@ import { ConfirmationService } from 'primeng/api'; header="Import Sources" [(visible)]="showImportDialog" > - + `, styles: [], diff --git a/src/kc_angular/src/app/components/shared/web.import.component.ts b/src/kc_angular/src/app/components/shared/web.import.component.ts index cb16273a..1b2596e6 100644 --- a/src/kc_angular/src/app/components/shared/web.import.component.ts +++ b/src/kc_angular/src/app/components/shared/web.import.component.ts @@ -14,7 +14,13 @@ * limitations under the License. */ -import { Component, HostListener, ViewChild } from '@angular/core'; +import { + Component, + EventEmitter, + HostListener, + Output, + ViewChild, +} from '@angular/core'; import { FormBuilder, FormGroup, Validators } from '@angular/forms'; import { KnowledgeSourceFactoryRequest, @@ -25,6 +31,7 @@ import { KsCommandService } from '@services/command-services/ks-command.service' import { IngestService } from '@services/ingest-services/ingest.service'; import { ProjectService } from '@services/factory-services/project.service'; import { tap } from 'rxjs'; +import { ProjectUpdateRequest } from '@app/models/project.model'; @Component({ selector: 'app-import-web', @@ -112,7 +119,8 @@ import { tap } from 'rxjs'; `, }) export class WebImportComponent { - // Handle for form input + @Output() close = new EventEmitter(); + @ViewChild('linkInput', { static: false }) linkInput: any; linkForm: FormGroup; @@ -219,10 +227,18 @@ export class WebImportComponent { if (destination === 'Inbox') { this.ingest.enqueue(this.sources); } else { - // TODO: Implement project import + const project = this.projects.getCurrentProjectId(); + if (project) { + const req: ProjectUpdateRequest = { + id: project, + addKnowledgeSource: this.sources, + }; + this.projects.updateProjects([req]); + } } this.linkForm.reset(); this.sources = []; + this.close.emit(); } } diff --git a/src/kc_angular/src/app/services/ingest-services/ingest.service.ts b/src/kc_angular/src/app/services/ingest-services/ingest.service.ts index 149eb03b..bdcd3809 100755 --- a/src/kc_angular/src/app/services/ingest-services/ingest.service.ts +++ b/src/kc_angular/src/app/services/ingest-services/ingest.service.ts @@ -108,7 +108,7 @@ export class IngestService implements OnDestroy { this.notify.success( 'IngestService', 'Source Imported', - `Imported ${ksList.length} Source${ksList.length > 1 ? 's' : ''}.` + `Imported ${ksNext.length} Source${ksNext.length > 1 ? 's' : ''}.` ); } From 943a24b56d1ed11059f1de0e361f368641288055 Mon Sep 17 00:00:00 2001 From: Rob Royce Date: Tue, 16 Jan 2024 08:36:00 -0800 Subject: [PATCH 03/37] fix: a bug where chat replies may be gigantic due to improper handling of intermittent summaries. --- .../src/local/controllers/chat.controller.ts | 11 +++++++++-- .../src/local/middleware/DocumentSummarizer.ts | 3 +++ 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/src/kc_electron/src/local/controllers/chat.controller.ts b/src/kc_electron/src/local/controllers/chat.controller.ts index de4e5e5b..b73d04c6 100755 --- a/src/kc_electron/src/local/controllers/chat.controller.ts +++ b/src/kc_electron/src/local/controllers/chat.controller.ts @@ -62,7 +62,10 @@ export default class ChatController { async summarize(text: string, succinct = true, verbose = false) { if (!(await this.veryifyApi()) || !this.openai) { - return text; + console.warn( + "[ChatController]: OpenAI API not initialized or unavailable, skipping summarization..." + ); + return ""; } const limited = this.tokenizerUtils.limitText.bind(this.tokenizerUtils); @@ -118,6 +121,10 @@ export default class ChatController { const limitedChunksText = limitedChunks(text); let chunkedResponse = ""; + console.log( + `[ChatController]: Limiting text to ${limitedChunksText.length} chunks...` + ); + for (let i = 0; i < limitedChunksText.length; i++) { // If the last message is a user message, remove it from the array // This is to ensure that each chunk is removed from the previous query @@ -129,7 +136,7 @@ export default class ChatController { messages.push({ role: "user", - content: `=========\n"""${chunk}"""\n=========`, + content: `===\n"""${chunk}"""\n===`, }); // Sleep for 0.25 seconds to avoid rate limiting diff --git a/src/kc_electron/src/local/middleware/DocumentSummarizer.ts b/src/kc_electron/src/local/middleware/DocumentSummarizer.ts index 08f2486a..e6f921c1 100644 --- a/src/kc_electron/src/local/middleware/DocumentSummarizer.ts +++ b/src/kc_electron/src/local/middleware/DocumentSummarizer.ts @@ -28,6 +28,9 @@ export class DocumentSummarizer { return async (req: Request, res: Response, next: NextFunction) => { // If the request already contains a summary, skip this middleware if (req.body.summary) { + console.log( + "[DocumentSummarizer]: Summary already exists, skipping DocumentSummarizer" + ); return next(); } From a5a1db21ba039b3c6918a7824e1952fc7f6bc772 Mon Sep 17 00:00:00 2001 From: Rob Royce Date: Tue, 16 Jan 2024 19:02:44 -0800 Subject: [PATCH 04/37] feat(Import): add special handling when importing PDFs directly from arXiv, use abstract to get metadata. --- .../factory-services/ks-factory.service.ts | 21 ++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/src/kc_angular/src/app/services/factory-services/ks-factory.service.ts b/src/kc_angular/src/app/services/factory-services/ks-factory.service.ts index 324d230d..7e3e4b21 100755 --- a/src/kc_angular/src/app/services/factory-services/ks-factory.service.ts +++ b/src/kc_angular/src/app/services/factory-services/ks-factory.service.ts @@ -1,5 +1,5 @@ /* - * Copyright (c) 2023 Rob Royce + * Copyright (c) 2023-2024 Rob Royce * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -303,10 +303,25 @@ export class KsFactoryService { }); } + private getLink(link: URL): string { + // If the link is a PDF file from arxiv.org, set the link to `https://arxiv.org/abs/${arxivId}` instead of the PDF link + if ( + link.hostname === 'arxiv.org' && + link.pathname.split('/')[1] === 'pdf' + ) { + const arxivId = link.pathname.split('/')[2]; + if (arxivId) { + return `https://arxiv.org/abs/${arxivId}`; + } + } + + // Otherwise, return the original link + return link.href; + } + private getWebsiteMetadata(ks: KnowledgeSource): Promise { return new Promise((resolve) => { - const link = - typeof ks.accessLink === 'string' ? ks.accessLink : ks.accessLink.href; + const link = this.getLink(new URL(ks.accessLink)); this.extractor .extractWebsiteMetadata(link) .then((metadata) => { From d45f1f48c7f85ce769d49b928585a21208f8227c Mon Sep 17 00:00:00 2001 From: Rob Royce Date: Tue, 16 Jan 2024 19:02:59 -0800 Subject: [PATCH 05/37] chore: bump to version 0.8.6 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 558b1817..f0fd1114 100755 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "knowledge-canvas", - "version": "0.8.5", + "version": "0.8.6", "description": "Knowledge is a tool for saving, searching, and chatting with all of your favorite websites, documents and files.", "author": "Rob Royce (https://robroyce.dev)", "license": "Apache-2.0", From a46fcf1c210236ead2a76e68f8f478068125cc64 Mon Sep 17 00:00:00 2001 From: Rob Royce Date: Thu, 18 Jan 2024 08:07:01 -0800 Subject: [PATCH 06/37] style: add a placeholder to multi-import window when no Sources are loaded. --- .../app/components/shared/create.component.ts | 6 +----- .../components/shared/web.import.component.ts | 21 +++++++++++++++---- .../src/app/models/knowledge.source.model.ts | 4 ++-- 3 files changed, 20 insertions(+), 11 deletions(-) diff --git a/src/kc_angular/src/app/components/shared/create.component.ts b/src/kc_angular/src/app/components/shared/create.component.ts index bc4fb1da..b45cb606 100755 --- a/src/kc_angular/src/app/components/shared/create.component.ts +++ b/src/kc_angular/src/app/components/shared/create.component.ts @@ -131,11 +131,7 @@ import { ConfirmationService } from 'primeng/api'; - + `, diff --git a/src/kc_angular/src/app/components/shared/web.import.component.ts b/src/kc_angular/src/app/components/shared/web.import.component.ts index 1b2596e6..065fbef5 100644 --- a/src/kc_angular/src/app/components/shared/web.import.component.ts +++ b/src/kc_angular/src/app/components/shared/web.import.component.ts @@ -48,13 +48,17 @@ import { ProjectUpdateRequest } from '@app/models/project.model'; type="url" (keyup.enter)="onSubmit(linkInput.value)" style="min-width: 35vw;" - placeholder="Enter a URL and press enter/return" + placeholder="Enter a URL or a list of URLs and press enter/return..." /> -
-
- +
+
+ @@ -98,6 +102,14 @@ import { ProjectUpdateRequest } from '@app/models/project.model'; + + +
+
+ No Sources +
+
+
@@ -112,6 +124,7 @@ import { ProjectUpdateRequest } from '@app/models/project.model'; pButton label="Import" class="p-button p-button-sm w-8rem" + [disabled]="sources.length === 0" (click)="import(destination.name)" >
diff --git a/src/kc_angular/src/app/models/knowledge.source.model.ts b/src/kc_angular/src/app/models/knowledge.source.model.ts index 655d276c..87cecff4 100755 --- a/src/kc_angular/src/app/models/knowledge.source.model.ts +++ b/src/kc_angular/src/app/models/knowledge.source.model.ts @@ -1,5 +1,5 @@ /* - * Copyright (c) 2023 Rob Royce + * Copyright (c) 2023-2024 Rob Royce * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -126,7 +126,7 @@ export class KnowledgeSourceNote { } static blank() { - return new KnowledgeSourceNote('test'); + return new KnowledgeSourceNote(''); } } From 7e5f385a33e0620999376ef1c7173eaf20e23090 Mon Sep 17 00:00:00 2001 From: Rob Royce Date: Thu, 18 Jan 2024 08:15:57 -0800 Subject: [PATCH 07/37] Multi-import (#144) -feat(Import): add `Web Import` dialog for importing multiple URLs at a time --- package.json | 2 +- src/kc_angular/src/app/app.module.ts | 4 +- .../app/components/shared/create.component.ts | 14 +- .../components/shared/web.import.component.ts | 257 ++++++++++++++++++ .../src/app/models/knowledge.source.model.ts | 4 +- .../factory-services/ks-factory.service.ts | 21 +- .../ingest-services/ingest.service.ts | 11 +- .../src/local/controllers/chat.controller.ts | 11 +- .../local/middleware/DocumentSummarizer.ts | 3 + 9 files changed, 310 insertions(+), 17 deletions(-) create mode 100644 src/kc_angular/src/app/components/shared/web.import.component.ts diff --git a/package.json b/package.json index 558b1817..f0fd1114 100755 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "knowledge-canvas", - "version": "0.8.5", + "version": "0.8.6", "description": "Knowledge is a tool for saving, searching, and chatting with all of your favorite websites, documents and files.", "author": "Rob Royce (https://robroyce.dev)", "license": "Apache-2.0", diff --git a/src/kc_angular/src/app/app.module.ts b/src/kc_angular/src/app/app.module.ts index 659c51cb..7144a1af 100755 --- a/src/kc_angular/src/app/app.module.ts +++ b/src/kc_angular/src/app/app.module.ts @@ -1,5 +1,5 @@ /* - * Copyright (c) 2022-2023 Rob Royce + * Copyright (c) 2022-2024 Rob Royce * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -162,6 +162,7 @@ import { SourceNotesComponent } from '@components/source-components/source.notes import { ProTipDirective } from './directives/pro-tip.directive'; import { ProTipsComponent } from '@components/shared/pro-tips.component'; import { ChatInputComponent } from '@components/chat-components/chat.input.component'; +import { WebImportComponent } from '@components/shared/web.import.component'; @NgModule({ declarations: [ @@ -240,6 +241,7 @@ import { ChatInputComponent } from '@components/chat-components/chat.input.compo SanitizeHtmlPipe, ProTipDirective, ProTipsComponent, + WebImportComponent, ], imports: [ A11yModule, diff --git a/src/kc_angular/src/app/components/shared/create.component.ts b/src/kc_angular/src/app/components/shared/create.component.ts index 6e4508ce..b45cb606 100755 --- a/src/kc_angular/src/app/components/shared/create.component.ts +++ b/src/kc_angular/src/app/components/shared/create.component.ts @@ -1,5 +1,5 @@ /* - * Copyright (c) 2022-2023 Rob Royce + * Copyright (c) 2022-2024 Rob Royce * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -77,18 +77,16 @@ import { ConfirmationService } from 'primeng/api'; (change)="onNewFile($event)" /> @@ -132,6 +130,10 @@ import { ConfirmationService } from 'primeng/api'; + + + + `, styles: [], }) @@ -142,6 +144,8 @@ export class CreateComponent { files: any[] = []; + showImportDialog = false; + constructor( private dialog: DialogService, private confirm: ConfirmationService, diff --git a/src/kc_angular/src/app/components/shared/web.import.component.ts b/src/kc_angular/src/app/components/shared/web.import.component.ts new file mode 100644 index 00000000..065fbef5 --- /dev/null +++ b/src/kc_angular/src/app/components/shared/web.import.component.ts @@ -0,0 +1,257 @@ +/* + * Copyright (c) 2024 Rob Royce + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + Component, + EventEmitter, + HostListener, + Output, + ViewChild, +} from '@angular/core'; +import { FormBuilder, FormGroup, Validators } from '@angular/forms'; +import { + KnowledgeSourceFactoryRequest, + KsFactoryService, +} from '@services/factory-services/ks-factory.service'; +import { KnowledgeSource } from '@app/models/knowledge.source.model'; +import { KsCommandService } from '@services/command-services/ks-command.service'; +import { IngestService } from '@services/ingest-services/ingest.service'; +import { ProjectService } from '@services/factory-services/project.service'; +import { tap } from 'rxjs'; +import { ProjectUpdateRequest } from '@app/models/project.model'; + +@Component({ + selector: 'app-import-web', + template: ` +
+ +
+ +
+
+ + + + + Title + + + + + + + + + + + + + + + + + + + + + +
+
+ No Sources +
+
+
+
+ +
+ + +
+
+ `, +}) +export class WebImportComponent { + @Output() close = new EventEmitter(); + + @ViewChild('linkInput', { static: false }) linkInput: any; + + linkForm: FormGroup; + + sources: KnowledgeSource[] = []; + + destinations: { name: string; code: string }[] = [ + { name: 'Inbox', code: '1' }, + { name: 'Project', code: '2' }, + ]; + + destination: { name: string; code: string } = this.destinations[0]; + + constructor( + private formBuilder: FormBuilder, + private factory: KsFactoryService, + private command: KsCommandService, + private ingest: IngestService, + private projects: ProjectService + ) { + this.linkForm = formBuilder.group({ + url: ['', [Validators.required, Validators.pattern('https?://.+[.]+.+')]], + }); + + projects.currentProject + .pipe( + tap((project) => { + if (project) { + this.destinations = [ + { name: 'Inbox', code: '1' }, + { name: `Project (${project.name})`, code: '2' }, + ]; + } + }) + ) + .subscribe(); + } + + // HostListener for pasting text + @HostListener('paste', ['$event']) + onPaste(event: ClipboardEvent) { + const text = event.clipboardData?.getData('text'); + + // Get all URLs in text + const regex = /https?:\/\/[^\s]+/g; + const matches = text?.match(regex); + + // Add URLs to array + if (matches) { + const urls: string[] = []; + matches.forEach((match) => { + // Remove commas from end of URLs + if (match.endsWith(',')) { + match = match.slice(0, -1); + } + urls.push(match); + }); + this.createSource(urls); + } + + // Clear form by resetting URL field, making sure the value is updated + setTimeout(() => { + this.linkForm.setValue({ + url: '', + }); + this.linkForm.reset(); + }); + } + + onSubmit(value: string) {} + + createSource(urls: string[]) { + const requests: KnowledgeSourceFactoryRequest = { + ingestType: 'website', + links: urls, + }; + + this.factory.many(requests).then((sources) => { + for (const source of sources) { + this.sources.push(source); + } + }); + } + + addFlag(source: KnowledgeSource) { + source.flagged = !source.flagged; + } + + removeSource(source: KnowledgeSource) { + this.sources = this.sources.filter((s) => s !== source); + } + + openSource(source: KnowledgeSource) { + this.command.open(source); + } + + previewSource(source: KnowledgeSource) { + this.command.preview(source); + } + + import(destination: string) { + console.log('Importing to ' + destination); + + if (destination === 'Inbox') { + this.ingest.enqueue(this.sources); + } else { + const project = this.projects.getCurrentProjectId(); + if (project) { + const req: ProjectUpdateRequest = { + id: project, + addKnowledgeSource: this.sources, + }; + this.projects.updateProjects([req]); + } + } + + this.linkForm.reset(); + this.sources = []; + this.close.emit(); + } +} diff --git a/src/kc_angular/src/app/models/knowledge.source.model.ts b/src/kc_angular/src/app/models/knowledge.source.model.ts index 655d276c..87cecff4 100755 --- a/src/kc_angular/src/app/models/knowledge.source.model.ts +++ b/src/kc_angular/src/app/models/knowledge.source.model.ts @@ -1,5 +1,5 @@ /* - * Copyright (c) 2023 Rob Royce + * Copyright (c) 2023-2024 Rob Royce * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -126,7 +126,7 @@ export class KnowledgeSourceNote { } static blank() { - return new KnowledgeSourceNote('test'); + return new KnowledgeSourceNote(''); } } diff --git a/src/kc_angular/src/app/services/factory-services/ks-factory.service.ts b/src/kc_angular/src/app/services/factory-services/ks-factory.service.ts index 324d230d..7e3e4b21 100755 --- a/src/kc_angular/src/app/services/factory-services/ks-factory.service.ts +++ b/src/kc_angular/src/app/services/factory-services/ks-factory.service.ts @@ -1,5 +1,5 @@ /* - * Copyright (c) 2023 Rob Royce + * Copyright (c) 2023-2024 Rob Royce * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -303,10 +303,25 @@ export class KsFactoryService { }); } + private getLink(link: URL): string { + // If the link is a PDF file from arxiv.org, set the link to `https://arxiv.org/abs/${arxivId}` instead of the PDF link + if ( + link.hostname === 'arxiv.org' && + link.pathname.split('/')[1] === 'pdf' + ) { + const arxivId = link.pathname.split('/')[2]; + if (arxivId) { + return `https://arxiv.org/abs/${arxivId}`; + } + } + + // Otherwise, return the original link + return link.href; + } + private getWebsiteMetadata(ks: KnowledgeSource): Promise { return new Promise((resolve) => { - const link = - typeof ks.accessLink === 'string' ? ks.accessLink : ks.accessLink.href; + const link = this.getLink(new URL(ks.accessLink)); this.extractor .extractWebsiteMetadata(link) .then((metadata) => { diff --git a/src/kc_angular/src/app/services/ingest-services/ingest.service.ts b/src/kc_angular/src/app/services/ingest-services/ingest.service.ts index fe854f01..bdcd3809 100755 --- a/src/kc_angular/src/app/services/ingest-services/ingest.service.ts +++ b/src/kc_angular/src/app/services/ingest-services/ingest.service.ts @@ -1,5 +1,5 @@ /* - * Copyright (c) 2023 Rob Royce + * Copyright (c) 2023-2024 Rob Royce * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -85,7 +85,8 @@ export class IngestService implements OnDestroy { this.notify.warn( 'Ingest Service', 'Ignoring Duplicate', - `Source: ${ks.title}` + `Source: ${ks.title}`, + 'toast' ); continue; } @@ -93,6 +94,10 @@ export class IngestService implements OnDestroy { ksNext.push(ks); } + if (ksNext.length <= 0) { + return; + } + ksQueue = ksQueue.concat(ksNext); this._queue.next(ksQueue); @@ -103,7 +108,7 @@ export class IngestService implements OnDestroy { this.notify.success( 'IngestService', 'Source Imported', - `Imported ${ksList.length} Source${ksList.length > 1 ? 's' : ''}.` + `Imported ${ksNext.length} Source${ksNext.length > 1 ? 's' : ''}.` ); } diff --git a/src/kc_electron/src/local/controllers/chat.controller.ts b/src/kc_electron/src/local/controllers/chat.controller.ts index de4e5e5b..b73d04c6 100755 --- a/src/kc_electron/src/local/controllers/chat.controller.ts +++ b/src/kc_electron/src/local/controllers/chat.controller.ts @@ -62,7 +62,10 @@ export default class ChatController { async summarize(text: string, succinct = true, verbose = false) { if (!(await this.veryifyApi()) || !this.openai) { - return text; + console.warn( + "[ChatController]: OpenAI API not initialized or unavailable, skipping summarization..." + ); + return ""; } const limited = this.tokenizerUtils.limitText.bind(this.tokenizerUtils); @@ -118,6 +121,10 @@ export default class ChatController { const limitedChunksText = limitedChunks(text); let chunkedResponse = ""; + console.log( + `[ChatController]: Limiting text to ${limitedChunksText.length} chunks...` + ); + for (let i = 0; i < limitedChunksText.length; i++) { // If the last message is a user message, remove it from the array // This is to ensure that each chunk is removed from the previous query @@ -129,7 +136,7 @@ export default class ChatController { messages.push({ role: "user", - content: `=========\n"""${chunk}"""\n=========`, + content: `===\n"""${chunk}"""\n===`, }); // Sleep for 0.25 seconds to avoid rate limiting diff --git a/src/kc_electron/src/local/middleware/DocumentSummarizer.ts b/src/kc_electron/src/local/middleware/DocumentSummarizer.ts index 08f2486a..e6f921c1 100644 --- a/src/kc_electron/src/local/middleware/DocumentSummarizer.ts +++ b/src/kc_electron/src/local/middleware/DocumentSummarizer.ts @@ -28,6 +28,9 @@ export class DocumentSummarizer { return async (req: Request, res: Response, next: NextFunction) => { // If the request already contains a summary, skip this middleware if (req.body.summary) { + console.log( + "[DocumentSummarizer]: Summary already exists, skipping DocumentSummarizer" + ); return next(); } From 27e9871a58d422407e0f7b20568ba42102b696f8 Mon Sep 17 00:00:00 2001 From: Rob Royce Date: Thu, 18 Jan 2024 08:21:52 -0800 Subject: [PATCH 08/37] fix: handle errors where incorrect body is sent to SourceLoader --- .../src/local/middleware/SourceLoader.ts | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/src/kc_electron/src/local/middleware/SourceLoader.ts b/src/kc_electron/src/local/middleware/SourceLoader.ts index 11736d59..8d7a521d 100644 --- a/src/kc_electron/src/local/middleware/SourceLoader.ts +++ b/src/kc_electron/src/local/middleware/SourceLoader.ts @@ -23,6 +23,13 @@ const settingsService = require("../../app/services/settings.service"); export class SourceLoader { static async load(req: Request, res: Response, next: NextFunction) { const source = req.body.source; + + try { + const sourceId = source.id; + } catch (err) { + next(); + } + const id = source.id; const filename = `${id.value}.json`; @@ -33,6 +40,12 @@ export class SourceLoader { filename ); + // Make sure the directory exists, if not create it + const dirname = path.dirname(filepath); + if (!fs.existsSync(dirname)) { + fs.mkdirSync(dirname, { recursive: true }); + } + // Check for the Source in local JSON files. Append to req.body if found. if (fs.existsSync(filepath)) { const json = fs.readFileSync(filepath, "utf8"); @@ -61,6 +74,12 @@ export class SourceLoader { const text = req.body.text; const summary = req.body.summary; + try { + const sourceId = source.id; + } catch (err) { + next(); + } + const id = source.id; const filename = `${id.value}.json`; @@ -71,6 +90,12 @@ export class SourceLoader { filename ); + // Make sure the directory exists, if not create it + const dirname = path.dirname(filepath); + if (!fs.existsSync(dirname)) { + fs.mkdirSync(dirname, { recursive: true }); + } + console.debug("Storing source text and summary at: ", filepath); // Store the Source into local JSON file using ID as filename. From 606bfbcf52346ea42f845a0bce0f4c2ea129e51e Mon Sep 17 00:00:00 2001 From: Rob Royce Date: Thu, 18 Jan 2024 08:23:09 -0800 Subject: [PATCH 09/37] fix: handle error where invalid Source is sent to SourceParser --- src/kc_electron/src/local/middleware/SourceParser.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/kc_electron/src/local/middleware/SourceParser.ts b/src/kc_electron/src/local/middleware/SourceParser.ts index 0487d425..711d314c 100644 --- a/src/kc_electron/src/local/middleware/SourceParser.ts +++ b/src/kc_electron/src/local/middleware/SourceParser.ts @@ -32,6 +32,13 @@ export class SourceParser { } const source = req.body.source; + + try { + const type = source.ingestType; + } catch (err) { + next(); + } + const ingestType = source.ingestType; if (ingestType === "file") { From 5bb91c2b3d75e343b3496b78dd0551b78c563fd7 Mon Sep 17 00:00:00 2001 From: Rob Royce Date: Thu, 18 Jan 2024 08:25:56 -0800 Subject: [PATCH 10/37] fix: general improvements to Chat API, especially for summarization --- .../src/local/controllers/chat.controller.ts | 172 ++++++++++++++---- .../local/middleware/DocumentSummarizer.ts | 32 ++-- 2 files changed, 149 insertions(+), 55 deletions(-) diff --git a/src/kc_electron/src/local/controllers/chat.controller.ts b/src/kc_electron/src/local/controllers/chat.controller.ts index b73d04c6..56a7ee44 100755 --- a/src/kc_electron/src/local/controllers/chat.controller.ts +++ b/src/kc_electron/src/local/controllers/chat.controller.ts @@ -23,10 +23,15 @@ import { } from "../../../../kc_shared/models/settings.model"; import TokenizerUtils from "../utils/tokenizer.utils"; import { OpenAI } from "openai"; -import { CreateChatCompletionRequestMessage } from "openai/resources"; +import { SummarizationPrompts } from "../ai/prompts/summarization.prompts"; const settings = require("../../app/services/settings.service"); +/** + * The ChatController is the only controller that interacts directly with the OpenAI API. + * All other controllers should interact with the ChatController to access the API. + * This makes it easier to manage the API key and other settings. + */ export default class ChatController { private openai?: OpenAI; @@ -34,11 +39,16 @@ export default class ChatController { private tokenizerUtils: TokenizerUtils; + /** + * @param tokenizerUtils The tokenizer utils to use for this controller + */ constructor(tokenizerUtils: TokenizerUtils) { this.tokenizerUtils = tokenizerUtils; + // Initialize the OpenAI API this.init(); + // Listen for changes to the chat settings and update the settings as necessary settings.all .pipe( skip(1), @@ -56,56 +66,98 @@ export default class ChatController { return this.settings; } - limitText(text: string) { - return this.tokenizerUtils.limitText(text); + async succinct(text: string) { + return this.summarize(text, true, false); + } + + async verbose(text: string) { + return this.summarize(text, false, true); } - async summarize(text: string, succinct = true, verbose = false) { - if (!(await this.veryifyApi()) || !this.openai) { + async summarize( + text: string, + succinct = true, + verbose = false + ): Promise { + if (!(await this.verifyAPI()) || !this.openai) { console.warn( "[ChatController]: OpenAI API not initialized or unavailable, skipping summarization..." ); return ""; } + // Bind the tokenizer utils to this class, so that we can use them in the + // async function below without having to pass them in as a parameter const limited = this.tokenizerUtils.limitText.bind(this.tokenizerUtils); const limitedChunks = this.tokenizerUtils.chunkLimitText.bind( this.tokenizerUtils ); + const countTokens = this.tokenizerUtils.countTokens.bind( + this.tokenizerUtils + ); - try { - const messages: CreateChatCompletionRequestMessage[] = [ - { - role: "system", - content: - "Your goal is to help the user understand the text by summarzing, paraphrasing, and quoting it as necessary.", - }, - { - role: "system", - content: `Use groupings instead of listing lots of named entities (e.g. use et al. instead of listing 10 different people.).`, - }, - { - role: "system", - content: `If the text does not make sense, simply return an empty string (e.g. "").`, - }, - ]; + const tokenCount = countTokens(text); + console.log( + `[ChatController]: Summarizing text (succinct: ${succinct}), (verbose: ${verbose}) with ${tokenCount} tokens...` + ); - if (succinct && !verbose) { - messages.push({ - role: "user", - content: `It is important to be as succinct as possible.`, - }); - messages.push({ - role: "system", - content: "The text is a small excerpt from a larger document.", - }); + // Create standard set of messages common to both succinct and verbose modes + const messages = SummarizationPrompts.Common(); - const limitedText = limited(text); + // Verify that the message is short enough to be succinct (if succinct mode) + if (succinct) { + console.log( + `[ChatController]: Verifying text length for succinct mode...` + ); - messages.push({ - role: "user", - content: `\n=========\n"""${limitedText}"""\n=========`, + console.log( + `[ChatController]: Text length: ${text.length}, tokens: ${tokenCount}, limit: ${this.settings.model.token_limit}` + ); + if (tokenCount > this.settings.model.token_limit) { + console.warn( + `[ChatController]: Text is too long (${tokenCount} tokens > ${this.settings.model.token_limit} tokens), truncating...` + ); + text = limited(text); + } + } + + if (succinct && !verbose) { + // Join with SummarizationPrompts.Succinct() + SummarizationPrompts.Succinct().forEach((message) => { + messages.push(message); + }); + + // Push the text into the messages + const limitedText = limited(text); + messages.push({ + role: "user", + content: `===\n"""${limitedText}"""\n===`, + }); + + try { + const response = await this.openai.chat.completions.create({ + model: this.settings.model.name, + temperature: this.settings.model.temperature, + top_p: this.settings.model.top_p, + max_tokens: this.settings.model.max_tokens, + presence_penalty: this.settings.model.presence_penalty, + frequency_penalty: this.settings.model.frequency_penalty, + messages: messages, }); + + return response.choices[0].message.content ?? ""; + } catch (error) { + console.error("Could not get response from OpenAI API..."); + console.error(error); + return ""; + } + } + + // Otherwise, verbose mode + + try { + if (succinct && !verbose) { + messages.push(); } else if (verbose) { messages.push({ role: "system", @@ -161,12 +213,12 @@ export default class ChatController { messages.push({ role: "system", - content: `It is important to be as detailed as possible. Your summary should be 2-3 paragraphs in length.`, + content: `It is important to be as detailed as possible.`, }); messages.push({ role: "user", - content: `=========\n"""${chunkedResponse}"""\n=========`, + content: `===\n"""${chunkedResponse}"""\n===`, }); } } @@ -181,7 +233,7 @@ export default class ChatController { messages: messages, }); - return response.choices[0].message.content; + return response.choices[0].message.content ?? ""; } catch (error) { console.error("Error limiting text..."); console.error(error); @@ -189,8 +241,40 @@ export default class ChatController { } } + async passthrough(req: Request, res: Response): Promise { + if (!req.body.messages) { + return res.status(400).json({ + error: "Missing messages", + }); + } + + if (!(await this.verifyAPI()) || !this.openai) { + return res.status(500).json({ + error: "OpenAI API not initialized", + }); + } + + try { + const response = await this.openai.chat.completions.create({ + model: this.settings.model.name, + temperature: this.settings.model.temperature, + top_p: this.settings.model.top_p, + max_tokens: this.settings.model.max_tokens, + presence_penalty: this.settings.model.presence_penalty, + frequency_penalty: this.settings.model.frequency_penalty, + messages: req.body.messages, + }); + return res.json(response); + } catch (error) { + console.error(error); + return res.status(500).json({ + error: `Error calling OpenAI API: ${error}`, + }); + } + } + async chat(req: Request, res: Response): Promise { - if (!(await this.veryifyApi()) || !this.openai) { + if (!(await this.verifyAPI()) || !this.openai) { return res.status(500).json({ error: "OpenAI API not initialized", }); @@ -257,9 +341,17 @@ export default class ChatController { }); } - private async veryifyApi() { + private async verifyAPI() { if (!this.openai) { - await this.init(); + try { + await this.init(); + } catch (e) { + console.error( + `Unable to initialize OpenAI API upon verification... something is wrong.` + ); + console.error(e); + return false; + } } return true; } diff --git a/src/kc_electron/src/local/middleware/DocumentSummarizer.ts b/src/kc_electron/src/local/middleware/DocumentSummarizer.ts index e6f921c1..afddded5 100644 --- a/src/kc_electron/src/local/middleware/DocumentSummarizer.ts +++ b/src/kc_electron/src/local/middleware/DocumentSummarizer.ts @@ -34,32 +34,34 @@ export class DocumentSummarizer { return next(); } - let text = req.body.text; + // If the request does not contain text, skip this middleware + try { + const length = req.body.text.length; + } catch (err) { + next(); + } // Absolute limit of text length is 100,000 characters, truncate anything above - if (text.length > 100000) { + if (req.body.text.length > 100000) { console.warn( - `[DocumentSummarizer]: truncating text of size ${text.length} to 100,000 characters` + `[DocumentSummarizer]: truncating text of size ${req.body.text.length} to 100,000 characters` ); - req.body.text = text.substring(0, 100000); - text = req.body.text; + req.body.text = req.body.text.substring(0, 100000); } - // Generate a summary of the text - const CHUNK_SIZE = 10000; - const chunks = text.match(new RegExp(`.{1,${CHUNK_SIZE}}`, "g")); + // Break the text into chunks under the assumption that each token represents approximately 3 characters + const CHUNK_SIZE = + (this.chatController.getSettings().model.token_limit - + this.chatController.getSettings().model.max_tokens) * + 3; + const chunks = req.body.text.match(new RegExp(`.{1,${CHUNK_SIZE}}`, "g")); if (chunks) { const summaries = await Promise.all( - chunks.map(this.chatController.summarize.bind(this.chatController)) + chunks.map(this.chatController.succinct.bind(this.chatController)) ); const initialSummaries = summaries.join("\n").trim(); - const summary = await this.chatController.summarize( - initialSummaries, - false, - true - ); - req.body.summary = summary; + req.body.summary = await this.chatController.verbose(initialSummaries); next(); } else { console.warn("DocumentSummarizer: unable to split text into chunks..."); From 303a31cc5dd1bbd71f80325be9f1d0d7a07b9fca Mon Sep 17 00:00:00 2001 From: Rob Royce Date: Thu, 18 Jan 2024 08:32:24 -0800 Subject: [PATCH 11/37] feat(Assistant): add `categorize` endpoint to help users assign Sources to Projects --- .../local/controllers/source.controller.ts | 26 +++++++++++++++++++ .../src/local/routes/source.routes.ts | 5 ++++ 2 files changed, 31 insertions(+) diff --git a/src/kc_electron/src/local/controllers/source.controller.ts b/src/kc_electron/src/local/controllers/source.controller.ts index eff1a45c..c833bc6a 100755 --- a/src/kc_electron/src/local/controllers/source.controller.ts +++ b/src/kc_electron/src/local/controllers/source.controller.ts @@ -87,4 +87,30 @@ export default class SourceChatController { async regenerate(req: Request, res: Response) { return undefined; } + + async categorize(req: Request, res: Response) { + // The request body should contain a source title and the entire project tree in YAML format + const sourceTitle = req.body.title; + const projectTree = req.body.yaml.replace("\n", "\\n"); + + req.body.messages = [ + { + role: "user", + content: + "List of projects and their subprojects:\n\n" + + "```yaml\n" + + `${projectTree}` + + "```\n" + + "---\n" + + "Title of newly import Source:\n" + + `> ${sourceTitle}\n` + + "---\n" + + "Please provide a comma separated list of the top 3 projects that this source is most likely to fit into. Do not explain your reasoning. Do not return anything other than the list of 3 projects.", + }, + ]; + + const response = await this.chatController.passthrough(req, res); + + console.log("Response: ", response); + } } diff --git a/src/kc_electron/src/local/routes/source.routes.ts b/src/kc_electron/src/local/routes/source.routes.ts index 6656cbd2..96efb4ee 100755 --- a/src/kc_electron/src/local/routes/source.routes.ts +++ b/src/kc_electron/src/local/routes/source.routes.ts @@ -55,6 +55,11 @@ export default class SourceRoutes { // Intro chat with the source (basically a summary) router.post("/intro", this.controller.intro.bind(this.controller)); + router.post( + "/categorize", + this.controller.categorize.bind(this.controller) + ); + // An endpoint to regenerate previous responses router.post( "/regenerate", From a5e6030c908d7512d3e8bc22d968503d4d4be29a Mon Sep 17 00:00:00 2001 From: Rob Royce Date: Thu, 18 Jan 2024 08:33:05 -0800 Subject: [PATCH 12/37] feat(Search): reprioritize Source and Project descriptions in search rankings --- .../src/app/services/user-services/search.service.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/kc_angular/src/app/services/user-services/search.service.ts b/src/kc_angular/src/app/services/user-services/search.service.ts index d869d64b..56234e3e 100755 --- a/src/kc_angular/src/app/services/user-services/search.service.ts +++ b/src/kc_angular/src/app/services/user-services/search.service.ts @@ -1,5 +1,5 @@ /* - * Copyright (c) 2022-2023 Rob Royce + * Copyright (c) 2022-2024 Rob Royce * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -79,7 +79,7 @@ export class SearchService { }, { name: 'description', - weight: 1, + weight: 4, }, { name: 'rawText', @@ -154,7 +154,7 @@ export class SearchService { }, { name: 'description', - weight: 1, + weight: 4, }, { name: 'accessLink', From bcbcecb2507dfe3f72d8c709f1c01a969d538a7f Mon Sep 17 00:00:00 2001 From: Rob Royce Date: Fri, 19 Jan 2024 06:09:17 -0800 Subject: [PATCH 13/37] feat(Chat): add chat commands UI and basic service implementation. --- src/kc_angular/src/app/app.module.ts | 2 + .../chat-components/chat.input.component.ts | 362 ++++++++++++++++-- .../chat-components/chat.view.component.html | 7 +- .../chat-components/chat.view.component.scss | 11 +- .../chat-components/chat.view.component.ts | 24 +- .../src/app/components/chat.component.ts | 3 +- .../chat-services/commands.service.ts | 277 ++++++++++++++ src/kc_angular/src/styles/kc-styles.scss | 13 +- 8 files changed, 658 insertions(+), 41 deletions(-) create mode 100644 src/kc_angular/src/app/services/chat-services/commands.service.ts diff --git a/src/kc_angular/src/app/app.module.ts b/src/kc_angular/src/app/app.module.ts index 7144a1af..f80f5822 100755 --- a/src/kc_angular/src/app/app.module.ts +++ b/src/kc_angular/src/app/app.module.ts @@ -163,6 +163,7 @@ import { ProTipDirective } from './directives/pro-tip.directive'; import { ProTipsComponent } from '@components/shared/pro-tips.component'; import { ChatInputComponent } from '@components/chat-components/chat.input.component'; import { WebImportComponent } from '@components/shared/web.import.component'; +import { MessageModule } from 'primeng/message'; @NgModule({ declarations: [ @@ -310,6 +311,7 @@ import { WebImportComponent } from '@components/shared/web.import.component'; TreeSelectModule, YouTubePlayerModule, DragDropModule, + MessageModule, ], providers: [ ConfirmationService, diff --git a/src/kc_angular/src/app/components/chat-components/chat.input.component.ts b/src/kc_angular/src/app/components/chat-components/chat.input.component.ts index fecdb8e9..25b5468e 100644 --- a/src/kc_angular/src/app/components/chat-components/chat.input.component.ts +++ b/src/kc_angular/src/app/components/chat-components/chat.input.component.ts @@ -1,5 +1,5 @@ /* - * Copyright (c) 2023 Rob Royce + * Copyright (c) 2023-2024 Rob Royce * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -18,14 +18,20 @@ import { Component, ElementRef, EventEmitter, + HostListener, Input, Output, ViewChild, } from '@angular/core'; import { ChatService } from '@services/chat-services/chat.service'; -import { BehaviorSubject, combineLatest, Observable } from 'rxjs'; -import { debounceTime } from 'rxjs/operators'; -import { NotificationsService } from '@services/user-services/notifications.service'; +import { BehaviorSubject, combineLatest, Observable, tap } from 'rxjs'; +import { debounceTime, distinctUntilChanged } from 'rxjs/operators'; +import { + ChatCommand, + ChatCommandService, + CommandArgs, +} from '@services/chat-services/commands.service'; +import { Message } from 'primeng/api'; @Component({ selector: 'chat-input', @@ -43,13 +49,48 @@ import { NotificationsService } from '@services/user-services/notifications.serv tooltipPosition="top" proTip tipHeader="Out of Questions? We've Got Your Back!" - tipMessage="Check out our recommended next questions, thoughtfully crafted by our LLM agents. They're here to kickstart your curiosity and keep the knowledge flowing. So, what's your next question?" + tipMessage="Explore our suggested follow-up questions, carefully created by our AI experts. Designed to spark your curiosity and continue your learning journey." [tipGroups]="['chat']" tipIcon="pi pi-question" > {{ question | truncate : [84] }}
+
+ +
+
+
+ {{ command.command }} + + +  {{ arg.label }} + + + :  + {{ command.description }} +
+
@@ -88,42 +133,111 @@ import { NotificationsService } from '@services/user-services/notifications.serv
`, - styles: [], + styles: [ + ` + .chat-command-box { + max-height: 24rem; + overflow-y: auto; + border-radius: 1.5rem 1.5rem 0 0; + position: fixed; + } + + .chat-input { + width: 100%; + border-bottom-left-radius: 1.5rem !important; + border-bottom-right-radius: 1.5rem !important; + border-top-right-radius: 0 !important; + border-top-left-radius: 0 !important; + } + `, + ], }) export class ChatInputComponent { - @ViewChild('chatInput', { static: true }) chatInput!: ElementRef; + @ViewChild('chatInput', { static: false }) chatInput!: ElementRef; + + @ViewChild('commandBox', { static: false }) commandBox!: ElementRef; @Input() loading = false; @Input() questions: string[] = []; + @Input() target: 'Source' | 'Project' = 'Source'; + + @Output() showCommands = new EventEmitter(); + @Output() send = new EventEmitter(); @Output() focusEvent = new EventEmitter(); + @HostListener('document:click', ['$event']) + // Listen for click events and close the command palette if the user clicks outside of it + clickout() { + this.commands.reset(); + } + tokenCount$: Observable; tokenLimit$: Observable; + chatCommands: Observable; + + commandWidth = '0'; + + commandBottom = '0'; + + commandLeft = '0'; + + commandIndex = 0; + + commandCount = 0; + private userInput = new BehaviorSubject(''); - private canSend = true; + private tokensWithinLimit = true; + bannerMessages: Message[] = []; - constructor(private chat: ChatService, private notify: NotificationsService) { + constructor(private chat: ChatService, private commands: ChatCommandService) { this.tokenCount$ = chat.tokenCount$; this.tokenLimit$ = chat.tokenLimit$; + this.chatCommands = commands.commands$; + + /* When either count or limit change, set flag accordingly */ combineLatest([this.tokenCount$, this.tokenLimit$]).subscribe( ([count, limit]) => { - this.canSend = count <= limit; + this.tokensWithinLimit = count <= limit; } ); + /* When the user input changes, trigger a token count update */ this.userInput .asObservable() - .pipe(debounceTime(500)) + .pipe(debounceTime(250), distinctUntilChanged()) .subscribe((value) => { this.chat.countTokens(value); }); + + /* Handle changes to available commands based on user input */ + this.chatCommands + .pipe( + tap((commands) => { + this.commandCount = commands.length; + + // If the index is out of bounds, reset it + if (this.commandIndex >= this.commandCount) { + this.commandIndex = 0; + } + + // If there are commands to display, show the command box + if (commands.length > 0) { + this.bannerMessages = []; + this.showCommands.emit(true); + this.scroll(0); + } else { + this.showCommands.emit(false); + } + }) + ) + .subscribe(); } ask(question: string) { @@ -132,25 +246,215 @@ export class ChatInputComponent { } input(value: string) { + // Set the command box position whenever input changes (required for responsive behavior) + this.setCommandBox(); + + // Required to get rid of command palette when user erases the chat + if (value.trim() === '') { + this.reset(); + } + + // If the input starts with a slash, trigger command palette update + if (value.startsWith('/')) { + this.commands.filter(value, this.target); + } else { + // Otherwise, ensure the command palette is hidden + this.commands.reset(); + } + this.userInput.next(value); } - focus() { - this.focusEvent.emit(); - } + enter(value: string, event: Event) { + event.preventDefault(); + event.stopPropagation(); + + // Handle commands + if (value.startsWith('/')) { + const cmd = this.commands.lookup(value.split(' ')[0]); - enter(value: string) { - if (this.canSend) { + if (cmd) { + // If the full command is entered, and it exists, execute it + this.execute(cmd, value); + } else { + // Otherwise, assume the user wants to execute the indexed command + const indexed = this.commands.indexed(this.commandIndex); + + if (indexed) { + this.execute(indexed, value); + } else { + this.bannerMessage({ + severity: 'warn', + summary: 'Invalid Command', + life: 5000, + detail: `The command ${value} is not valid. Type / to see a list of available commands.`, + }); + + this.reset(); + } + } + return; + } + + if (this.tokensWithinLimit) { this.send.emit(value); - this.chatInput.nativeElement.value = ''; - this.userInput.next(''); + this.reset(); + } else { + this.bannerMessage({ + severity: 'warn', + summary: 'Woah there!', + life: 5000, + detail: `The current Chat model has reached its token limit. Please shorten your message and try again.`, + }); + } + } + + clickSelect(command: ChatCommand, input: string, $event: Event) { + // If the command requires arguments, set the input to the command and filter the commands list + $event.preventDefault(); + $event.stopPropagation(); + + this.setInputText(command.command + ' '); + + if (command.args.length > 0) { + this.chatInput.nativeElement.value = command.command + ' '; + this.commands.filter(this.chatInput.nativeElement.value, this.target); + return; } else { - this.notify.warn( - 'Chat Input', - 'Uh-oh!', - 'Your message is too long! Please shorten it and try again.', - 'toast' - ); + this.execute(command, input); + } + } + + execute(command: ChatCommand, input: string) { + // Check if all args are optional + const argsAllOptional = command.args.every((arg) => arg.optional); + + // For any non-optional args, make sure their values are not undefined + const parsedArgs = command.argParse ? command.argParse(input) : []; + const requiredArgsAreValid = parsedArgs.every( + (arg) => arg.optional || (!arg.optional && arg.value !== undefined) + ); + + // Execute no-arg commands or commands with optional args if no args are provided + if (!requiredArgsAreValid) { + this.bannerMessage({ + severity: 'warn', + summary: 'Forget something?', + life: 5000, + detail: `The ${command.command} command requires additional arguments. Please try again.`, + }); + } else if (command.args.length === 0 || argsAllOptional) { + command.execute([]); + } else { + // Otherwise, parse the arguments (verify) and execute the command + let args: CommandArgs[] = []; + + if (command.argParse) { + args = command.argParse(input); + } + + if (args.length !== command.args.length) { + this.bannerMessage({ + severity: 'warn', + summary: 'Forget something?', + life: 5000, + detail: `The ${command.command} command requires additional arguments. Please try again.`, + }); + } else { + // Execute the command + command.execute(args); + } } + this.reset(); + } + + arrowSelectCommand(newIndex: number, event: Event) { + /* Handle arrow key navigation of command palette */ + + if (this.commandCount === 0) { + return; + } + + event.preventDefault(); + event.stopPropagation(); + + if (newIndex < 0) { + // If the index is out of bounds, wrap around + newIndex = this.commandCount - 1; + } else if (newIndex >= this.commandCount) { + newIndex = 0; + } + + this.commandIndex = newIndex; + this.scroll(0); + } + + tabSelectCommand($event: Event) { + /* Handle when the user presses tab in the chat input */ + $event.preventDefault(); + $event.stopPropagation(); + const command = this.commands.indexed(this.commandIndex); + if (!command) { + return; + } + this.setInputText(command.command + ' '); + } + + reset() { + /* Convenience function for procedurally clearing the chat input */ + this.setInputText(''); + } + + private bannerMessage(message: Message) { + /* Convenience function for procedurally setting the banner message */ + this.bannerMessages = [message]; + setTimeout(() => { + this.bannerMessages = []; + }, message.life ?? 5000); + } + + private scroll(timeoutms = 100) { + /* Make sure the currently selected command is in view */ + setTimeout(() => { + const classElement = document.getElementsByClassName('active-command'); + if (classElement.length > 0) { + classElement[0].scrollIntoView({ behavior: 'smooth' }); + } + }, timeoutms); + } + + private setInputText(text: string) { + /* Handle the procedural tasks that need to happen when the input changes */ + this.chatInput.nativeElement.value = text; + this.commands.filter(text, this.target); + setTimeout(() => { + this.userInput.next(text); + }); + } + + private setCommandBox() { + /* Ensures visual consistency between the command palette and the chat input element */ + const bodyRect = document.body.getBoundingClientRect(); + const inputRect = this.chatInput.nativeElement.getBoundingClientRect(); + const commandHeight = this.chatInput.nativeElement.offsetHeight; + const commandBottom = bodyRect.height - inputRect.top; + + // If the command box would extend beyond the bottom of the window, move it up + if (commandBottom + commandHeight > bodyRect.height) { + this.commandBottom = `${bodyRect.height - commandHeight}px`; + } else { + this.commandBottom = `${commandBottom}px`; + } + + // The command box should be the same width as the input element + const chatBar = document.getElementById('chat-bar'); + this.commandWidth = `calc(${chatBar?.offsetWidth}px - 1rem)`; + + // Center the command box above the chat input element + this.commandLeft = `${inputRect.left}px`; + } + + bannerChange($event: Message[]) { + console.log('Chat input banner changes: ', $event); } } diff --git a/src/kc_angular/src/app/components/chat-components/chat.view.component.html b/src/kc_angular/src/app/components/chat-components/chat.view.component.html index b922337a..87e39db3 100755 --- a/src/kc_angular/src/app/components/chat-components/chat.view.component.html +++ b/src/kc_angular/src/app/components/chat-components/chat.view.component.html @@ -1,5 +1,5 @@ - -
- - -
-
- - -
- - -
-
-
-
- - -
- - - diff --git a/src/kc_angular/src/app/components/chat-components/chat.view.component.scss b/src/kc_angular/src/app/components/chat-components/chat.view.component.scss deleted file mode 100755 index f8be1259..00000000 --- a/src/kc_angular/src/app/components/chat-components/chat.view.component.scss +++ /dev/null @@ -1,58 +0,0 @@ -/*! - * Copyright (c) 2023-2024 Rob Royce - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -.user-message { - background-color: var(--primary-color) !important; - color: var(--primary-color-text) !important; - border-radius: 10px; - padding: 0 10px 0 10px; - margin: 10px; - text-align: right; - max-width: 80%; -} - -.agent-message { - color: var(--primary-color) !important; - background-color: var(--primary-color-text) !important; - border-radius: 10px; - padding: 0 10px 0 10px; - margin: 10px; - text-align: left; - max-width: 80%; -} - -#chat-toolbar { - height: 2rem; - background-color: var(--surface-ground); -} - -.next-question-suggestion:hover { - opacity: 1 !important; -} - -.html2canvas-container { - width: 3000px !important; - height: 3000px !important; -} - -.chat-history-active { - // If the command bar is active, make the chat history slightly opaque and transparent - opacity: 0.5; - background-color: var(--surface-ground); - - // Make it blurry - filter: blur(5px); -} diff --git a/src/kc_angular/src/app/components/chat-components/chat.view.component.ts b/src/kc_angular/src/app/components/chat-components/chat.view.component.ts index 42430050..1d4cd912 100755 --- a/src/kc_angular/src/app/components/chat-components/chat.view.component.ts +++ b/src/kc_angular/src/app/components/chat-components/chat.view.component.ts @@ -14,170 +14,116 @@ * limitations under the License. */ -import { - Component, - ElementRef, - EventEmitter, - Input, - OnChanges, - OnInit, - Output, - SimpleChanges, - ViewChild, -} from '@angular/core'; +import { Component, Input, ViewChild } from '@angular/core'; import { ChatService } from '@app/services/chat-services/chat.service'; -import { debounceTime, finalize, map, take, tap } from 'rxjs/operators'; -import { ConfirmationService, MenuItem, PrimeIcons } from 'primeng/api'; +import { MenuItem } from 'primeng/api'; import { ContextMenu } from 'primeng/contextmenu'; -import { - KcNotification, - NotificationsService, -} from '@app/services/user-services/notifications.service'; -import { ChatContextMenuService } from '@app/services/factory-services/chat-context-menu.service'; -import { AgentType, ChatMessage } from '@app/models/chat.model'; -import { ChatPrompts } from '@services/chat-services/prompts'; -import { SettingsService } from '@services/ipc-services/settings.service'; -import { BehaviorSubject, combineLatest, Observable } from 'rxjs'; +import { AgentType, ChatMessage, MessageRating } from '@app/models/chat.model'; +import { Observable } from 'rxjs'; +import { ChatContextMenuService } from '@services/factory-services/chat-context-menu.service'; +import { ChatCommandService } from '@services/chat-services/commands.service'; +import { distinctUntilChanged, tap } from 'rxjs/operators'; @Component({ selector: 'app-chat-view', - templateUrl: './chat.view.component.html', - styleUrls: ['./chat.view.component.scss'], + template: ` +
+ + +
+
+
+
+ +
+
+
+
+ No Messages +
+
+
+ + +
+ + + + `, + styles: [ + ` + #chat-toolbar { + height: 2rem; + background-color: var(--surface-ground); + } + `, + ], }) -export class ChatViewComponent implements OnInit, OnChanges { - /* The input element for the chat */ - @ViewChild('chatInput', { static: true }) chatInput!: ElementRef; - +export class ChatViewComponent { /* The context menu for chat messages */ @ViewChild('cm', { static: true }) cm!: ContextMenu; - /* The chat history to display */ - @Input() history: ChatMessage[] = []; - - /* Whether the chat is loading (e.g. waiting for a response from the server) */ - @Input() loading = false; - /* Whether to apply a height restriction to the chat history */ @Input() heightRestricted = false; - /* Special handling for target-specific functionality required */ - @Input() target: 'Source' | 'Project' = 'Source'; - - /* The event emitted when the user submits a message */ - @Output() submit: EventEmitter = new EventEmitter(); - - /* The event emitted when the user clicks on the Regenerate button */ - @Output() regenerate: EventEmitter = - new EventEmitter(); - - /* The event emitted when the user clicks on the Delete button */ - @Output() delete: EventEmitter = new EventEmitter< - ChatMessage[] - >(); - - /* An array of suggestions for the next question */ - nextQuestionSuggestions: string[] = []; - /* The context menu used to display the options for a message */ messageMenu: MenuItem[] = []; - suggestions = true; - - focusTrap = true; - - editedMessage?: ChatMessage; - - filtered$: Observable; - - private _history$ = new BehaviorSubject([]); - - history$ = this._history$.asObservable(); - - private filter = new BehaviorSubject(''); - - filter$ = this.filter.asObservable(); - - private messageInput = new BehaviorSubject(''); + messages$: Observable; constructor( private chat: ChatService, private context: ChatContextMenuService, - private confirm: ConfirmationService, - private notify: NotificationsService, - private prompts: ChatPrompts, - private settings: SettingsService + private command: ChatCommandService ) { - settings.all - .pipe( - map((settings) => settings.app.chat.suggestions), - debounceTime(250), - tap((suggestions) => { - this.suggestions = suggestions.enabled; - this.focusTrap = suggestions.onInput; - }) - ) - .subscribe(); - - this.history$.pipe(debounceTime(100)).subscribe((history) => { - if (history.length === 0) { - // Add an introductory message to the chat history - const msg = this.chat.createMessage( - AgentType.Knowledge, - AgentType.User, - "Welcome to Knowledge Chat! I'm here to help you with any questions or research needs you may have. Whether you need help with a project or just want to learn more about a topic, I'm here to assist you. Please feel free to ask me anything!" - ); - this._history$.next([msg]); - } - }); - - // Filter the chat history based on the filter text - this.filtered$ = combineLatest([this.filter$, this.history$]).pipe( - debounceTime(100), - map(([filter, history]) => { - if (!filter) return history; - - return history.filter((message) => { - return message.text.toLowerCase().includes(filter.toLowerCase()); - }); + this.messages$ = this.chat.messages$.pipe( + distinctUntilChanged((a, b) => { + // If the messages are the same length, check if they are the same messages + if (a.length === b.length) { + for (let i = 0; i < a.length; i++) { + if (a[i].id !== b[i].id) { + return false; + } + } + return true; + } else { + return false; + } + }), + tap(() => { + this.scroll(); }) ); - this.messageInput - .asObservable() - .pipe(debounceTime(1000)) - .subscribe((message) => { - this.chat.countTokens(message); - }); - } - - ngOnInit(): void { - // Scroll to the bottom of the chat history after rest of component finishes loading - setTimeout(() => { + this.command.scrollView$.asObservable().subscribe(() => { this.scroll(); - }, 50); - } - - /** - * If the history changes, reset the suggestions and sort the history by timestamp - * If the suggestions are enabled, use the most recent message to generate suggestions - */ - ngOnChanges(changes: SimpleChanges) { - if (changes.history) { - // Reset the suggestions when the history changes - this.nextQuestionSuggestions = []; - this.filter.next(''); - this._history$.next(changes.history.currentValue); - - setTimeout(() => { - if ( - changes.history?.currentValue?.length === - changes.history?.previousValue?.length - 1 - ) { - return; - } - this.scroll(); - }, 1000); - } + }); } /* Scroll to the end of the #chat-history element */ @@ -192,225 +138,17 @@ export class ChatViewComponent implements OnInit, OnChanges { } } - sendMessage(message: string) { - if (!message) { - return; - } - - this.submit.emit(message); - this.chat.countTokens(''); - this.scroll(); - } - - /** - * Generate the next question suggestions for the given message - * - * @param message The message to generate suggestions for - * @description The suggestions are generated using the message and the 14 messages prior to it - */ - generateNextQuestion(message: ChatMessage) { - if (!this.settings.get().app.chat.suggestions.enabled) { - return; - } - - // Get the message and the 14 messages prior to it, then send those to the prompt service - this.chat.getHistory(message, this.history, true); - const index = this.history.indexOf(message); - const messages = this.history.slice(index - 14, index + 1); - - this.prompts - .predictNextQuestion(messages, message.source ? 'source' : 'project') - .pipe( - take(1), - tap((response) => { - this.nextQuestionSuggestions = response; - this.scroll(); - }) - ) - .subscribe(); - } - - chatFocusTrap() { - if (this.suggestions && this.focusTrap) { - this.generateNextQuestion(this.history[this.history.length - 1]); - } - } - - /* Submit the given suggestion as the next question */ - sendSuggestion(suggestion: string) { - if (this.loading) { - return; - } - this.submit.emit(suggestion); - this.nextQuestionSuggestions = this.nextQuestionSuggestions.filter( - (s) => s !== suggestion - ); - - if (this.nextQuestionSuggestions.length === 0) { - this.generateNextQuestion(this.history[this.history.length - 1]); - } - } - - /** - * Regenerate the given message, replacing it with a new message - * @param message The message to regenerate - * @param warn Whether to warn the user that the message will be replaced in the chat history - * @description This will replace the message in the chat history with a new message - */ - regenerateMessage(message: ChatMessage, warn = true) { - if (warn) { - this.warnUser( - [message], - `Regenerate`, - `Are you sure you want to regenerate this message? This will replace the message in the chat history.`, - `Regenerate Message`, - this.regenerate - ); + blurHistory($event: boolean) { + // If the command bar is active, make the chat history slightly opaque and transparent + if ($event) { + document + .getElementsByClassName('chat-history')[0] + .classList.add('chat-history-active'); } else { - this.regenerate.emit(message); - } - } - - /** - * Delete the given message from the chat history and storage - * @param message The message to delete - * @description This will delete the message from the chat history and storage - */ - deleteMessage(message: ChatMessage) { - this.warnUser( - [message], - `Delete`, - `Are you sure you want to permanently delete this message?`, - `Delete Message`, - this.delete - ); - } - - /** - * Edit the given message in the chat history - * @param message The message to edit - * @param edited (optional) The edited message to replace the original message with - * @description This will replace the message in the chat history with the edited message. - * If no edited message is given, the message will turn into an input field. If an edited message is given, - * the message will be replaced with the edited message and the response will be regenerated. - */ - editMessage(message: ChatMessage, edited?: string) { - if (edited && edited !== message.text) { - message.text = edited; - - // If this is a user message, regenerate the response to the message - if (message.sender === AgentType.User) { - // Get the message that was a response to the message being edited and regenerate it - const index = this.history.indexOf(message); - if (index + 1 <= this.history.length) { - const response = this.history[index + 1]; - - if (response.sender === message.recipient) { - // If the next message is from the same agent, regenerate the message - this.regenerateMessage(response, false); - } else { - // Otherwise, we need to insert a new response into the history immediately after the edited message - const newResponse = this.chat.createMessage( - message.recipient, - AgentType.User, - '', - message.project, - message.source - ); - this.history.splice(index + 1, 0, newResponse); - this.regenerateMessage(newResponse, false); - } - } - } + document + .getElementsByClassName('chat-history')[0] + .classList.remove('chat-history-active'); } - this.editedMessage = edited ? undefined : message; - } - - /** - * Summarize the given message using the chat service - * @param message The message to summarize - * @description This will add a tldr section to the top of the message - */ - tldr(message: ChatMessage) { - // Get most recent messages leading up to the message to summarize - const index = this.history.indexOf(message); - const messages = this.history.slice(index - 14, index + 1); - - this.loading = true; - this.chat - .elaborate(messages, 'TLDR') - .pipe( - take(1), - tap((response) => { - message.text += ` - -**TLDR:** ${response.content}`; - this.chat.saveChat(this.history); - }), - finalize(() => { - this.loading = false; - }) - ) - .subscribe(); - } - - /** - * Explain the given message to a 5-year-old - * @param message The message to explain - * @description This will append the ELI5 explaination to the bottom of the message - */ - explainLikeIm5(message: ChatMessage) { - // Get most recent messages leading up to the message to explain - this.loading = true; - const index = this.history.indexOf(message); - const messages = this.history.slice(index - 14, index + 1); - this.chat - .elaborate(messages, 'ELI5') - .pipe( - take(1), - tap((response) => { - message.text += ` - -**ELI5:** ${response.content}`; - this.chat.saveChat(this.history); - }), - finalize(() => { - this.loading = false; - }) - ) - .subscribe(); - } - - /** - * Continue the response to the given message (useful when the response is too long to fit in one message) - * @param message - */ - continueResponse(message: ChatMessage) { - // Get most recent messages leading up to the message to explain - this.loading = true; - const index = this.history.indexOf(message); - const messages = this.history.slice(index - 14, index + 1); - this.chat - .elaborate(messages, 'Continue') - .pipe( - take(1), - tap((response) => { - message.text += `${response.content}`; - this.chat.saveChat(this.history); - }), - finalize(() => { - this.loading = false; - }) - ) - .subscribe(); - } - - /** - * Filter the chat history by the given string - * @param $event The string to filter by - */ - onFilter($event: string) { - this.filter.next($event); } /** @@ -428,24 +166,15 @@ export class ChatViewComponent implements OnInit, OnChanges { const text = selection.toString().trim(); const tokens = text.split(' '); if (text.length > 0 && tokens.length > 0 && tokens.length <= 5) { - this.messageMenu.push( - this.context.topics(message, text, () => { - this.chat.saveChat(this.history); - }) - ); + this.messageMenu.push(this.context.topics(message, text, () => {})); } + // Add the search option - this.messageMenu.push( - this.context.search(message, selection, () => { - this.chat.saveChat(this.history); - }) - ); + this.messageMenu.push(this.context.search(message, selection, () => {})); } // If the user has highlighted text, add a highlight option - const highlight = this.context.highlight(message, $event, () => { - this.chat.saveChat(this.history); - }); + const highlight = this.context.highlight(message, $event, () => {}); if (highlight) { this.messageMenu.push(highlight); } @@ -463,133 +192,21 @@ export class ChatViewComponent implements OnInit, OnChanges { this.messageMenu.push(this.context.save(message)); } - this.messageMenu.push(this.context.copy(message)); - this.messageMenu.push( - this.context.edit(() => { - this.editMessage(message); - }) - ); - - // If the message is not a user message, add continue, regenerate, tldr, and eli5 options - if (message.sender !== AgentType.User) { + if (this.messageMenu.length > 0) { this.messageMenu.push(this.context.separator()); - this.messageMenu.push( - this.context.continue(() => { - this.continueResponse(message); - }) - ); - this.messageMenu.push( - this.context.regenerate(() => { - this.regenerateMessage(message); - }) - ); - this.messageMenu.push( - this.context.tldr(() => { - this.tldr(message); - }) - ); - this.messageMenu.push( - this.context.eli5(() => { - this.explainLikeIm5(message); - }) - ); } - this.messageMenu.push(this.context.separator()); this.messageMenu.push( + this.context.copy(message), this.context.delete(() => { - this.deleteMessage(message); + this.chat.deleteMessage(message); }) ); this.cm.show($event); } - rating(rating: 'thumbs-up' | 'thumbs-down' | 'none', message: ChatMessage) { - message.rating = rating; - - this.notify.debug('Chat View', rating.toLocaleUpperCase(), message); - this.chat.saveChat(this._history$.value); - - if (rating === 'none') { - return; - } - - const msg: KcNotification = { - severity: rating === 'thumbs-up' ? 'success' : 'warn', - icon: - rating === 'thumbs-up' ? PrimeIcons.THUMBS_UP : PrimeIcons.THUMBS_DOWN, - summary: rating === 'thumbs-up' ? 'Context++' : 'Context--', - detail: - rating === 'thumbs-up' - ? `This message will be prioritized for providing context.` - : `This message will not be used to provide context.`, - closable: true, - presentation: 'toast', - }; - this.notify.broadcast(msg); - } - - /** - * Warn the user that the given action will replace the message in the chat history - * @param messages The message(s) to regenerate or delete - * @param acceptLabel The label for the accept button - * @param confirmation The confirmation message to display to the user - * @param header The header to display to the user - * @param emitter The emitter to emit the message to when the user confirms the action - */ - private warnUser( - messages: ChatMessage[], - acceptLabel: string, - confirmation: string, - header: string, - emitter: EventEmitter - ) { - this.confirm.confirm({ - message: confirmation, - icon: `pi pi-exclamation-triangle`, - header: header, - accept: () => { - emitter.emit(messages); - }, - acceptLabel: acceptLabel, - acceptIcon: `pi pi-check`, - rejectIcon: `pi pi-times`, - acceptButtonStyleClass: `p-button-danger`, - rejectButtonStyleClass: `p-button-secondary`, - }); - } - - onClear() { - this.confirm.confirm({ - message: 'Are you sure you want to clear the chat history?', - icon: `pi pi-exclamation-triangle`, - header: 'Clear Chat History', - accept: () => { - this.delete.emit(this.history); - }, - acceptLabel: 'Clear', - acceptIcon: `pi pi-check`, - rejectIcon: `pi pi-times`, - acceptButtonStyleClass: `p-button-danger`, - rejectButtonStyleClass: `p-button-secondary`, - }); - } - - countTokens(value: string) { - this.messageInput.next(value); - } - - blurHistory($event: boolean) { - // If the command bar is active, make the chat history slightly opaque and transparent - if ($event) { - document - .getElementsByClassName('chat-history')[0] - .classList.add('chat-history-active'); - } else { - document - .getElementsByClassName('chat-history')[0] - .classList.remove('chat-history-active'); - } + setRating($event: MessageRating, message: ChatMessage) { + this.chat.rateMessage(message, $event); } } diff --git a/src/kc_angular/src/app/components/chat-components/message-templates/categorize.message.ts b/src/kc_angular/src/app/components/chat-components/message-templates/categorize.message.ts new file mode 100644 index 00000000..091a4f9b --- /dev/null +++ b/src/kc_angular/src/app/components/chat-components/message-templates/categorize.message.ts @@ -0,0 +1,322 @@ +/* + * Copyright (c) 2024 Rob Royce + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core'; +import { ChatMessage } from '@app/models/chat.model'; +import { + ProjectIdentifiers, + ProjectService, +} from '@services/factory-services/project.service'; +import { take } from 'rxjs/operators'; +import { ProjectTreeNode } from '@app/models/project.tree.model'; +import { KcProject, ProjectCreationRequest } from '@app/models/project.model'; +import { ChatService } from '@services/chat-services/chat.service'; +import { IngestService } from '@services/ingest-services/ingest.service'; +import { NotificationsService } from '@services/user-services/notifications.service'; +import { select } from 'async'; + +interface ProjectSuggestion { + project: KcProject | null | undefined; + lineage: string[]; + details: string; + ancestors: ProjectIdentifiers[]; +} + +@Component({ + template: ` +
+
Suggested Projects
+
+ The following projects were suggested for this source: +
+
+
+
+ + + +
+
+
+ +
+ `, + selector: 'chat-categorize', + styles: [ + ` + :host { + justify-content: center; + align-content: center; + } + `, + ], +}) +export class CategorizeMessage implements OnInit { + @Input() message!: ChatMessage; + + @Output() add = new EventEmitter(); + + @Output() create = new EventEmitter(); + + projects: ProjectSuggestion[] = []; + + lines: string[] = []; + + selectedSuggestion = undefined; + + buttonLabel = 'Add'; + + constructor( + private service: ProjectService, + private chat: ChatService, + private ingest: IngestService, + private notify: NotificationsService + ) {} + + ngOnInit() { + this.parseMessage(this.message); + } + + parseMessage(message: ChatMessage) { + // Suggested projects are formatted as follows: + // - Root Project > Subproject > ... > Final Project + // The message will contain at least one project suggestion + + // Get all lines that start with a dash + const lines = message.text + .split('\n') + .filter((line) => line.startsWith('-')) + .map((line) => line.replace('-', '').trim()); + + this.lines = lines; + + // Find the project by searching for the root project + this.service.projectTree.pipe(take(1)).subscribe((tree) => { + for (const line of lines) { + const parts = line.split(':'); + const lineage = parts[0] + .trim() + .split('>') + .map((project) => project.trim()); + + let details = parts[1]?.trim() ?? null; + details = details?.charAt(0).toUpperCase() + details?.slice(1); + + const project = this.find(tree, lineage); + const ancestors = this.service.getAncestors(project?.id.value ?? ''); + + this.projects.push({ + project: + project?.name === lineage[lineage.length - 1] ? project : null, + lineage, + details: details, + ancestors: ancestors ?? [], + }); + } + + if (this.projects.length === 0) { + this.notify.error( + 'Categorize Message', + 'Error parsing categories.', + 'The suggested proejcts message could not be parsed (see console for details).', + 'toast' + ); + console.log('Error parsing message', message); + } + }); + } + + private find( + root: ProjectTreeNode[], + lineage: string[], + last?: ProjectTreeNode + ): KcProject | null | undefined { + // Recursively search for the projects in the lineage until we find the final project + const project = lineage[0]; + const subprojects = lineage.slice(1); + + // Find the node, ignoring case + const node = root.find( + (node) => node.name.toLowerCase() === project.toLowerCase() + ); + + if (!node) { + if (last) { + return this.service.getProject(last.id); + } else { + return null; + } + } else { + if (subprojects.length === 0) { + return this.service.getProject(node.id); + } else { + return this.find(node.subprojects, subprojects, node); + } + } + } + + addProject() { + // Find the project suggestion that matches the selected suggestion + const suggestion = this.projects.find( + (s) => s.lineage.join(' > ') === this.selectedSuggestion + ); + + if (!suggestion) { + console.warn('No suggestion selected'); + return; + } + + // If the project does not exist, create it + if (!suggestion.project) { + this.createProjects(suggestion.lineage, suggestion.ancestors).then( + (parent) => { + this.addSource(parent); + } + ); + } else { + // Otherwise, add the source to the project + this.addSource({ + id: suggestion.project.id.value, + title: suggestion.project.name, + }); + } + } + + setLabel($event: any, project: ProjectSuggestion) { + this.buttonLabel = project.project + ? `Add to ${project.project.name}` + : 'Create and Add'; + } + + private async addSource(project: ProjectIdentifiers) { + this.chat.target$.pipe(take(1)).subscribe((target) => { + if (!target.source) { + console.warn('No source selected'); + return; + } + + const source = target.source; + + if (source.associatedProject.value.trim().length > 0) { + // If the Source is already in a project, we need to move it + this.service.updateProjects([ + { + id: source.associatedProject, + moveKnowledgeSource: { + ks: source, + new: { value: project.id }, + }, + }, + ]); + } else { + // Otherwise it must be in the inbox + this.service + .updateProjects([ + { + id: { value: project.id }, + addKnowledgeSource: [source], + }, + ]) + .then(() => { + setTimeout(() => { + this.ingest.add(source); + this.service.setCurrentProject(project.id); + }, 500); + }); + } + }); + } + + private async createProjects( + lineage: string[], + ancestors: ProjectIdentifiers[] + ) { + /** + * Create the projects in the lineage that do not exist + * @param lineage The lineage of the project to create, starting with the root project + * @param ancestors The ancestors of the project that already exist + */ + let parent = ancestors[ancestors.length - 1] ?? null; + + for (let i = ancestors.length; i < lineage.length; i++) { + const name = lineage[i]; + + const request: ProjectCreationRequest = { + authors: [], + calendar: { events: [], start: null, end: null }, + description: '', + knowledgeSource: [], + name: name, + parentId: { value: parent?.id ?? '' }, + sources: [], + subProjects: [], + topics: [], + type: 'default', + }; + const newProject = await this.service.newProject(request); + parent = { id: newProject.id.value, title: newProject.name }; + + // Wait for 250 ms to allow the project to be created + await new Promise((resolve) => setTimeout(resolve, 250)); + } + + return parent; + } + + protected readonly select = select; +} diff --git a/src/kc_angular/src/app/components/chat-components/message-templates/project.message.ts b/src/kc_angular/src/app/components/chat-components/message-templates/project.message.ts new file mode 100644 index 00000000..e69de29b diff --git a/src/kc_angular/src/app/components/chat-components/message-templates/quiz.message.ts b/src/kc_angular/src/app/components/chat-components/message-templates/quiz.message.ts new file mode 100644 index 00000000..7011f363 --- /dev/null +++ b/src/kc_angular/src/app/components/chat-components/message-templates/quiz.message.ts @@ -0,0 +1,367 @@ +/* + * Copyright (c) 2024 Rob Royce + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Component, Input, OnChanges, SimpleChanges } from '@angular/core'; +import { ChatMessage } from '@app/models/chat.model'; +import { fadeIn, fadeInAndOut, flyInOut } from '@app/animations'; +import { ChatService } from '@services/chat-services/chat.service'; +import { take } from 'rxjs/operators'; + +interface QuizQuestion { + question: string; + choices: string[]; + answer: string; + correct: boolean; + answered: boolean; +} + +@Component({ + selector: 'chat-quiz', + template: ` +
+
+
Nice Work!
+
+
+ +
+
+
+ {{ quip }} +
+
+
+
+
+
Try Again?
+
+ +
+
+ You got {{ numberCorrect }} out of {{ questions.length }} correct +
+
+
+
+
+
Uh oh!
+
+ There was a problem with this quiz. You might need to delete this + message and try again. +
+
+
+
+
+
+
+ Correct! +
+ +
+
Try Again!
+
The correct answer was {{ question.answer }}
+
+
+
+
+
+ {{ questions[questionIndex].question }} +
+
+
+
+ {{ choice }} +
+
+
+
+ {{ questionIndex + 1 }} / + {{ questions.length }} +
+
+
+ Need Help? +
+
+ {{ hint }} +
+
+
+
+
+ `, + styles: [ + ` + :host { + max-width: 100rem !important; + display: flex; + flex-direction: column; + align-items: center; + justify-content: flex-start; + } + .question-choice { + cursor: pointer !important; + display: flex; + flex-direction: column; + align-content: center; + justify-content: center; + align-items: center; + text-align: center !important; + min-width: 50% !important; + height: 5rem; + } + `, + ], + animations: [fadeInAndOut, flyInOut, fadeIn], +}) +export class QuizMessage implements OnChanges { + @Input() message!: ChatMessage; + + /* The questions, choices, and answers */ + questions: QuizQuestion[] = []; + + /* The index of the current question */ + questionIndex = 0; + + complete = false; + + tryAgain = false; + + private quips = [ + 'Look at you go, you little genius', + "It's big brain time", + "You're on fire", + "You're a wizard, Harry", + 'Take it easy, Einstein', + 'How do you know all this stuff?', + 'Next stop, Jeopardy', + 'Can I borrow your brain for a sec?', + 'Your mom would be proud', + "I'd give you a high five if I could", + ]; + + quip: string; + + hint?: string = undefined; + + private audio = { + correct: new Audio(), + incorrect: new Audio(), + complete: new Audio(), + }; + + constructor(private chat: ChatService) { + // Select a random quip to display after the user gets all questions correct + function getRandomInt(max: number) { + return Math.floor(Math.random() * Math.floor(max)); + } + this.quip = this.quips[getRandomInt(this.quips.length)]; + this.audio.correct.src = 'assets/sounds/correct.wav'; + this.audio.incorrect.src = 'assets/sounds/incorrect.wav'; + this.audio.complete.src = 'assets/sounds/complete.wav'; + this.audio.correct.load(); + this.audio.incorrect.load(); + this.audio.complete.load(); + } + + ngOnChanges(changes: SimpleChanges) { + if (changes.message) { + setTimeout(() => { + this.questions = this.parseQuiz(changes.message.currentValue); + }, 100); + } + } + + get numberCorrect() { + return this.questions.filter((question) => question.correct).length; + } + + private parseQuiz(message: ChatMessage): QuizQuestion[] { + const questions: QuizQuestion[] = []; + const qaRegex = /Q.*\n+A.*\n+B.*\n+C.*\n+D.*\n+A.*\n*$/gm; + const matches = message.text.match(qaRegex); + + if (!matches) { + console.warn('No matches found in quiz message: ', message.text); + return []; + } + + for (const match of matches) { + const components = match.split('\n'); + + // Remove any blank elements + for (let i = 0; i < components.length; i++) { + if (components[i] === '') { + components.splice(i, 1); + } + } + + const question: QuizQuestion = { + question: components[0].replace(/Q[0-9]*\. /, ''), + choices: [components[1], components[2], components[3], components[4]], + answer: components[5].replace('Answer: ', '').substring(0, 1), + correct: false, + answered: false, + }; + questions.push(question); + } + + return questions; + } + + answer(question: QuizQuestion, choice: string) { + question.answered = true; + + // Answer index is the index of the choice in the choices array + const answerIndex = question.choices.indexOf(choice); + + // The answer will be either A, B, C, or D + const answer = String.fromCharCode(65 + answerIndex); + + // Check if the answer is correct + question.correct = answer === question.answer; + + // If the answer is correct, play a sound + if (question.correct) { + this.audio.correct.play(); + } else { + this.audio.incorrect.play(); + } + + setTimeout(() => { + this.nextQuestion(); + }, 1650); + } + + nextQuestion() { + this.hint = undefined; + + if (this.questions.every((question) => question.answered)) { + // If all questions were answered correctly, celebrate with a small animation + if (this.questions.every((question) => question.correct)) { + this.complete = true; + this.audio.complete.play(); + return; + } else { + this.tryAgain = true; + this.audio.incorrect.play(); + } + } + + // Otherwise, go to the next question that has not been answered correctly + // Make sure to wrap around to the beginning if necessary + let index = (this.questionIndex + 1) % this.questions.length; + while (index < this.questions.length && this.questions[index].correct) { + index = (index + 1) % this.questions.length; + } + + if (this.questions[index].answered) { + this.questions[index].answered = false; + } + this.questionIndex = index; + } + + help(question: QuizQuestion) { + this.chat.messages$.pipe(take(1)).subscribe((messages) => { + const augmented = [ + ...this.chat.convertToOpenAI(messages), + { + role: 'system', + content: + 'The following is a question from a quiz you gave to the user.', + }, + { + role: 'system', + content: 'Keep in mind it is a multiple choice question.', + }, + { + role: 'system', + content: `Question: ${question.question}`, + }, + { + role: 'system', + content: `The following answer is provided strictly for you to paraphrase and help the user figure it out on their own.`, + }, + { + role: 'system', + content: `${question.answer}`, + }, + { + role: 'system', + content: + 'Do not, under any circumstance, give the user the answer to this question.', + }, + { + role: 'system', + content: 'Please starting your response with "hint: "', + }, + { + role: 'user', + content: + 'I need help trying to figure out this question, but I do not want you to tell me the answer.', + }, + ]; + this.chat + .post('/chat', { messages: augmented }) + .pipe(take(1)) + .subscribe((response) => { + this.hint = response; + }); + }); + } +} diff --git a/src/kc_angular/src/app/components/chat-components/message-templates/topic.message.ts b/src/kc_angular/src/app/components/chat-components/message-templates/topic.message.ts new file mode 100644 index 00000000..f53b26a5 --- /dev/null +++ b/src/kc_angular/src/app/components/chat-components/message-templates/topic.message.ts @@ -0,0 +1,162 @@ +/* + * Copyright (c) 2024 Rob Royce + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Component, Input, OnChanges, SimpleChanges } from '@angular/core'; +import { fadeIn } from '@app/animations'; +import { ChatMessage } from '@app/models/chat.model'; +import { ChatService } from '@services/chat-services/chat.service'; +import { SearchService } from '@services/user-services/search.service'; +import { KcProject } from '@app/models/project.model'; + +export interface TopicDetails { + topic: string; + description?: string; + added?: boolean; + keywords?: string[]; + passages?: string[]; + sources?: any[]; + projects?: KcProject[]; +} + +@Component({ + selector: 'chat-topic', + template: ` +
+
+
+
Topics
+
+
+
+ +
+
+
+
+ {{ topic.topic }} +
+
+ +
+
+
+
+
+
+
+
+ {{ description }} +
+ +
+ Click on a topic to learn more +
+
+
+
+ `, + styles: [ + ` + :host { + width: 100%; + max-width: 100rem !important; + } + `, + ], + animations: [fadeIn], +}) +export class TopicMessage implements OnChanges { + @Input() message!: ChatMessage; + + topics: TopicDetails[] = []; + + description?: string = ''; + + sources: any[] = []; + + constructor(private chat: ChatService, private search: SearchService) {} + + ngOnChanges(changes: SimpleChanges) { + if (changes.message) { + this.topics = this.parseTopics(this.message.text); + + for (const topic of this.topics) { + topic.added = + this.message.source?.topics?.includes(topic.topic) || + this.message.project?.topics?.includes(topic.topic); + } + + setTimeout(() => { + this.onClick(this.topics[0]); + }); + } + } + + private parseTopics(text: string): TopicDetails[] { + const details: TopicDetails[] = []; + let topics: string[] = text.split('\n'); + + // Filter out empty strings + topics = topics.filter( + (topic) => topic.trim() !== '' && topic.startsWith('-') + ); + + for (const topic of topics) { + const split = topic.split(':'); + const topicName = split[0].trim().substring(1).trim(); + const description = split[1]?.trim() ?? ''; + details.push({ topic: topicName, description }); + } + + return details; + } + + addTopic(topic: TopicDetails) { + topic.added = true; + this.chat.addTopic(topic.topic); + } + + onClick(topic: TopicDetails) { + this.description = + this.description === topic?.description ? '' : topic?.description; + } +} diff --git a/src/kc_angular/src/app/components/chat.component.ts b/src/kc_angular/src/app/components/chat.component.ts index c0293d7f..972662a2 100755 --- a/src/kc_angular/src/app/components/chat.component.ts +++ b/src/kc_angular/src/app/components/chat.component.ts @@ -15,36 +15,19 @@ */ import { Component, ViewChild } from '@angular/core'; -import { finalize, map, take, tap } from 'rxjs/operators'; -import { KnowledgeSource } from '../models/knowledge.source.model'; -import { KcProject } from '../models/project.model'; import { ContextMenu } from 'primeng/contextmenu'; import { MenuItem } from 'primeng/api'; import { ProjectService } from '@services/factory-services/project.service'; import { ChatService } from '@services/chat-services/chat.service'; -import { ProjectChat } from '@services/chat-services/project'; import { ChatViewComponent } from './chat-components/chat.view.component'; -import { SourceChat } from '@services/chat-services/source'; -import { AgentType, ChatMessage } from '@app/models/chat.model'; -import { SettingsService } from '@services/ipc-services/settings.service'; -import { combineLatest } from 'rxjs'; -import { ChatCompletionMessage } from 'openai/resources/chat/completions'; +import { Observable } from 'rxjs'; +import { ChatMessage } from '@app/models/chat.model'; @Component({ selector: 'app-chat', template: `
- +
app.chat.display.sourceMessages)), - projects.currentProject.pipe( - map((project) => project || new KcProject('', { value: '' })) - ), - ]) - .pipe( - map(([show, project]) => { - this.showSources = show; - this.chatHistory = []; - this.sources = []; - this.project = project; - let sources: KnowledgeSource[] = project.knowledgeSource; + messages$: Observable; - // Get all the sources for the current project and its subprojects - const subProjects = [...project.subprojects]; - - while (subProjects.length > 0) { - const subProject = subProjects.pop(); - - if (subProject) { - const sub = this.projects.getProject(subProject); - if (sub) { - for (const source of sub.knowledgeSource) { - sources = [...sources, source]; - } - for (const sp of sub.subprojects) { - subProjects.push(sp); - } - } - } - } - - this.sources = sources; - this.loadHistory(); - }) - ) - .subscribe(); - } - - /** - * Save the chat history for the current project and all sources - */ - saveHistory() { - if (this.project) { - const projectHistory = this.chatHistory.filter( - (message) => message.project?.id.value === this.project?.id.value - ); - this.chat.saveChat(projectHistory, this.project?.id); - } - - // Save the individual history for all sources - let sources = this.chatHistory.map((message) => message.source); - - // Get unique sources - sources = sources.filter((source, index) => { - return sources.indexOf(source) === index; + constructor(private chat: ChatService, private projects: ProjectService) { + this.messages$ = this.chat.messages$; + this.projects.currentProject.subscribe((project) => { + this.chat.setTarget({ project: project }); }); - - for (const source of sources) { - if (source) { - const sourceHistory = this.chatHistory.filter( - (message) => message.source?.id.value === source?.id.value - ); - this.chat.saveChat(sourceHistory, source.id); - } - } - } - - /** - * Load the chat history for the current project and all sources - */ - loadHistory() { - this.loading = true; - this.chatHistory = []; - - if (this.project?.id) { - const projectChat = this.chat.loadChat( - this.project.id, - this.project, - undefined - ); - for (const message of projectChat) { - this.chatHistory.push(message); - } - } - - // Load the source history - if (this.showSources) { - for (const source of this.sources) { - const sourceChat = this.chat.loadChat(source.id, undefined, source); - for (const message of sourceChat) { - this.chatHistory.push(message); - } - } - } - - // Sort history by timestamp - this.chatHistory = this.chatHistory.sort( - (a, b) => a.timestamp.getTime() - b.timestamp.getTime() - ); - this.loading = false; - } - - /** - * Create a new chat message and send it to the chat service on submit - * @param $event The text of the message - */ - onSubmit($event: string) { - this.loading = true; - - if (this.chatHistory.length === 0) { - this.chatHistory = []; - } - - this.chatHistory.push( - this.chat.createMessage( - AgentType.User, - AgentType.Project, - $event, - this.project - ) - ); - this.chatView.scroll(); - - this.projectChat - .send(this.project, this.chatHistory) - .pipe( - take(1), - tap((response: ChatCompletionMessage) => { - if (response.content) { - this.chatHistory.push( - this.chat.createMessage( - AgentType.Project, - AgentType.User, - response.content, - this.project - ) - ); - } - this.saveHistory(); - this.chatView.scroll(); - }), - finalize(() => { - // If the user message was not responded to, remove it from the history - if ( - this.chatHistory[this.chatHistory.length - 1].sender !== - AgentType.Project - ) { - // Remove first element - this.chatHistory.shift(); - } - this.loading = false; - }) - ) - .subscribe(); - } - - /** - * Delete a message from the history - * @param messages The message(s) to delete - */ - onDeleteMessage(messages: ChatMessage[]) { - // Remove messages from the history and save - this.chatHistory = this.chatHistory.filter( - (message) => !messages.includes(message) - ); - } - - /** - * Regenerate a message by sending it and its history to the chat service - * @param message The message to regenerate - */ - regenerateMessage(message: ChatMessage) { - if (!this.project) { - return; - } - - this.loading = true; - - /* Use the message index to get the history up to that point - * Source history should only include messages to/from the same source - * Project history should include messages from the project and any sources that are part of the project */ - const historySlice = this.chat.getHistory( - message, - this.chatHistory, - false, - !message.project - ); - - /* Create a new message with the same text as the message that was responded to */ - const promptMessage = historySlice[historySlice.length - 1]; - - if (message.project) { - this.projectChat - .send(message.project, historySlice, promptMessage.text) - .pipe( - take(1), - tap((response: ChatCompletionMessage) => { - if (response.content) { - message.text = response.content; - message.regenerated = true; - this.saveHistory(); - } - }), - finalize(() => { - this.loading = false; - }) - ) - .subscribe(); - } else if (message.source) { - this.sourceChat - .send(message.source, historySlice, promptMessage.text) - .pipe( - take(1), - tap((response: ChatCompletionMessage) => { - if (response.content) { - message.text = response.content; - message.regenerated = true; - this.saveHistory(); - } - }), - finalize(() => { - this.loading = false; - }) - ) - .subscribe(); - } } } diff --git a/src/kc_angular/src/app/components/home.component.ts b/src/kc_angular/src/app/components/home.component.ts index 8b7eabb3..ce4d69ef 100755 --- a/src/kc_angular/src/app/components/home.component.ts +++ b/src/kc_angular/src/app/components/home.component.ts @@ -1,5 +1,5 @@ /* - * Copyright (c) 2022-2023 Rob Royce + * Copyright (c) 2022-2024 Rob Royce * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -53,7 +53,7 @@ import { finalize, map, take, takeUntil, tap } from 'rxjs/operators'; >
- - - - - - - -
-
-
- Project Type: {{ kcProject.type || 'Default' | titlecase - }}
- Date Created: {{ kcProject.dateCreated | date : 'medium' - }}
- Last Modified: {{ kcProject.dateModified | date : 'medium' - }}
- Last Accessed: {{ kcProject.dateAccessed | date : 'medium' - }}
-
-
Topics: None
-
- Knowledge Sources: {{ kcProject.knowledgeSource.length - }}
- Subprojects: {{ kcProject.subprojects.length }} -
- Description: -
- {{ kcProject.description || 'None' }}
-
-
-
-
- - - -
-
Subprojects
- - - {{ subproject | projectName }} - - - None - -
-
- - -
- Topics: - -
-
- `, - styles: [], -}) -export class ProjectCardComponent { - /** - * The project to be displayed on this card. - */ - @Input() kcProject?: KcProject | null; - - /** - * Whether to display the "Archive Project" button. - * Default is false. - */ - @Input() showArchive = false; - - /** - * Whether to display the "Remove Project" button. - * Default is true. - */ - @Input() showRemove = true; - - /** - * Whether to display the "Edit Project" button. - * Default is true. - */ - @Input() showEdit = true; - - /** - * Whether to display the "Add Subproject" button. - * Default is true. - */ - @Input() showAddSubproject = true; - - /** - * Whether to display the "GoTo Project" button. - * Default is true. - */ - @Input() showNavigate = true; - - /** - * Whether to display the "GoTo Project" button for subprojects. - * Default is true. - */ - @Input() showSubprojectNavigate = true; - - /** - * Emitted when the "edit" button is pressed. - * Contains the ID of the project to be edited. - */ - @Output() onEditProject = new EventEmitter(); - - /** - * Emitted when the "GoTo" button is pressed. - * Contains the ID of the project to be navigated to. - */ - @Output() onNavigateToProject = new EventEmitter(); - - /** - * Emitted when the "Add Subproject" button is pressed. - * Contains the ID of the parent project. - */ - @Output() onAddSubproject = new EventEmitter(); - - /** - * Emitted when the "Remove Project" button is pressed. - * Contains the ID of the project to be removed. - */ - @Output() onRemoveProject = new EventEmitter(); - - /** - * Emitted when the "Archive Project" button is pressed. - * Contains the ID of the project to be archived. - */ - @Output() onArchiveProject = new EventEmitter(); - - topics: string[] = []; - - constructor(private data: DataService) { - data.ksList.subscribe((ksList) => { - if (ksList.length > 0) { - this.topics = Array.from( - new Set( - ksList - .map((ks) => ks.topics) - .reduce((previousValue, currentValue) => { - if (previousValue && currentValue) { - return previousValue.concat(currentValue); - } else if (previousValue) { - return previousValue; - } else if (currentValue) { - return currentValue; - } else { - return []; - } - }) - ) - ); - } else { - this.topics = []; - } - }); - } - - /** - * Convenience method that transforms a project ID string to UuidModel - * @param id - */ - navigate(id: string) { - this.onNavigateToProject.emit({ value: id }); - } -} diff --git a/src/kc_angular/src/app/components/source-components/ks-card.component.ts b/src/kc_angular/src/app/components/source-components/ks-card.component.ts index ed0ae459..ca394941 100755 --- a/src/kc_angular/src/app/components/source-components/ks-card.component.ts +++ b/src/kc_angular/src/app/components/source-components/ks-card.component.ts @@ -35,7 +35,7 @@ import { DragAndDropService } from '@services/ingest-services/drag-and-drop.serv selector: 'app-ks-card', template: `
@@ -262,6 +263,9 @@ export class KsCardComponent implements OnDestroy, OnChanges { /* Set an optional label */ @Input() label?: string; + /* Determines whether to allow dragging of the card (default: true) */ + @Input() allowDrag = true; + /* EventEmitter that is triggered when the "Remove" button is pressed */ @Output() onRemove = new EventEmitter(); @@ -433,10 +437,59 @@ export class KsCardComponent implements OnDestroy, OnChanges { } dragStart($event: DragEvent, ks: KnowledgeSource) { - this.dnd.dragSource($event, ks); + if (this.allowDrag) { + this.dnd.dragSource($event, ks); + } } dragEnd($event: any, rowData: any) { - this.dnd.dragSourceEnd($event, rowData); + if (this.allowDrag) { + this.dnd.dragSourceEnd($event, rowData); + } + } + + openSource() { + if (this.onOpen.observed) { + this.onOpen.emit(this.ks); + } else { + this.command.open(this.ks); + } + } + + removeSource() { + if (this.onRemove.observed) { + this.onRemove.emit(this.ks); + } else { + this.command.remove([this.ks]); + } + } + + previewSource() { + if (this.onPreview.observed) { + this.onPreview.emit(this.ks); + } else { + this.command.preview(this.ks); + } + } + + editSource() { + if (this.onEdit.observed) { + this.onEdit.emit(this.ks); + } else { + this.command.detail(this.ks); + } + } + + chatSource() { + if (this.onChat.observed) { + this.onChat.emit(this.ks); + } else { + this.command.chat(this.ks); + } + } + + flagSource(checked: boolean) { + this.ks.flagged = checked; + this.command.update([this.ks]); } } diff --git a/src/kc_angular/src/app/components/source-components/ks-table.component.ts b/src/kc_angular/src/app/components/source-components/ks-table.component.ts index 146b11c9..2740515c 100755 --- a/src/kc_angular/src/app/components/source-components/ks-table.component.ts +++ b/src/kc_angular/src/app/components/source-components/ks-table.component.ts @@ -215,32 +215,6 @@ import { DragAndDropService } from '@services/ingest-services/drag-and-drop.serv {{ rowData[col.field] | date : 'mediumDate' }}
- -
-
-
-
-
-
- {{ rowData[col.field] | date : 'mediumDate' }} -
-
- {{ rowData[col.field] | countdown }} -
-
- -
- {{ rowData[col.field] | date : 'mediumDate' }} -
-
- {{ rowData[col.field] | countdown }} -
-
-
-
-
+
+
+ {{ note.title }} +
+
+ {{ note.content }} +
+
+
+
+ + +
+
+
+ `, + styles: [ + ` + :host { + } + `, + ], +}) +export class SourceNoteComponent { + @Input() note?: SourceNote; + + @Output() addNote = new EventEmitter(); + + constructor() {} +} diff --git a/src/kc_angular/src/app/components/source-components/source.component.ts b/src/kc_angular/src/app/components/source-components/source.component.ts index f89839a4..fd9ff464 100755 --- a/src/kc_angular/src/app/components/source-components/source.component.ts +++ b/src/kc_angular/src/app/components/source-components/source.component.ts @@ -30,7 +30,6 @@ import { import { SourceChatComponent } from '@components/source-components/source.chat.component'; import { SourceDetailsComponent } from '@components/source-components/source.details.component'; import { KnowledgeSource } from '@app/models/knowledge.source.model'; -import { SourceMetadataComponent } from '@components/source-components/source.metadata.component'; import { PrimeIcons } from 'primeng/api'; import { SourceDocumentComponent } from '@components/source-components/source.document.component'; import { SourceVideoComponent } from '@components/source-components/source.video.component'; @@ -132,14 +131,14 @@ export class SourceComponent implements OnInit, OnChanges { tabContainer!: ViewContainerRef; detailsTab: TabDescriptor = { - label: 'Details', + label: 'Source', icon: PrimeIcons.INFO_CIRCLE, hidden: false, disabled: false, loading: false, component: SourceDetailsComponent, - tipHeader: 'Ready for the Details?', - tipMessage: `Head over to the details tab! It will tell you everything you need to know about the Source, including title, description, and topics.`, + tipHeader: 'Source Details', + tipMessage: `This tab shows you all the details about the Source. It's like the Source's personal ID card, with all the important information you need to know.`, }; chatTab: TabDescriptor = { @@ -197,17 +196,6 @@ export class SourceComponent implements OnInit, OnChanges { tipMessage: `Meet the video tab! Your personal movie theater, playing Source videos straight from YouTube using the video ID in the Source link. Sit back, relax, and enjoy the show!`, }; - metadataTab: TabDescriptor = { - label: 'Metadata', - icon: PrimeIcons.HASHTAG, - hidden: false, - disabled: false, - loading: false, - component: SourceMetadataComponent, - tipHeader: 'Ready to go Meta?', - tipMessage: `Step into the metadata tab! It's your personal viewing deck for all things meta about the Source. And guess what? You can easily copy any of the metadata to your clipboard with a single click!`, - }; - tabs: TabDescriptor[] = [ this.detailsTab, this.chatTab, @@ -215,7 +203,6 @@ export class SourceComponent implements OnInit, OnChanges { this.browserTab, this.documentTab, this.videoTab, - this.metadataTab, ]; selectedTabIndex = 0; @@ -261,10 +248,6 @@ export class SourceComponent implements OnInit, OnChanges { } views(source: KnowledgeSource) { - // Metadata is disabled if no metadata exists - this.metadataTab.disabled = - !this.source.reference.source.website?.metadata?.meta; - // If video, enable video tab and disable document tab if (source.ingestType === 'website') { source.accessLink = new URL(source.accessLink); diff --git a/src/kc_angular/src/app/components/source-components/source.details.component.ts b/src/kc_angular/src/app/components/source-components/source.details.component.ts index c19bdfc6..22b8d25d 100755 --- a/src/kc_angular/src/app/components/source-components/source.details.component.ts +++ b/src/kc_angular/src/app/components/source-components/source.details.component.ts @@ -14,7 +14,14 @@ * limitations under the License. */ -import { Component, EventEmitter, OnInit, Output } from '@angular/core'; +import { + Component, + ElementRef, + EventEmitter, + OnInit, + Output, + ViewChild, +} from '@angular/core'; import { KnowledgeSource } from '@app/models/knowledge.source.model'; import { FormBuilder, FormGroup, Validators } from '@angular/forms'; import { debounceTime, distinctUntilChanged } from 'rxjs/operators'; @@ -23,263 +30,254 @@ import { ElectronIpcService } from '@services/ipc-services/electron-ipc.service' import { NotificationsService } from '@services/user-services/notifications.service'; import { SearchService } from '@services/user-services/search.service'; import { skip } from 'rxjs'; +import { Clipboard } from '@angular/cdk/clipboard'; @Component({ selector: 'source-details', template: ` -
+
-
- -
- - -
-
-
-
- -
-
-

Title

- -
-
-

Topics

- - -
+ +
+
+ +
+
+
+ +
+
+ +
- -
+
+ + + +
+
+ + +
+
+
+
+
-
-
-
+

Metadata

+ + + + + + + Tag + + + + Value + + + + + + + -

Notes

-
- -
+ + + + + + + {{ meta.key }} + + + + + + + + + +
+ {{ meta.value }} +
+
+
+ + +
+ +
-
-
- -
-
-
-
-
- -
-
-
-
-
-
-
- -
-
-
-

Model

-
-
- - -
- -
- - -
- -
- - -
- -
- - -
- -
- - -
- -
- - -
- -
- - - -
-
-
-
-
+ + + + +
`, styleUrls: ['./source.styles.scss'], }) export class SourceDetailsComponent implements OnInit { + @ViewChild('tagInput') tagInput!: ElementRef; + + @Output() remove = new EventEmitter(); + + @Output() update = new EventEmitter(); + source!: KnowledgeSource; form: FormGroup; showMarkdownPreview = true; - @Output() remove = new EventEmitter(); + metaUpdates: { [id: number]: { key: string; value: string } } = {}; - @Output() update = new EventEmitter(); + metaEntry: { key: string; value: string } = { key: '', value: '' }; constructor( + private clipboard: Clipboard, private fb: FormBuilder, private command: KsCommandService, private ipc: ElectronIpcService, @@ -310,6 +308,44 @@ export class SourceDetailsComponent implements OnInit { ngOnInit() { this.form.patchValue(this.source); + + if (!this.source.meta) { + this.source.meta = []; + } + + if (this.source.reference.source.website?.metadata?.meta) { + for (const m of this.source.reference.source.website.metadata.meta) { + // Add to source.meta if it doesn't exist + if (!this.source.meta.find((meta) => meta.key === m.key)) { + if (m.key && m.value) { + this.source.meta.push({ key: m.key, value: m.value }); + } + } + } + + this.source.meta = [ + { key: 'knowledge:id', value: this.source.id.value }, + { + key: 'knowledge:method', + value: this.source.importMethod ?? 'manual', + }, + { + key: 'knowledge:link', + value: this.source.accessLink.toString(), + }, + { + key: 'knowledge:type', + value: this.source.ingestType, + }, + ...this.source.meta, + ]; + delete this.source.reference.source.website.metadata.meta; + this.update.emit(this.source); + } + + for (let i = 0; i < this.source.meta.length; i++) { + this.source.meta[i].id = i; + } } flag() { @@ -342,4 +378,62 @@ export class SourceDetailsComponent implements OnInit { this.command.open(this.source); } } + + toClipboard(key: string) { + if (key && key.trim().length > 0) { + this.clipboard.copy(key); + this.notify.success('Source Info', 'Copied to Clipboard', ''); + } + } + + onRowEditSave(meta: { key: string; value: string }, ri: number) { + if (!meta.key) { + return; + } + const original = this.source.meta.find((m) => m.key === meta.key); + const updated = this.metaUpdates[ri]; + // If they are the same, do nothing + if (original && updated) { + if (original.value === updated.value && original.key === updated.key) { + return; + } else { + this.update.emit(this.source); + } + } + } + + onRowEditCancel(meta: { key: string; value: string }, ri: number) { + if (meta.key) { + this.source.meta[ri] = this.metaUpdates[ri]; + delete this.metaUpdates[ri]; + } + } + + onRowEditInit(meta: { key: string; value: string }, ri: number) { + this.metaUpdates[ri] = { ...meta }; + } + + addMeta(metaEntry: { key: string; value: string }) { + const entry = { ...metaEntry }; + + if (entry.key && entry.value) { + this.source.meta = [ + { key: entry.key, value: entry.value }, + ...this.source.meta, + ]; + this.metaEntry = { key: '', value: '' }; + this.update.emit(this.source); + this.tagInput.nativeElement.focus(); + } else { + this.notify.error('Source Info', 'Missing Key or Value', ''); + } + } + + onRowDelete(meta: { key: string; value: string }, ri: number) { + this.source.meta = this.source.meta.filter( + (m) => m.key !== meta.key && m.value !== meta.value + ); + delete this.metaUpdates[ri]; + this.update.emit(this.source); + } } diff --git a/src/kc_angular/src/app/components/source-components/source.metadata.component.ts b/src/kc_angular/src/app/components/source-components/source.metadata.component.ts deleted file mode 100755 index 4ae3422b..00000000 --- a/src/kc_angular/src/app/components/source-components/source.metadata.component.ts +++ /dev/null @@ -1,113 +0,0 @@ -/* - * Copyright (c) 2023-2024 Rob Royce - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { Component, OnInit } from '@angular/core'; -import { KnowledgeSource } from '@app/models/knowledge.source.model'; -import { WebsiteMetaTagsModel } from '@shared/models/web.source.model'; -import { NotificationsService } from '@services/user-services/notifications.service'; -import { Clipboard } from '@angular/cdk/clipboard'; - -@Component({ - selector: 'source-metadata', - template: ` - - `, - styleUrls: ['./source.styles.scss'], -}) -export class SourceMetadataComponent implements OnInit { - source!: KnowledgeSource; - - /* If the source is a website, this will contain the metadata for the website and sanitized to prevent XSS attacks */ - ksMetadata: WebsiteMetaTagsModel[] = []; - - constructor( - private clipboard: Clipboard, - private notify: NotificationsService - ) {} - - ngOnInit(): void { - this.ksMetadata = - this.source.reference.source.website?.metadata?.meta ?? []; - } - - /* When the user clicks on the "Copy" button, copy the source key to the clipboard */ - toClipboard(key: string) { - if (key && key.trim().length > 0) { - this.clipboard.copy(key); - this.notify.success('Source Info', 'Copied to Clipboard', ''); - } - } -} diff --git a/src/kc_angular/src/app/components/source-components/source.notes.component.ts b/src/kc_angular/src/app/components/source-components/source.notes.component.ts deleted file mode 100755 index a15bb744..00000000 --- a/src/kc_angular/src/app/components/source-components/source.notes.component.ts +++ /dev/null @@ -1,26 +0,0 @@ -/* - * Copyright (c) 2023 Rob Royce - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { Component } from '@angular/core'; - -@Component({ - selector: 'source-notes', - template: ``, - styles: [``], -}) -export class SourceNotesComponent { - constructor() {} -} diff --git a/src/kc_angular/src/app/models/knowledge.source.model.ts b/src/kc_angular/src/app/models/knowledge.source.model.ts index 87cecff4..3c3f2fde 100755 --- a/src/kc_angular/src/app/models/knowledge.source.model.ts +++ b/src/kc_angular/src/app/models/knowledge.source.model.ts @@ -13,11 +13,11 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { AuthorModel } from '../../../../kc_shared/models/author.model'; -import { FileSourceModel } from '../../../../kc_shared/models/file.source.model'; -import { UUID } from '../../../../kc_shared/models/uuid.model'; -import { ImportMethod } from '../../../../kc_shared/models/knowledge.source.model'; -import { WebSourceModel } from '../../../../kc_shared/models/web.source.model'; +import { AuthorModel } from '@shared/models/author.model'; +import { FileSourceModel } from '@shared/models/file.source.model'; +import { UUID } from '@shared/models/uuid.model'; +import { ImportMethod } from '@shared/models/knowledge.source.model'; +import { WebSourceModel } from '@shared/models/web.source.model'; export type IngestType = | 'file' @@ -62,6 +62,13 @@ export type KnowledgeSourceEvent = { iconText?: string; }; +export type SourceNote = { + title: string; + content: string; + createdAt: Date; + updatedAt: Date; +}; + export class KnowledgeSource { associatedProject: UUID; authors: AuthorModel[]; @@ -77,6 +84,8 @@ export class KnowledgeSource { ingestType: IngestType; rawText?: string; flagged?: boolean; + meta: { id?: number; key: string; value: string }[] = []; + notes: SourceNote[] = []; title: string; topics?: string[]; accessLink: URL | string; @@ -101,6 +110,7 @@ export class KnowledgeSource { this.dateAccessed = []; this.accessLink = reference.link; this.flagged = false; + this.notes = []; this.topics = []; // TODO: this should be replaced with the new EventModel this.events = [ @@ -112,22 +122,4 @@ export class KnowledgeSource { } } -export class KnowledgeSourceNote { - text: string; - dateCreated: string; - dateModified: string; - dateAccessed: string; - - constructor(text?: string) { - this.text = text ? text : ''; - this.dateCreated = Date(); - this.dateAccessed = Date(); - this.dateModified = Date(); - } - - static blank() { - return new KnowledgeSourceNote(''); - } -} - export class KnowledgeSourceMarkup {} diff --git a/src/kc_angular/src/app/services/factory-services/ks-factory.service.ts b/src/kc_angular/src/app/services/factory-services/ks-factory.service.ts index d7386fae..ab41037c 100755 --- a/src/kc_angular/src/app/services/factory-services/ks-factory.service.ts +++ b/src/kc_angular/src/app/services/factory-services/ks-factory.service.ts @@ -220,6 +220,8 @@ export class KsFactoryService { }, link: '', }, + meta: [], + notes: [], ingestType: 'website', associatedProject: { value: '' }, // TODO: remove these to align with new model... @@ -356,6 +358,15 @@ export class KsFactoryService { if (ks.reference.source.website) ks.reference.source.website.metadata = metadata; + + ks.meta = []; + if (metadata.meta) { + for (const meta of metadata.meta) { + if (meta.key && meta.value) { + ks.meta.push({ key: meta.key, value: meta.value }); + } + } + } }) .catch((reason) => { console.warn('Unable to extract website metadata because: ', reason); diff --git a/src/kc_angular/src/styles.scss b/src/kc_angular/src/styles.scss index 63906f13..00cac58f 100755 --- a/src/kc_angular/src/styles.scss +++ b/src/kc_angular/src/styles.scss @@ -444,7 +444,7 @@ source-chat, source-details, source-document, source-metadata, source-timeline, @include styleclass('w-full flex flex-wrap p-2 max-h-full'); .source-header-div { - @include styleclass('col-12 lg:col-6 flex flex-column max-h-full'); + @include styleclass('flex flex-column max-h-full w-full'); .source-thumbnail { @include styleclass('w-full flex flex-column flex-auto'); @@ -476,29 +476,15 @@ source-chat, source-details, source-document, source-metadata, source-timeline, } .source-model { - @include styleclass('w-full flex col-12 flex-wrap p-2 flex-column'); + @include styleclass('w-full flex col-12 flex-wrap flex-column'); } .source-youtube-player { @include styleclass('w-full h-full'); } -.source-metadata { - @media (min-width: 0px) and (max-width: 999px) { - max-width: min(45vw, 70rem) !important; - } - - @media (min-width: 1000px) and (max-width: 1199px) { - max-width: min(50vw, 80rem) !important; - } - - @media (min-width: 1200px) and (max-width: 1599px) { - max-width: min(45vw, 70rem) !important; - } - - @media (min-width: 1600px) { - max-width: min(60vw, 1000rem) !important; - } +.source-metadata, .source-notes { + @include styleclass('w-full flex flex-wrap p-2 max-h-full'); } .title-bar { diff --git a/src/kc_shared/models/web.source.model.ts b/src/kc_shared/models/web.source.model.ts index 4e372862..19fdb915 100755 --- a/src/kc_shared/models/web.source.model.ts +++ b/src/kc_shared/models/web.source.model.ts @@ -1,59 +1,55 @@ -/** - Copyright 2022 Rob Royce - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. +/* + * Copyright (c) 2024 Rob Royce + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. */ -import {WebSourceMarkup} from "./markup.model"; +import { WebSourceMarkup } from "./markup.model"; export interface WebSourceModel { - accessLink: string, - iconUrl?: string, - topics?: string[], - metadata?: WebsiteMetadataModel, - markup?: WebSourceMarkup, - flagged?: boolean, - rawText?: string, - thumbnail?: string, - title?: string, - description?: string + accessLink: string; + iconUrl?: string; + topics?: string[]; + metadata?: WebsiteMetadataModel; + markup?: WebSourceMarkup; + flagged?: boolean; + rawText?: string; + thumbnail?: string; + title?: string; + description?: string; } export interface WebsiteMetadataModel { - title?: string, - meta?: WebsiteMetaTagsModel[], - icon?: any, - article?: ArticleModel, - code?: CodeModel[], - links?: string[] + title?: string; + meta?: WebsiteMetaTagsModel[]; + icon?: any; + article?: ArticleModel; + code?: CodeModel[]; + links?: string[]; } export interface WebsiteMetaTagsModel { - key?: string | null, - value?: string | null, - property?: string | null + key?: string | null; + value?: string | null; + property?: string | null; } export interface WebsiteContentModel { - title?: string, - type?: string, - text?: string, - html?: string + title?: string; + type?: string; + text?: string; + html?: string; } -export interface ArticleModel extends WebsiteContentModel { +export type ArticleModel = WebsiteContentModel; -} - -export interface CodeModel extends WebsiteContentModel { - -} +export type CodeModel = WebsiteContentModel; From 01568ee37293137588663a0e5b032bfbeedb3186 Mon Sep 17 00:00:00 2001 From: Rob Royce Date: Tue, 13 Feb 2024 22:07:12 -0800 Subject: [PATCH 30/37] style: fix text wrapping in metadata table. --- .../components/source-components/source.details.component.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/kc_angular/src/app/components/source-components/source.details.component.ts b/src/kc_angular/src/app/components/source-components/source.details.component.ts index 22b8d25d..d7cf1674 100755 --- a/src/kc_angular/src/app/components/source-components/source.details.component.ts +++ b/src/kc_angular/src/app/components/source-components/source.details.component.ts @@ -188,7 +188,7 @@ import { Clipboard } from '@angular/cdk/clipboard'; From 35199cb4c577f0e25429e2f42eccff5ba2c8c016 Mon Sep 17 00:00:00 2001 From: Rob Royce Date: Wed, 14 Feb 2024 20:42:33 -0800 Subject: [PATCH 31/37] style: fix chat command sizing. --- .../src/app/components/chat-components/chat.input.component.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/kc_angular/src/app/components/chat-components/chat.input.component.ts b/src/kc_angular/src/app/components/chat-components/chat.input.component.ts index 735fc5c5..c01451a7 100644 --- a/src/kc_angular/src/app/components/chat-components/chat.input.component.ts +++ b/src/kc_angular/src/app/components/chat-components/chat.input.component.ts @@ -437,7 +437,7 @@ export class ChatInputComponent { // The command box should be the same width as the input element const chatBar = document.getElementById('chat-bar'); - this.commandWidth = `calc(${chatBar?.offsetWidth}px - 1rem)`; + this.commandWidth = `calc(${chatBar?.offsetWidth}px - 3rem)`; // Center the command box above the chat input element this.commandLeft = `${inputRect.left}px`; From 40daccbd8b7569d3739768158551975efbc073ac Mon Sep 17 00:00:00 2001 From: Rob Royce Date: Sun, 23 Jun 2024 14:05:55 -0700 Subject: [PATCH 32/37] feat: add support for GPT-4o --- package.json | 2 +- src/kc_electron/package.json | 2 +- .../src/local/utils/tokenizer.utils.ts | 32 ++++++++++++++--- src/kc_shared/models/chat.model.ts | 36 ++++++++++--------- yarn.lock | 10 +++--- 5 files changed, 54 insertions(+), 28 deletions(-) diff --git a/package.json b/package.json index f9c091d8..65d90bfd 100755 --- a/package.json +++ b/package.json @@ -26,7 +26,7 @@ "clean": "rm -rf dist && yarn workspaces foreach run clean", "purge": "yarn workspaces foreach run purge && rm -rf node_modules && yarn cache clean", "pack": "electron-builder --dir", - "dist": "electron-builder --publish never", + "dist": "electron-builder --publish never --x64 --arm64", "dist-all": "electron-builder -mwl --x64 --arm64 --publish never", "interactive-upgrade": "yarn upgrade-interactive", "publish": "electron-builder --publish always", diff --git a/src/kc_electron/package.json b/src/kc_electron/package.json index 560f79c4..727759fe 100755 --- a/src/kc_electron/package.json +++ b/src/kc_electron/package.json @@ -16,7 +16,7 @@ "watch-electron-dev": "webpack --config webpack.dev.js --watch" }, "dependencies": { - "@dqbd/tiktoken": "^1.0.7", + "@dqbd/tiktoken": "^1.0.15", "chokidar": "^3.5.3", "cors": "^2.8.5", "crypto-js": "^4.2.0", diff --git a/src/kc_electron/src/local/utils/tokenizer.utils.ts b/src/kc_electron/src/local/utils/tokenizer.utils.ts index 868a48cd..5acea207 100755 --- a/src/kc_electron/src/local/utils/tokenizer.utils.ts +++ b/src/kc_electron/src/local/utils/tokenizer.utils.ts @@ -154,8 +154,11 @@ export default class TokenizerUtils { return messages; } + /** + * Remove duplicate messages from a list of messages. + * @param messages + */ deduplicate(messages: ChatCompletionMessageParam[]) { - // Remove duplicate messages using a hash map based on message content. const unique: ChatCompletionMessageParam[] = []; const hash: { [key: string]: boolean } = {}; messages.forEach((message) => { @@ -172,10 +175,18 @@ export default class TokenizerUtils { return unique; } + /** + * Count the number of tokens in a text for the current model. + * @param text + */ countTokens(text: string) { return this.tiktoken.encode(text).length + 5; } + /** + * Count the number of tokens in a list of messages for the current model. + * @param messages + */ countMessageTokens(messages: ChatCompletionMessageParam[]): number { let tokenCount = 0; messages.forEach((message: ChatCompletionMessageParam) => { @@ -186,10 +197,11 @@ export default class TokenizerUtils { return tokenCount + (messages.length > 1 ? 3 : 0); } + /** + * Given a text, chunk it into smaller pieces that fit within the token limit. + * @param text + */ chunkLimitText(text: string): string[] { - /** - * Given a text, chunk it into pieces that are within the token limit. - */ const model = this.verifiedModel(); const maxTokens = model.token_limit - model.max_tokens - 512; @@ -218,6 +230,11 @@ export default class TokenizerUtils { return chunkedText; } + /** + * Given a text, limit it to the token limit of the model. + * @param text The text to limit + * @param padding The number of tokens to pad the limit with + */ limitText(text: string, padding = 350): string { const model = this.verifiedModel(); const maxTokens = Math.max( @@ -242,6 +259,10 @@ export default class TokenizerUtils { return text; } + /** + * Get the path to the tiktoken wasm file. This is used to initialize the tokenizer. + * @private + */ private getWasmPath() { // TODO: Figure out how to do this in a cleaner way const possiblePaths = [ @@ -266,6 +287,9 @@ export default class TokenizerUtils { throw new Error("Could not find tiktoken wasm file."); } + /** + * Get the model object for the current model. Throws an error if the model is not found. + */ private verifiedModel(): ChatModel { const model = SupportedChatModels.find( (model) => model.name === this.model diff --git a/src/kc_shared/models/chat.model.ts b/src/kc_shared/models/chat.model.ts index 86758d9b..9d311f91 100644 --- a/src/kc_shared/models/chat.model.ts +++ b/src/kc_shared/models/chat.model.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -export type ChatModelName = "gpt-3.5-turbo" | "gpt-4"; +export type ChatModelName = "gpt-3.5-turbo" | "gpt-4" | "gpt-4o"; /** * Used for user settings and for the back-end chat controller. @@ -68,25 +68,14 @@ const GPT3p5Turbo: ChatModel = { name: "gpt-3.5-turbo", label: "GPT-3.5 Turbo", description: "GPT-3.5 Turbo is optimized for dialogue.", - token_limit: 4096, - input_kilo_cost: 0.0015, - output_kilo_cost: 0.002, + token_limit: 16384, + input_kilo_cost: 0.0005, + output_kilo_cost: 0.0015, max_tokens_upper_bound: 2048, - max_tokens: 512, // Default + max_tokens: 256, // Default ...OpenAIDefaults, }; -/*TODO: not available in tiktoken yet - const GPT3p5Turbo16k: ChatModel = { - name: "gpt-3.5-turbo-16k", - label: "GPT-3.5 Turbo 16k", - description: - "GPT-3.5 Turbo is optimized for dialogue. This model has a larger token limit than GPT-3.5 Turbo, so you can enter more text at once.", - tokenLimit: 16384, - inputCostPer1k: 0.003, - outputCostPer1k: 0.004, -};*/ - const GPT4: ChatModel = { name: "gpt-4", label: "GPT-4", @@ -96,8 +85,21 @@ const GPT4: ChatModel = { input_kilo_cost: 0.03, output_kilo_cost: 0.06, max_tokens_upper_bound: 4096, + max_tokens: 512, + ...OpenAIDefaults, +}; + +const GPT4o: ChatModel = { + name: "gpt-4o", + label: "GPT-4o", + description: + "OpenAI's most advanced, multimodal flagship model that’s cheaper and faster than GPT-4 Turbo.", + token_limit: 128000, + input_kilo_cost: 0.005, + output_kilo_cost: 0.015, + max_tokens_upper_bound: 8192, max_tokens: 1024, ...OpenAIDefaults, }; -export const SupportedChatModels: ChatModel[] = [GPT3p5Turbo, GPT4]; +export const SupportedChatModels: ChatModel[] = [GPT3p5Turbo, GPT4, GPT4o]; diff --git a/yarn.lock b/yarn.lock index a4840900..15e81b9d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2117,10 +2117,10 @@ __metadata: languageName: node linkType: hard -"@dqbd/tiktoken@npm:^1.0.7": - version: 1.0.7 - resolution: "@dqbd/tiktoken@npm:1.0.7" - checksum: 81049797dcffa101c57b68a78be19b11958edf6e6cfffab7ccd3ec2e5290e3b27bc25f1060a1c34e1cb94f22e0f46d9aafa925d383fb4559738b644aa01c0fb1 +"@dqbd/tiktoken@npm:^1.0.15": + version: 1.0.15 + resolution: "@dqbd/tiktoken@npm:1.0.15" + checksum: e33259bb3fcb35acf8a897b92e83968a19d9de5cd24fc36068637dee703fe4516db4a7df2adc75411c6567e69d22f9d86b9b41e0c31ac711d4afc8d126e2d05f languageName: node linkType: hard @@ -9192,7 +9192,7 @@ __metadata: version: 0.0.0-use.local resolution: "kc_electron@workspace:src/kc_electron" dependencies: - "@dqbd/tiktoken": ^1.0.7 + "@dqbd/tiktoken": ^1.0.15 "@types/cors": ^2.8.17 "@types/crypto-js": ^4.2.2 "@types/lodash": ^4.14.202 From b421e5d582bb1c1ecbe3e7b9bf4b652daaaf5142 Mon Sep 17 00:00:00 2001 From: Rob Royce Date: Wed, 3 Jul 2024 10:16:24 -0700 Subject: [PATCH 33/37] style: various changes to make the UI better. --- src/kc_angular/src/app/app.component.ts | 6 ++- .../chat-components/chat.message.component.ts | 2 +- .../chat-components/chat.view.component.ts | 5 ++- .../src/app/components/chat.component.ts | 31 +------------- .../project-details.component.ts | 4 +- .../app/components/shared/create.component.ts | 2 +- .../source.details.component.ts | 40 +++++++++++-------- src/kc_angular/src/styles.scss | 2 + 8 files changed, 41 insertions(+), 51 deletions(-) diff --git a/src/kc_angular/src/app/app.component.ts b/src/kc_angular/src/app/app.component.ts index d63a7f89..d18c4db3 100755 --- a/src/kc_angular/src/app/app.component.ts +++ b/src/kc_angular/src/app/app.component.ts @@ -277,7 +277,11 @@ export class AppComponent implements OnInit { this.projectInfoDialog = this.dialog.open(ProjectDetailsComponent, { data: { project: project }, showHeader: false, - contentStyle: { 'border-radius': '10px' }, + contentStyle: { + 'border-radius': '10px', + width: '50vw', + 'max-width': '60rem', + }, closeOnEscape: true, }); this.projectInfoDialog.onClose diff --git a/src/kc_angular/src/app/components/chat-components/chat.message.component.ts b/src/kc_angular/src/app/components/chat-components/chat.message.component.ts index 38173b3d..ad62cb05 100755 --- a/src/kc_angular/src/app/components/chat-components/chat.message.component.ts +++ b/src/kc_angular/src/app/components/chat-components/chat.message.component.ts @@ -187,7 +187,7 @@ import { ChatService } from '@services/chat-services/chat.service'; } .message-text { - white-space: pre-wrap; + text-rendering: geometricPrecision; } .user-action { diff --git a/src/kc_angular/src/app/components/chat-components/chat.view.component.ts b/src/kc_angular/src/app/components/chat-components/chat.view.component.ts index ece4f55f..b3f11c60 100755 --- a/src/kc_angular/src/app/components/chat-components/chat.view.component.ts +++ b/src/kc_angular/src/app/components/chat-components/chat.view.component.ts @@ -63,7 +63,10 @@ import { distinctUntilChanged, tap } from 'rxjs/operators';
- +
- - `, styles: [``], }) export class ChatComponent implements OnDestroy { - /* The context menu for the chat view */ - @ViewChild('cm') cm!: ContextMenu; - - /* The chat view component */ - @ViewChild('chatView') chatView!: ChatViewComponent; - - /* The context menu items */ - menuItems: MenuItem[] = []; - - showSources = true; - - messages$: Observable; - subscription: Subscription; constructor(private chat: ChatService, private projects: ProjectService) { - this.messages$ = this.chat.messages$; this.subscription = this.projects.currentProject.subscribe((project) => { this.chat.setTarget({ project: project }); }); diff --git a/src/kc_angular/src/app/components/project-components/project-details.component.ts b/src/kc_angular/src/app/components/project-components/project-details.component.ts index 2db01d45..a5d4f0e7 100755 --- a/src/kc_angular/src/app/components/project-components/project-details.component.ts +++ b/src/kc_angular/src/app/components/project-components/project-details.component.ts @@ -1,5 +1,5 @@ /* - * Copyright (c) 2023 Rob Royce + * Copyright (c) 2023-2024 Rob Royce * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -55,7 +55,7 @@ import { PrimeIcons } from 'primeng/api';
diff --git a/src/kc_angular/src/app/components/shared/create.component.ts b/src/kc_angular/src/app/components/shared/create.component.ts index b45cb606..b825741e 100755 --- a/src/kc_angular/src/app/components/shared/create.component.ts +++ b/src/kc_angular/src/app/components/shared/create.component.ts @@ -80,7 +80,7 @@ import { ConfirmationService } from 'primeng/api'; pButton proTip tipHeader="Web Surfer? Turn URLs into Sources" - tipMessage="Got a cool web link? Turn it into a Source! Not only will Knowledge store the link for you to access anytime, but it'll also try to gather some handy metadata. Ready for some surfing?" + tipMessage="Got a cool web link? Turn it into a Source! Not only will Knowledge store the link for you to access anytime, but it'll also extract relevant and useful metadata." [tipGroups]="['source', 'intro']" tipIcon="pi pi-link" [tipShowOnHover]="true" diff --git a/src/kc_angular/src/app/components/source-components/source.details.component.ts b/src/kc_angular/src/app/components/source-components/source.details.component.ts index d7cf1674..30e2f9ac 100755 --- a/src/kc_angular/src/app/components/source-components/source.details.component.ts +++ b/src/kc_angular/src/app/components/source-components/source.details.component.ts @@ -105,10 +105,20 @@ import { Clipboard } from '@angular/cdk/clipboard';
-
+
  • ; @@ -115,9 +114,8 @@ export class ChatViewComponent { } } return true; - } else { - return false; } + return false; }), tap(() => { this.scroll(); @@ -177,7 +175,10 @@ export class ChatViewComponent { } // If the user has highlighted text, add a highlight option - const highlight = this.context.highlight(message, $event, () => {}); + const highlight = this.context.highlight(message, $event, () => { + this.chat.persistChatHistory(); + }); + if (highlight) { this.messageMenu.push(highlight); } diff --git a/src/kc_angular/src/app/services/chat-services/chat.service.ts b/src/kc_angular/src/app/services/chat-services/chat.service.ts index 5b961a1f..383416f5 100755 --- a/src/kc_angular/src/app/services/chat-services/chat.service.ts +++ b/src/kc_angular/src/app/services/chat-services/chat.service.ts @@ -184,16 +184,16 @@ export class ChatService { return this.backendUrl; } + canChat() { + return this.canConnect.value; + } + addMessage(message: ChatMessage) { this.messages$.pipe(take(1)).subscribe((messages) => { this._messages$.next([...messages, message]); }); } - canChat() { - return this.canConnect.value; - } - deleteMessage(message: ChatMessage) { // Remove the message from the chat history using the message ID this.messages$.pipe(take(1)).subscribe((messages) => { @@ -201,6 +201,12 @@ export class ChatService { }); } + persistChatHistory() { + this.messages$.pipe(take(1)).subscribe((messages) => { + this.saveChat(messages); + }); + } + displayText(text: string, sender: AgentType = AgentType.Assistant) { const message = this.factory.message(sender, AgentType.User, text); this.addMessage(message); From bddb994723d68a9316dd6f1a3bd70fe477966676 Mon Sep 17 00:00:00 2001 From: Rob Royce Date: Wed, 3 Jul 2024 10:58:31 -0700 Subject: [PATCH 37/37] fix: prevent duplicate metadata fields from being specified. --- .../source.details.component.ts | 59 +++++++++++++------ 1 file changed, 41 insertions(+), 18 deletions(-) diff --git a/src/kc_angular/src/app/components/source-components/source.details.component.ts b/src/kc_angular/src/app/components/source-components/source.details.component.ts index 30e2f9ac..2d20e377 100755 --- a/src/kc_angular/src/app/components/source-components/source.details.component.ts +++ b/src/kc_angular/src/app/components/source-components/source.details.component.ts @@ -330,27 +330,35 @@ export class SourceDetailsComponent implements OnInit { } } } - - this.source.meta = [ - { key: 'knowledge:id', value: this.source.id.value }, - { - key: 'knowledge:method', - value: this.source.importMethod ?? 'manual', - }, - { - key: 'knowledge:link', - value: this.source.accessLink.toString(), - }, - { - key: 'knowledge:type', - value: this.source.ingestType, - }, - ...this.source.meta, - ]; delete this.source.reference.source.website.metadata.meta; - this.update.emit(this.source); } + this.source.meta = [ + { key: 'knowledge:id', value: this.source.id.value }, + { + key: 'knowledge:method', + value: this.source.importMethod ?? 'manual', + }, + { + key: 'knowledge:link', + value: this.source.accessLink.toString(), + }, + { + key: 'knowledge:type', + value: this.source.ingestType, + }, + ...this.source.meta, + ]; + + // Remove any duplicates + this.source.meta = this.source.meta.filter( + (meta, index, self) => + index === + self.findIndex((m) => m.key === meta.key && m.value === meta.value) + ); + + this.update.emit(this.source); + for (let i = 0; i < this.source.meta.length; i++) { this.source.meta[i].id = i; } @@ -425,6 +433,21 @@ export class SourceDetailsComponent implements OnInit { const entry = { ...metaEntry }; if (entry.key && entry.value) { + // If the key/value pair already exists, don't add it + if ( + this.source.meta.find( + (m) => m.key === entry.key && m.value === entry.value + ) + ) { + this.notify.warn( + 'Source Info', + 'Ignoring Duplicate', + 'That key/value pair already exists...', + 'toast' + ); + return; + } + this.source.meta = [ { key: entry.key, value: entry.value }, ...this.source.meta,