(core) updates from grist-core

This commit is contained in:
Paul Fitzpatrick
2023-05-08 14:17:45 -04:00
11 changed files with 589 additions and 186 deletions

View File

@@ -4,6 +4,7 @@ import {CellRec, DocModel, IRowModel, recordSet,
refRecord, TableRec, ViewFieldRec} from 'app/client/models/DocModel';
import {urlState} from 'app/client/models/gristUrlState';
import {jsonObservable, ObjObservable} from 'app/client/models/modelUtil';
import {AssistanceState} from 'app/common/AssistancePrompts';
import * as gristTypes from 'app/common/gristTypes';
import {getReferencedTableId} from 'app/common/gristTypes';
import {
@@ -83,7 +84,7 @@ export interface ColumnRec extends IRowModel<"_grist_Tables_column"> {
/**
* Current history of chat. This is a temporary array used only in the ui.
*/
chatHistory: ko.PureComputed<Observable<ChatMessage[]>>;
chatHistory: ko.PureComputed<Observable<ChatHistory>>;
// Helper which adds/removes/updates column's displayCol to match the formula.
saveDisplayFormula(formula: string): Promise<void>|undefined;
@@ -162,8 +163,9 @@ export function createColumnRec(this: ColumnRec, docModel: DocModel): void {
this.chatHistory = this.autoDispose(ko.computed(() => {
const docId = urlState().state.get().doc ?? '';
const key = `formula-assistant-history-${docId}-${this.table().tableId()}-${this.colId()}`;
return localStorageJsonObs(key, [] as ChatMessage[]);
// Changed key name from history to history-v2 when ChatHistory changed in incompatible way.
const key = `formula-assistant-history-v2-${docId}-${this.table().tableId()}-${this.colId()}`;
return localStorageJsonObs(key, {messages: []} as ChatHistory);
}));
}
@@ -196,8 +198,20 @@ export interface ChatMessage {
*/
sender: 'user' | 'ai';
/**
* The formula returned from the AI. It is only set when the sender is the AI. For now it is the same
* value as the message, but it might change in the future when we use more conversational AI.
* The formula returned from the AI. It is only set when the sender is the AI.
*/
formula?: string;
}
/**
* The state of assistance for a particular column.
* ChatMessages are what are shown in the UI, whereas state is
* how the back-end represents the conversation. The two are
* similar but not the same because of post-processing.
* It may be possible to reconcile them when things settle down
* a bit?
*/
export interface ChatHistory {
messages: ChatMessage[];
state?: AssistanceState;
}

View File

@@ -8,7 +8,7 @@ import {basicButton, primaryButton, textButton} from 'app/client/ui2018/buttons'
import {theme} from 'app/client/ui2018/cssVars';
import {cssTextInput, rawTextInput} from 'app/client/ui2018/editableLabel';
import {icon} from 'app/client/ui2018/icons';
import {Suggestion} from 'app/common/AssistancePrompts';
import {AssistanceResponse, AssistanceState} from 'app/common/AssistancePrompts';
import {Disposable, dom, makeTestId, MultiHolder, obsArray, Observable, styled} from 'grainjs';
import noop from 'lodash/noop';
@@ -78,7 +78,7 @@ function buildControls(
}
) {
const hasHistory = props.column.chatHistory.peek().get().length > 0;
const hasHistory = props.column.chatHistory.peek().get().messages.length > 0;
// State variables, to show various parts of the UI.
const saveButtonVisible = Observable.create(owner, true);
@@ -153,36 +153,46 @@ function buildControls(
};
}
function buildChat(owner: Disposable, context: Context & { formulaClicked: (formula: string) => void }) {
function buildChat(owner: Disposable, context: Context & { formulaClicked: (formula?: string) => void }) {
const { grist, column } = context;
const history = owner.autoDispose(obsArray(column.chatHistory.peek().get()));
const history = owner.autoDispose(obsArray(column.chatHistory.peek().get().messages));
const hasHistory = history.get().length > 0;
const enabled = Observable.create(owner, hasHistory);
const introVisible = Observable.create(owner, !hasHistory);
owner.autoDispose(history.addListener((cur) => {
column.chatHistory.peek().set([...cur]);
const chatHistory = column.chatHistory.peek();
chatHistory.set({...chatHistory.get(), messages: [...cur]});
}));
const submit = async () => {
// Ask about suggestion, and send the whole history. Currently the chat is implemented by just sending
// all previous user prompts back to the AI. This is subject to change (and probably should be done in the backend).
const prompt = history.get().filter(x => x.sender === 'user')
.map(entry => entry.message)
.filter(Boolean)
.join("\n");
console.debug('prompt', prompt);
const { suggestedActions } = await askAI(grist, column, prompt);
console.debug('suggestedActions', suggestedActions);
const submit = async (regenerate: boolean = false) => {
// Send most recent question, and send back any conversation
// state we have been asked to track.
const chatHistory = column.chatHistory.peek().get();
const messages = chatHistory.messages.filter(msg => msg.sender === 'user');
const description = messages[messages.length - 1]?.message || '';
console.debug('description', {description});
const {reply, suggestedActions, state} = await askAI(grist, {
column, description, state: chatHistory.state,
regenerate,
});
console.debug('suggestedActions', {suggestedActions, reply});
const firstAction = suggestedActions[0] as any;
// Add the formula to the history.
const formula = firstAction[3].formula as string;
const formula = firstAction ? firstAction[3].formula as string : undefined;
// Add to history
history.push({
message: formula,
message: formula || reply || '(no reply)',
sender: 'ai',
formula
});
// If back-end is capable of conversation, keep its state.
if (state) {
const chatHistoryNew = column.chatHistory.peek();
const value = chatHistoryNew.get();
value.state = state;
chatHistoryNew.set(value);
}
return formula;
};
@@ -203,12 +213,13 @@ function buildChat(owner: Disposable, context: Context & { formulaClicked: (form
// Remove the last AI response from the history.
history.pop();
// And submit again.
context.formulaClicked(await submit());
context.formulaClicked(await submit(true));
};
const newChat = () => {
// Clear the history.
history.set([]);
column.chatHistory.peek().set({messages: []});
// Show intro.
introVisible.set(true);
};
@@ -371,9 +382,11 @@ function openAIAssistant(grist: GristDoc, column: ColumnRec) {
const chat = buildChat(owner, {...props,
// When a formula is clicked (or just was returned from the AI), we set it in the formula editor and hit
// the preview button.
formulaClicked: (formula: string) => {
formulaEditor.set(formula);
controls.preview().catch(reportError);
formulaClicked: (formula?: string) => {
if (formula) {
formulaEditor.set(formula);
controls.preview().catch(reportError);
}
},
});
@@ -397,11 +410,22 @@ function openAIAssistant(grist: GristDoc, column: ColumnRec) {
grist.formulaPopup.autoDispose(popup);
}
async function askAI(grist: GristDoc, column: ColumnRec, description: string): Promise<Suggestion> {
async function askAI(grist: GristDoc, options: {
column: ColumnRec,
description: string,
regenerate?: boolean,
state?: AssistanceState
}): Promise<AssistanceResponse> {
const {column, description, state, regenerate} = options;
const tableId = column.table.peek().tableId.peek();
const colId = column.colId.peek();
try {
const result = await grist.docComm.getAssistance({tableId, colId, description});
const result = await grist.docComm.getAssistance({
context: {type: 'formula', tableId, colId},
text: description,
state,
regenerate,
});
return result;
} catch (error) {
reportError(error);