Skip to content

Commit

Permalink
WIP show a working state when AI is preparing a command
Browse files Browse the repository at this point in the history
  • Loading branch information
lukemelia committed Jan 29, 2025
1 parent 68873c5 commit 22c8486
Show file tree
Hide file tree
Showing 10 changed files with 213 additions and 60 deletions.
9 changes: 7 additions & 2 deletions packages/ai-bot/lib/debug.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import { setTitle } from './set-title';
import { sendError, sendOption, sendMessage, MatrixClient } from './matrix';
import {
sendError,
sendCommandMessage,
sendMessage,
MatrixClient,
} from './matrix';
import OpenAI from 'openai';

import * as Sentry from '@sentry/node';
Expand Down Expand Up @@ -59,7 +64,7 @@ export async function handleDebugCommands(
undefined,
);
}
return await sendOption(
return await sendCommandMessage(
client,
roomId,
{
Expand Down
10 changes: 3 additions & 7 deletions packages/ai-bot/lib/matrix.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,16 +88,13 @@ export async function sendMessage(
// TODO we might want to think about how to handle patches that are larger than
// 65KB (the maximum matrix event size), such that we split them into fragments
// like we split cards into fragments
export async function sendOption(
export async function sendCommandMessage(
client: MatrixClient,
roomId: string,
functionCall: FunctionToolCall,
eventToUpdate: string | undefined,
) {
let messageObject = toMatrixMessageCommandContent(
functionCall,
eventToUpdate,
);
let messageObject = toMatrixMessageCommandContent(functionCall);

if (messageObject !== undefined) {
return await sendEvent(
Expand Down Expand Up @@ -140,7 +137,6 @@ export async function sendError(

export const toMatrixMessageCommandContent = (
functionCall: FunctionToolCall,
eventToUpdate: string | undefined,
): IContent | undefined => {
let { arguments: payload } = functionCall;
const body = payload['description'] || 'Issuing command';
Expand All @@ -149,8 +145,8 @@ export const toMatrixMessageCommandContent = (
msgtype: APP_BOXEL_COMMAND_MSGTYPE,
formatted_body: body,
format: 'org.matrix.custom.html',
isStreamingFinished: true,
data: {
eventId: eventToUpdate,
toolCall: functionCall,
},
};
Expand Down
50 changes: 41 additions & 9 deletions packages/ai-bot/lib/send-response.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
import { cleanContent } from '../helpers';
import { logger } from '@cardstack/runtime-common';
import { MatrixClient, sendError, sendMessage, sendOption } from './matrix';
import {
MatrixClient,
sendError,
sendMessage,
sendCommandMessage,
} from './matrix';

import * as Sentry from '@sentry/node';
import { OpenAIError } from 'openai/error';
Expand All @@ -9,6 +14,7 @@ import { ISendEventResponse } from 'matrix-js-sdk/lib/matrix';
import { ChatCompletionMessageToolCall } from 'openai/resources/chat/completions';
import { FunctionToolCall } from '@cardstack/runtime-common/helpers/ai';
import { thinkingMessage } from '../constants';
import { APP_BOXEL_COMMAND_MSGTYPE } from '@cardstack/runtime-common/matrix-constants';

let log = logger('ai-bot');

Expand All @@ -19,6 +25,8 @@ export class Responder {
initialMessageReplaced = false;
client: MatrixClient;
roomId: string;
includesFunctionToolCall = false;
latestContent?: string;
messagePromises: Promise<ISendEventResponse | void>[] = [];
debouncedMessageSender: (
content: string,
Expand All @@ -35,14 +43,22 @@ export class Responder {
eventToUpdate: string | undefined,
isStreamingFinished = false,
) => {
this.latestContent = content;
let dataOverrides: Record<string, string | boolean> = {
isStreamingFinished: isStreamingFinished,
};
if (this.includesFunctionToolCall) {
dataOverrides = {
...dataOverrides,
msgtype: APP_BOXEL_COMMAND_MSGTYPE,
};
}
const messagePromise = sendMessage(
this.client,
this.roomId,
content,
eventToUpdate,
{
isStreamingFinished: isStreamingFinished,
},
dataOverrides,
);
this.messagePromises.push(messagePromise);
await messagePromise;
Expand All @@ -65,7 +81,19 @@ export class Responder {

async onChunk(chunk: {
usage?: { prompt_tokens: number; completion_tokens: number };
choices: {
delta: { content?: string; role?: string; tool_calls?: any[] };
}[];
}) {
if (chunk.choices[0].delta?.tool_calls?.[0]?.function) {
if (!this.includesFunctionToolCall) {
this.includesFunctionToolCall = true;
await this.debouncedMessageSender(
this.latestContent || '',
this.initialMessageId,
);
}
}
// This usage value is set *once* and *only once* at the end of the conversation
// It will be null at all other times.
if (chunk.usage) {
Expand All @@ -76,6 +104,7 @@ export class Responder {
}

async onContent(snapshot: string) {
log.debug('onContent: ', snapshot);
await this.debouncedMessageSender(
cleanContent(snapshot),
this.initialMessageId,
Expand All @@ -87,6 +116,7 @@ export class Responder {
role: string;
tool_calls?: ChatCompletionMessageToolCall[];
}) {
log.debug('onMessage: ', msg);
if (msg.role === 'assistant') {
await this.handleFunctionToolCalls(msg);
}
Expand All @@ -111,14 +141,14 @@ export class Responder {
for (const toolCall of msg.tool_calls || []) {
log.debug('[Room Timeline] Function call', toolCall);
try {
let optionPromise = sendOption(
let commandMessagePromise = sendCommandMessage(
this.client,
this.roomId,
this.deserializeToolCall(toolCall),
this.initialMessageReplaced ? undefined : this.initialMessageId,
this.initialMessageId,
);
this.messagePromises.push(optionPromise);
await optionPromise;
this.messagePromises.push(commandMessagePromise);
await commandMessagePromise;
this.initialMessageReplaced = true;
} catch (error) {
Sentry.captureException(error);
Expand All @@ -127,7 +157,7 @@ export class Responder {
this.client,
this.roomId,
error,
this.initialMessageReplaced ? undefined : this.initialMessageId,
this.initialMessageId,
);
this.messagePromises.push(errorPromise);
await errorPromise;
Expand All @@ -136,6 +166,7 @@ export class Responder {
}

async onError(error: OpenAIError | string) {
log.debug('onError: ', error);
Sentry.captureException(error);
return await sendError(
this.client,
Expand All @@ -146,6 +177,7 @@ export class Responder {
}

async finalize(finalContent: string | void | null | undefined) {
log.debug('finalize: ', finalContent);
if (finalContent) {
finalContent = cleanContent(finalContent);
await this.debouncedMessageSender(
Expand Down
18 changes: 12 additions & 6 deletions packages/base/matrix-event.gts
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,7 @@ export interface CommandEvent extends BaseMatrixEvent {
};
}

export interface CommandMessageContent {
export type CommandMessageContent = {
'm.relates_to'?: {
rel_type: string;
event_id: string;
Expand All @@ -143,11 +143,17 @@ export interface CommandMessageContent {
format: 'org.matrix.custom.html';
body: string;
formatted_body: string;
data: {
toolCall: FunctionToolCall;
eventId: string;
};
}
} & (
| {
isStreamingFinished: true | undefined;
data: {
toolCall: FunctionToolCall;
};
}
| {
isStreamingFinished: false;
}
);

export interface CardMessageEvent extends BaseMatrixEvent {
type: 'm.room.message';
Expand Down
77 changes: 75 additions & 2 deletions packages/host/app/components/ai-assistant/apply-button/index.gts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,12 @@ import { eq } from '@cardstack/boxel-ui/helpers';
import { CheckMark, Exclamation } from '@cardstack/boxel-ui/icons';
import { setCssVar } from '@cardstack/boxel-ui/modifiers';

export type ApplyButtonState = 'ready' | 'applying' | 'applied' | 'failed';
export type ApplyButtonState =
| 'ready'
| 'applying'
| 'applied'
| 'failed'
| 'preparing';

interface Signature {
Element: HTMLButtonElement | HTMLDivElement;
Expand Down Expand Up @@ -34,6 +39,17 @@ const AiAssistantApplyButton: TemplateOnlyComponent<Signature> = <template>
<CheckMark width='16' height='16' />
{{else if (eq @state 'failed')}}
<Exclamation width='16' height='16' />
{{else if (eq @state 'preparing')}}
<BoxelButton
@kind='secondary-dark'
@size='small'
class='apply-button'
{{setCssVar boxel-button-text-color='var(--boxel-200)'}}
data-test-apply-state='preparing'
...attributes
>
Working…
</BoxelButton>
{{/if}}
</div>
{{/if}}
Expand Down Expand Up @@ -67,7 +83,64 @@ const AiAssistantApplyButton: TemplateOnlyComponent<Signature> = <template>
width: 58px;
border-radius: 100px;
}
.state-indicator:not(.applying) {
.state-indicator.preparing {
width: 78px;
padding: 1px;
border-radius: 100px;
}
.state-indicator.preparing .apply-button {
border: 0;
min-width: 74px;
}
.state-indicator.preparing::before {
content: '';
position: absolute;
top: -105px;
left: -55px;
width: 250px;
height: 250px;
background: conic-gradient(
#ffcc8f 0deg,
#ff3966 45deg,
#ff309e 90deg,
#aa1dc9 135deg,
#d7fad6 180deg,
#5fdfea 225deg,
#3d83f2 270deg,
#5145e8 315deg,
#ffcc8f 360deg
);
z-index: -1;
animation: spin 4s infinite linear;
}
.state-indicator.preparing::after {
content: '';
position: absolute;
top: 1px;
left: 1px;
right: 1px;
bottom: 1px;
background: var(--ai-bot-message-background-color);
border-radius: inherit;
z-index: -1;
}
.state-indicator.preparing {
position: relative;
display: inline-block;
border-radius: 3rem;
color: white;
background: var(--boxel-700);
border: none;
cursor: pointer;
z-index: 1;
overflow: hidden;
}
.state-indicator:not(.applying):not(.preparing) {
width: 1.5rem;
aspect-ratio: 1;
border-radius: 50%;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@ export default class AiAssistantApplyButtonUsage extends Component {
this.state = 'failed';
break;
case 'failed':
this.state = 'preparing';
break;
case 'preparing':
this.state = 'ready';
break;
}
Expand All @@ -31,7 +34,7 @@ export default class AiAssistantApplyButtonUsage extends Component {
<FreestyleUsage @name='AiAssistant::ApplyButton'>
<:description>
Displays button for applying change proposed by AI Assistant. Includes
ready, applying, applied and failed states.
ready, applying, applied, failed, and preparing states.
</:description>
<:example>
<div class='example-container'>
Expand All @@ -45,7 +48,7 @@ export default class AiAssistantApplyButtonUsage extends Component {
<Args.String
@name='state'
@value={{this.state}}
@options={{array 'ready' 'applying' 'applied' 'failed'}}
@options={{array 'ready' 'applying' 'applied' 'failed' 'preparing'}}
@description='Button state'
@onInput={{fn (mut this.state)}}
/>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { TemplateOnlyComponent } from '@ember/component/template-only';

import ApplyButton from '../ai-assistant/apply-button';

interface Signature {
Element: HTMLDivElement;
}

const RoomMessageCommand: TemplateOnlyComponent<Signature> = <template>
<div ...attributes>
<div class='command-button-bar'>
<ApplyButton @state='preparing' data-test-command-apply='preparing' />
</div>
</div>

{{! template-lint-disable no-whitespace-for-layout }}
{{! ignore the above error because ember-template-lint complains about the whitespace in the multi-line comment below }}
<style scoped>
.command-button-bar {
display: flex;
justify-content: flex-end;
gap: var(--boxel-sp-xs);
margin-top: var(--boxel-sp);
}
</style>
</template>;

export default RoomMessageCommand;
3 changes: 3 additions & 0 deletions packages/host/app/components/matrix/room-message.gts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import { type CardDef } from 'https://cardstack.com/base/card-api';
import AiAssistantMessage from '../ai-assistant/message';
import { aiBotUserId } from '../ai-assistant/panel';

import PreparingRoomMessageCommand from './preparing-room-message-command';
import RoomMessageCommand from './room-message-command';

interface Signature {
Expand Down Expand Up @@ -137,6 +138,8 @@ export default class RoomMessage extends Component<Signature> {
@failedCommandState={{this.failedCommandState}}
@isError={{bool this.errorMessage}}
/>
{{else if @message.isPreparingCommand}}
<PreparingRoomMessageCommand />
{{/if}}
</AiAssistantMessage>
{{/if}}
Expand Down
Loading

0 comments on commit 22c8486

Please sign in to comment.