From 2639be28eccc02535c6d46142e21a6a73ad7db2e Mon Sep 17 00:00:00 2001 From: meta-d Date: Fri, 2 Feb 2024 16:03:47 +0800 Subject: [PATCH] feat: multiple conversations for copilot --- .../features/home/insight/insight.service.ts | 8 +- .../model/query-lab/query-lab.service.ts | 2 +- .../model/query-lab/query/query.service.ts | 2 +- .../features/semantic-model/model/types.ts | 2 +- .../story-widget/story-widget.service.ts | 108 +++++------------- .../angular/copilot/chat/chat.component.html | 22 ++-- .../angular/copilot/chat/chat.component.scss | 6 +- .../angular/copilot/chat/chat.component.ts | 35 +++--- .../copilot/services/engine.service.ts | 102 +++++++++-------- packages/copilot/src/lib/engine.ts | 32 ++++-- packages/copilot/src/lib/types/types.ts | 15 --- 11 files changed, 147 insertions(+), 187 deletions(-) diff --git a/apps/cloud/src/app/features/home/insight/insight.service.ts b/apps/cloud/src/app/features/home/insight/insight.service.ts index cfc494299..552619572 100644 --- a/apps/cloud/src/app/features/home/insight/insight.service.ts +++ b/apps/cloud/src/app/features/home/insight/insight.service.ts @@ -222,13 +222,11 @@ export class InsightService { const entityType = await this.getEntityType(classification) const cubes = await this.getAllCubes() - await this.#copilotEngine.chat({ - prompt: `/chart 多维数据模型信息为: + await this.#copilotEngine.chat(`/chart 多维数据模型信息为: ${calcEntityTypePrompt(entityType)} 问题:${prompt} -`, - newConversation: true - }, { + `, { + newConversation: true, abortController: options?.abortController, }) } catch (err) { diff --git a/apps/cloud/src/app/features/semantic-model/model/query-lab/query-lab.service.ts b/apps/cloud/src/app/features/semantic-model/model/query-lab/query-lab.service.ts index 5c629a48a..40bb6a87a 100644 --- a/apps/cloud/src/app/features/semantic-model/model/query-lab/query-lab.service.ts +++ b/apps/cloud/src/app/features/semantic-model/model/query-lab/query-lab.service.ts @@ -143,7 +143,7 @@ export class QueryLabService extends ComponentStore implements On }) readonly setConversations = this.updater( - (state, { key, conversations }: { key: string; conversations: CopilotChatMessage[] }) => { + (state, { key, conversations }: { key: string; conversations: Array }) => { const query = state.queries[key].query query.conversations = conversations } diff --git a/apps/cloud/src/app/features/semantic-model/model/query-lab/query/query.service.ts b/apps/cloud/src/app/features/semantic-model/model/query-lab/query/query.service.ts index 1212e4750..0ce889985 100644 --- a/apps/cloud/src/app/features/semantic-model/model/query-lab/query/query.service.ts +++ b/apps/cloud/src/app/features/semantic-model/model/query-lab/query/query.service.ts @@ -37,7 +37,7 @@ export class QueryService extends ComponentSubStore { + setConversations = this.updater((state, conversations: Array) => { state.query.conversations = conversations }) setAIOptions = this.updater((state, options: AIOptions) => { diff --git a/apps/cloud/src/app/features/semantic-model/model/types.ts b/apps/cloud/src/app/features/semantic-model/model/types.ts index 0654f9456..bafd28884 100644 --- a/apps/cloud/src/app/features/semantic-model/model/types.ts +++ b/apps/cloud/src/app/features/semantic-model/model/types.ts @@ -63,7 +63,7 @@ export interface ModelQuery extends IModelQuery { entities: string[] statement?: string aiOptions?: AIOptions - conversations?: CopilotChatMessage[] + conversations?: Array } export interface QueryResult { diff --git a/libs/story-angular/story/story-widget/story-widget.service.ts b/libs/story-angular/story/story-widget/story-widget.service.ts index 7a44c1956..04c058994 100644 --- a/libs/story-angular/story/story-widget/story-widget.service.ts +++ b/libs/story-angular/story/story-widget/story-widget.service.ts @@ -1,19 +1,24 @@ import { computed, inject, Inject, Injectable, Optional } from '@angular/core' import { MatSnackBar } from '@angular/material/snack-bar' import { ID } from '@metad/contracts' -import { AIOptions, CopilotChatMessage, CopilotChatMessageRoleEnum, CopilotChatResponseChoice, CopilotEngine } from '@metad/copilot' +import { AIOptions } from '@metad/copilot' +import { NgmCopilotService, WidgetService } from '@metad/core' import { DataSettings } from '@metad/ocap-core' import { ComponentSubStore } from '@metad/store' +import { + LinkedAnalysisSettings, + NX_STORY_FEED, + NxStoryFeedService, + StoryPointState, + StoryWidget +} from '@metad/story/core' import { TranslateService } from '@ngx-translate/core' -import { convertQueryResultColumns, convertTableToCSV, NgmCopilotService, WidgetService } from '@metad/core' -import { NxStoryFeedService, NX_STORY_FEED, StoryPointState, StoryWidget, LinkedAnalysisSettings } from '@metad/story/core' -import { firstValueFrom, Observable, of } from 'rxjs' -import { filter, map, scan, startWith, switchMap, tap } from 'rxjs/operators' +import { firstValueFrom, Observable } from 'rxjs' +import { filter, tap } from 'rxjs/operators' import { NxStoryPointService } from '../story-point.service' -import { nanoid } from 'ai' @Injectable() -export class NxStoryWidgetService extends ComponentSubStore implements CopilotEngine { +export class NxStoryWidgetService extends ComponentSubStore { private widgetService = inject(WidgetService) readonly copilot? = inject(NgmCopilotService, { optional: true }) @@ -27,8 +32,9 @@ export class NxStoryWidgetService extends ComponentSubStore this.conversations) + + readonly messages = computed(() => []) + readonly conversations = computed(() => []) readonly dataSettings$ = this.select((state) => state.dataSettings).pipe( filter(Boolean), @@ -43,7 +49,7 @@ export class NxStoryWidgetService extends ComponentSubStore { + this.translateService?.get(code, { Default: text, ...(params ?? {}) }).subscribe((value) => { result = value }) return result } - - async chat({prompt}, options?: {action: string}) { - } - - async preprocess(prompt: string, options?: { signal?: AbortSignal }): Promise { - return [] - } - - process({prompt}, options?: {action: string}) { - return of(prompt).pipe( - switchMap(async () => { - const explains = this.widgetService.explains() - return explains[1] - }), - switchMap((explain) => { - if(!explain?.data) { - return of( - { - id: nanoid(), - role: CopilotChatMessageRoleEnum.Assistant, - content: '未能获取相关数据' - } - ) - } - - return this.copilot.chatStream([ - { - id: nanoid(), - role: CopilotChatMessageRoleEnum.System, - content: `你是一名 BI 数据分析专家,根据以下数据给出用户问题的分析: -${convertTableToCSV(convertQueryResultColumns(explain.schema), explain.data)} -` - }, - { - id: nanoid(), - role: CopilotChatMessageRoleEnum.User, - content: prompt - } - ]) - .pipe( - scan((acc, value: any) => acc + (value?.choices?.[0]?.delta?.content ?? ''), ''), - map((content) => content.trim()), - startWith({ - id: nanoid(), - role: CopilotChatMessageRoleEnum.Assistant, - content: '正在分析。。。' - }) - ) - }) - ) - } - - postprocess(prompt: string, choices: CopilotChatResponseChoice[]): Observable { - throw new Error('Method not implemented.') - } - - clear() {} } diff --git a/packages/angular/copilot/chat/chat.component.html b/packages/angular/copilot/chat/chat.component.html index eae7ef4ec..486039dfe 100644 --- a/packages/angular/copilot/chat/chat.component.html +++ b/packages/angular/copilot/chat/chat.component.html @@ -10,7 +10,8 @@
- @for (message of (enabled && hasKey ? conversations() : _mockConversations); track message.id) { + @for (conversation of (enabled && hasKey ? conversations() : _mockConversations); track $index; let last = $last) { + @for (message of conversation; track message.id) { @switch (message.role) { @case (CopilotChatMessageRoleEnum.Assistant) {
@@ -26,7 +27,7 @@
🤖
}
-
+
@if (message.templateRef) { } @else { @@ -115,6 +116,10 @@ [pageSizeOptions]="[10, 20, 50, 100]" > } @else { + @if (message.command) { + /{{message.command}} + } +
- @if (message.command) { - /{{message.command}} - } - @if (showTokenizer$() && message.content) { + @if (showTokenizer$() && message.content) { - } + }
} -
diff --git a/packages/angular/copilot/chat/chat.component.scss b/packages/angular/copilot/chat/chat.component.scss index 9572568f0..c590dbde5 100644 --- a/packages/angular/copilot/chat/chat.component.scss +++ b/packages/angular/copilot/chat/chat.component.scss @@ -63,8 +63,10 @@ } :host::ng-deep { - markdown p:first-child { - @apply indent-4; + markdown { + p:first-child { + @apply indent-4; + } } // popper diff --git a/packages/angular/copilot/chat/chat.component.ts b/packages/angular/copilot/chat/chat.component.ts index ffcfc625d..797931d9f 100644 --- a/packages/angular/copilot/chat/chat.component.ts +++ b/packages/angular/copilot/chat/chat.component.ts @@ -1,3 +1,4 @@ +import { CdkDragDrop } from '@angular/cdk/drag-drop' import { ClipboardModule, Clipboard } from '@angular/cdk/clipboard' import { TextFieldModule } from '@angular/cdk/text-field' import { CommonModule } from '@angular/common' @@ -46,7 +47,7 @@ import { IUser, NgmCopilotChatMessage } from '../types' import { nanoid } from 'nanoid' import { injectCopilotCommand } from '../hooks' import { PlaceholderMessages } from './types' -import { CdkDragDrop } from '@angular/cdk/drag-drop' + @Component({ standalone: true, @@ -135,7 +136,7 @@ export class NgmCopilotChatComponent { return this.copilotEngine?.placeholder ?? this.placeholder } - _mockConversations: NgmCopilotChatMessage[] = PlaceholderMessages + _mockConversations: Array = [PlaceholderMessages] // Copilot private openaiOptions = { @@ -186,9 +187,7 @@ export class NgmCopilotChatComponent { |-------------------------------------------------------------------------- */ readonly showTokenizer$ = toSignal(this.copilotService.copilot$.pipe(map((copilot) => copilot?.showTokenizer))) - readonly conversations = computed(() => this.copilotEngine.messages() - // .filter((message) => message.status === 'thinking' || message.content || message.error) - ) + readonly conversations = computed>(() => this.copilotEngine.conversations()) /** * 当前 Asking prompt @@ -318,8 +317,8 @@ export class NgmCopilotChatComponent { } - async askCopilotStream(prompt: string, options: {newConversation?: boolean; assistantMessageId?: string;} = {}) { - const { newConversation, assistantMessageId } = options ?? {} + async askCopilotStream(prompt: string, options: {command?: string; newConversation?: boolean; assistantMessageId?: string;} = {}) { + const { command, newConversation, assistantMessageId } = options ?? {} // Reset history index this.historyIndex.set(-1) // Add to history @@ -335,7 +334,9 @@ export class NgmCopilotChatComponent { if (this.copilotEngine) { try { this.#abortController = new AbortController() - const message = await this.#copilotEngine.chat({ prompt, newConversation, messages: [] }, { + const message = await this.#copilotEngine.chat(prompt, { + command, + newConversation, abortController: this.#abortController, assistantMessageId }) @@ -408,20 +409,20 @@ export class NgmCopilotChatComponent { } async resubmitMessage(message: CopilotChatMessage, content: string) { - this.copilotEngine.updateConversations((conversations) => { - const index = conversations.indexOf(message) + this.copilotEngine.updateLastConversation((messages) => { + const index = messages.findIndex((item) => item.id === message.id) if (index > -1) { // 删除答案 - if (conversations[index + 1]?.role === CopilotChatMessageRoleEnum.Assistant) { - conversations.splice(index + 1, 1) + if (messages[index + 1]?.role === CopilotChatMessageRoleEnum.Assistant) { + messages.splice(index + 1, 1) } // 删除提问 - conversations.splice(index, 1) - return [...conversations] + messages.splice(index, 1) + return [...messages] } - return conversations + return messages }) - await this.askCopilotStream(content) + await this.askCopilotStream(content, {command: message.command}) } onMessageFocus() { @@ -432,7 +433,7 @@ export class NgmCopilotChatComponent { * @deprecated regenerate method should in copilot engine service */ async regenerate(message: CopilotChatMessage) { - this.copilotEngine.updateConversations((conversations) => { + this.copilotEngine.updateLastConversation((conversations) => { const index = conversations.findIndex((item) => item.id === message.id) conversations.splice(index) return [...conversations] diff --git a/packages/angular/copilot/services/engine.service.ts b/packages/angular/copilot/services/engine.service.ts index d12998da2..5103f17bc 100644 --- a/packages/angular/copilot/services/engine.service.ts +++ b/packages/angular/copilot/services/engine.service.ts @@ -5,6 +5,7 @@ import { AnnotatedFunction, CopilotChatMessage, CopilotChatMessageRoleEnum, + CopilotChatOptions, CopilotCommand, CopilotEngine, CopilotService, @@ -15,7 +16,7 @@ import { processChatStream } from '@metad/copilot' import { ChatRequest, ChatRequestOptions, JSONValue, Message, nanoid } from 'ai' -import { pick } from 'lodash-es' +import { flatten, pick } from 'lodash-es' import { NGXLogger } from 'ngx-logger' import { DropAction } from '../types' @@ -30,29 +31,28 @@ export class NgmCopilotEngineService implements CopilotEngine { private chatId = `chat-${uniqueId++}` private key = computed(() => `${this.api()}|${this.chatId}`) + placeholder?: string + aiOptions: AIOptions = { model: DefaultModel } as AIOptions - readonly conversations$ = signal([]) - get conversations() { - return this.conversations$() - } - - readonly messages = computed(() => this.conversations$()) - /** - * Calculate messages in the last conversation + * One conversation including user and assistant messages + * This is a array of conversations */ + readonly conversations$ = signal>([]) + + readonly conversations = computed(() => this.conversations$()) + readonly messages = computed(() => flatten(this.conversations$())) + readonly lastConversation = computed(() => { - const conversations = this.conversations$() + const conversations = this.conversations$()[this.conversations$().length - 1] ?? [] + // Get last conversation messages const lastMessages = [] let lastUserMessage = null for (let i = conversations.length - 1; i >= 0; i--) { - if (conversations[i].end) { - break - } if (conversations[i].role === CopilotChatMessageRoleEnum.User) { if (lastUserMessage) { lastUserMessage.content = conversations[i].content + '\n' + lastUserMessage.content @@ -77,7 +77,7 @@ export class NgmCopilotEngineService implements CopilotEngine { }) readonly lastUserMessages = computed(() => { - const conversations = this.conversations$() + const conversations = this.conversations$()[this.conversations$().length - 1] ?? [] const messages = [] for (let i = conversations.length - 1; i >= 0; i--) { if (conversations[i].role === CopilotChatMessageRoleEnum.User && !conversations[i].command) { @@ -93,8 +93,6 @@ export class NgmCopilotEngineService implements CopilotEngine { return messages }) - placeholder?: string - // Entry Points readonly #entryPoints = signal>>({}) @@ -178,17 +176,21 @@ export class NgmCopilotEngineService implements CopilotEngine { }) } - async chat( - data: { prompt: string; newConversation?: boolean; messages?: CopilotChatMessage[] }, - options?: { action?: string; abortController?: AbortController; assistantMessageId?: string } - ) { - this.#logger?.debug(`process ask: ${data.prompt}`) + async chat(prompt: string, options?: CopilotChatOptions) { + this.#logger?.debug(`process copilot ask: ${prompt}`) + let { command } = options ?? {} const { abortController, assistantMessageId } = options ?? {} // New messages const newMessages: CopilotChatMessage[] = [] - const { command, prompt } = data.prompt ? getCommandPrompt(data.prompt) : { command: null, prompt: null } + // deconstruct prompt to get command and prompt + if (!command && prompt) { + const data = getCommandPrompt(prompt) + command = data.command + prompt = data.prompt + } + if (command) { const _command = this.getCommand(command) @@ -213,7 +215,7 @@ export class NgmCopilotEngineService implements CopilotEngine { // Last user messages before add new messages const lastUserMessages = this.lastUserMessages() // Append new messages to conversation - this.conversations$.update((state) => [...state, ...newMessages]) + this.upsertMessage(...newMessages) // Exec command implementation if (_command.implementation) { @@ -242,6 +244,7 @@ export class NgmCopilotEngineService implements CopilotEngine { abortController } ) + this.conversations$.update((conversations) => [...conversations, []]) } else { // Last conversation messages before append new messages const lastConversation = this.lastConversation() @@ -256,7 +259,7 @@ export class NgmCopilotEngineService implements CopilotEngine { // Append new messages to conversation if (newMessages.length > 0) { - this.conversations$.update((state) => [...state, ...newMessages]) + this.upsertMessage(...newMessages) } const functions = this.getGlobalFunctionDescriptions() @@ -370,22 +373,16 @@ export class NgmCopilotEngineService implements CopilotEngine { if (err instanceof Error) { this.error.set(err) - - this.conversations$.update((state) => { - return [ - ...state.filter((item) => item.id !== assistantMessageId), - { - id: nanoid(), - role: CopilotChatMessageRoleEnum.Assistant, - content: '', - error: (err).message, - status: 'error' - } - ] + this.deleteMessage(assistantMessageId) + this.upsertMessage({ + id: nanoid(), + role: CopilotChatMessageRoleEnum.Assistant, + content: '', + error: (err).message, + status: 'error' }) } - // this.error.set(err as Error) return null } finally { this.isLoading.set(false) @@ -403,31 +400,42 @@ export class NgmCopilotEngineService implements CopilotEngine { return nanoid() } - upsertMessage(message: CopilotChatMessage) { + upsertMessage(...messages: CopilotChatMessage[]) { this.conversations$.update((conversations) => { - const index = conversations.findIndex((item) => item.id === message.id) - if (index > -1) { - conversations[index] = { ...message } - } else { - conversations.push(message) - } - return [...conversations] + const lastConversation = conversations[conversations.length - 1] ?? [] + messages.forEach((message) => { + const index = lastConversation.findIndex((item) => item.id === message.id) + if (index > -1) { + lastConversation[index] = { ...message } + } else { + lastConversation.push(message) + } + }) + return [...conversations.slice(0, conversations.length - 1), lastConversation] }) } deleteMessage(message: CopilotChatMessage | string) { const messageId = typeof message === 'string' ? message : message.id - this.conversations$.update((conversations) => conversations.filter((item) => item.id !== messageId)) + this.conversations$.update((conversations) => conversations.map((messages) => messages.filter((item) => item.id !== messageId))) } clear() { this.conversations$.set([]) } - updateConversations(fn: (conversations: CopilotChatMessage[]) => CopilotChatMessage[]): void { + updateConversations(fn: (conversations: Array) => Array): void { this.conversations$.update(fn) } + updateLastConversation(fn: (conversations: CopilotChatMessage[]) => CopilotChatMessage[]): void { + this.conversations$.update((conversations) => { + const lastConversation = conversations[conversations.length - 1] ?? [] + conversations[conversations.length - 1] = fn(lastConversation) + return [...conversations] + }) + } + async dropCopilot(event: CdkDragDrop) { const dropActions = this.#dropActions() if (dropActions[event.previousContainer.id]) { diff --git a/packages/copilot/src/lib/engine.ts b/packages/copilot/src/lib/engine.ts index 537523606..f54f858e0 100644 --- a/packages/copilot/src/lib/engine.ts +++ b/packages/copilot/src/lib/engine.ts @@ -3,6 +3,14 @@ import { CopilotCommand } from './command' import { CopilotService } from './copilot' import { AIOptions, AnnotatedFunction, CopilotChatMessage, CopilotChatResponseChoice } from './types' +export type CopilotChatOptions = { + command?: string + newConversation?: boolean + action?: string + abortController?: AbortController + assistantMessageId?: string +} + /** * Copilot engine */ @@ -13,6 +21,10 @@ export interface CopilotEngine { * Copilot engine name */ name?: string + /** + * Placeholder in ask input + */ + placeholder?: string /** * AI Configuration */ @@ -30,18 +42,11 @@ export interface CopilotEngine { /** * Conversations */ - conversations: CopilotChatMessage[] - /** - * Placeholder in ask input - */ - placeholder?: string + conversations(): Array messages(): CopilotChatMessage[] - chat( - data: { prompt: string; newConversation?: boolean; messages?: CopilotChatMessage[] }, - options?: { action?: string; abortController?: AbortController; assistantMessageId?: string; } - ): Promise + chat(prompt: string, options?: CopilotChatOptions): Promise /** * @deprecated use `chat` instead @@ -91,7 +96,7 @@ export interface CopilotEngine { * * @param message */ - deleteMessage?(message: CopilotChatMessage): void + deleteMessage?(message: CopilotChatMessage | string): void /** * Clear conversations @@ -103,5 +108,10 @@ export interface CopilotEngine { * * @param fn */ - updateConversations?(fn: (conversations: CopilotChatMessage[]) => CopilotChatMessage[]): void + updateConversations?(fn: (conversations: Array) => Array): void + /** + * Update the last conversation messages + * @param fn + */ + updateLastConversation?(fn: (conversations: CopilotChatMessage[]) => CopilotChatMessage[]): void } diff --git a/packages/copilot/src/lib/types/types.ts b/packages/copilot/src/lib/types/types.ts index 1513a6b6a..2084ac141 100644 --- a/packages/copilot/src/lib/types/types.ts +++ b/packages/copilot/src/lib/types/types.ts @@ -47,22 +47,7 @@ export enum CopilotChatMessageRoleEnum { Info = 'info' } -// export interface CopilotChatMessage extends Omit { -// role: CopilotChatMessageRoleEnum -// error?: string -// data?: { -// columns: any[] -// content: any[] -// } -// end?: boolean -// } - export interface CopilotChatMessage extends Omit { - /** - * Chat Session Ended - */ - end?: boolean - error?: string role: Message['role'] | 'info'