Skip to content

Commit 22c8486

Browse files
committed
WIP show a working state when AI is preparing a command
1 parent 68873c5 commit 22c8486

File tree

10 files changed

+213
-60
lines changed

10 files changed

+213
-60
lines changed

packages/ai-bot/lib/debug.ts

+7-2
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
import { setTitle } from './set-title';
2-
import { sendError, sendOption, sendMessage, MatrixClient } from './matrix';
2+
import {
3+
sendError,
4+
sendCommandMessage,
5+
sendMessage,
6+
MatrixClient,
7+
} from './matrix';
38
import OpenAI from 'openai';
49

510
import * as Sentry from '@sentry/node';
@@ -59,7 +64,7 @@ export async function handleDebugCommands(
5964
undefined,
6065
);
6166
}
62-
return await sendOption(
67+
return await sendCommandMessage(
6368
client,
6469
roomId,
6570
{

packages/ai-bot/lib/matrix.ts

+3-7
Original file line numberDiff line numberDiff line change
@@ -88,16 +88,13 @@ export async function sendMessage(
8888
// TODO we might want to think about how to handle patches that are larger than
8989
// 65KB (the maximum matrix event size), such that we split them into fragments
9090
// like we split cards into fragments
91-
export async function sendOption(
91+
export async function sendCommandMessage(
9292
client: MatrixClient,
9393
roomId: string,
9494
functionCall: FunctionToolCall,
9595
eventToUpdate: string | undefined,
9696
) {
97-
let messageObject = toMatrixMessageCommandContent(
98-
functionCall,
99-
eventToUpdate,
100-
);
97+
let messageObject = toMatrixMessageCommandContent(functionCall);
10198

10299
if (messageObject !== undefined) {
103100
return await sendEvent(
@@ -140,7 +137,6 @@ export async function sendError(
140137

141138
export const toMatrixMessageCommandContent = (
142139
functionCall: FunctionToolCall,
143-
eventToUpdate: string | undefined,
144140
): IContent | undefined => {
145141
let { arguments: payload } = functionCall;
146142
const body = payload['description'] || 'Issuing command';
@@ -149,8 +145,8 @@ export const toMatrixMessageCommandContent = (
149145
msgtype: APP_BOXEL_COMMAND_MSGTYPE,
150146
formatted_body: body,
151147
format: 'org.matrix.custom.html',
148+
isStreamingFinished: true,
152149
data: {
153-
eventId: eventToUpdate,
154150
toolCall: functionCall,
155151
},
156152
};

packages/ai-bot/lib/send-response.ts

+41-9
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
import { cleanContent } from '../helpers';
22
import { logger } from '@cardstack/runtime-common';
3-
import { MatrixClient, sendError, sendMessage, sendOption } from './matrix';
3+
import {
4+
MatrixClient,
5+
sendError,
6+
sendMessage,
7+
sendCommandMessage,
8+
} from './matrix';
49

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

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

@@ -19,6 +25,8 @@ export class Responder {
1925
initialMessageReplaced = false;
2026
client: MatrixClient;
2127
roomId: string;
28+
includesFunctionToolCall = false;
29+
latestContent?: string;
2230
messagePromises: Promise<ISendEventResponse | void>[] = [];
2331
debouncedMessageSender: (
2432
content: string,
@@ -35,14 +43,22 @@ export class Responder {
3543
eventToUpdate: string | undefined,
3644
isStreamingFinished = false,
3745
) => {
46+
this.latestContent = content;
47+
let dataOverrides: Record<string, string | boolean> = {
48+
isStreamingFinished: isStreamingFinished,
49+
};
50+
if (this.includesFunctionToolCall) {
51+
dataOverrides = {
52+
...dataOverrides,
53+
msgtype: APP_BOXEL_COMMAND_MSGTYPE,
54+
};
55+
}
3856
const messagePromise = sendMessage(
3957
this.client,
4058
this.roomId,
4159
content,
4260
eventToUpdate,
43-
{
44-
isStreamingFinished: isStreamingFinished,
45-
},
61+
dataOverrides,
4662
);
4763
this.messagePromises.push(messagePromise);
4864
await messagePromise;
@@ -65,7 +81,19 @@ export class Responder {
6581

6682
async onChunk(chunk: {
6783
usage?: { prompt_tokens: number; completion_tokens: number };
84+
choices: {
85+
delta: { content?: string; role?: string; tool_calls?: any[] };
86+
}[];
6887
}) {
88+
if (chunk.choices[0].delta?.tool_calls?.[0]?.function) {
89+
if (!this.includesFunctionToolCall) {
90+
this.includesFunctionToolCall = true;
91+
await this.debouncedMessageSender(
92+
this.latestContent || '',
93+
this.initialMessageId,
94+
);
95+
}
96+
}
6997
// This usage value is set *once* and *only once* at the end of the conversation
7098
// It will be null at all other times.
7199
if (chunk.usage) {
@@ -76,6 +104,7 @@ export class Responder {
76104
}
77105

78106
async onContent(snapshot: string) {
107+
log.debug('onContent: ', snapshot);
79108
await this.debouncedMessageSender(
80109
cleanContent(snapshot),
81110
this.initialMessageId,
@@ -87,6 +116,7 @@ export class Responder {
87116
role: string;
88117
tool_calls?: ChatCompletionMessageToolCall[];
89118
}) {
119+
log.debug('onMessage: ', msg);
90120
if (msg.role === 'assistant') {
91121
await this.handleFunctionToolCalls(msg);
92122
}
@@ -111,14 +141,14 @@ export class Responder {
111141
for (const toolCall of msg.tool_calls || []) {
112142
log.debug('[Room Timeline] Function call', toolCall);
113143
try {
114-
let optionPromise = sendOption(
144+
let commandMessagePromise = sendCommandMessage(
115145
this.client,
116146
this.roomId,
117147
this.deserializeToolCall(toolCall),
118-
this.initialMessageReplaced ? undefined : this.initialMessageId,
148+
this.initialMessageId,
119149
);
120-
this.messagePromises.push(optionPromise);
121-
await optionPromise;
150+
this.messagePromises.push(commandMessagePromise);
151+
await commandMessagePromise;
122152
this.initialMessageReplaced = true;
123153
} catch (error) {
124154
Sentry.captureException(error);
@@ -127,7 +157,7 @@ export class Responder {
127157
this.client,
128158
this.roomId,
129159
error,
130-
this.initialMessageReplaced ? undefined : this.initialMessageId,
160+
this.initialMessageId,
131161
);
132162
this.messagePromises.push(errorPromise);
133163
await errorPromise;
@@ -136,6 +166,7 @@ export class Responder {
136166
}
137167

138168
async onError(error: OpenAIError | string) {
169+
log.debug('onError: ', error);
139170
Sentry.captureException(error);
140171
return await sendError(
141172
this.client,
@@ -146,6 +177,7 @@ export class Responder {
146177
}
147178

148179
async finalize(finalContent: string | void | null | undefined) {
180+
log.debug('finalize: ', finalContent);
149181
if (finalContent) {
150182
finalContent = cleanContent(finalContent);
151183
await this.debouncedMessageSender(

packages/base/matrix-event.gts

+12-6
Original file line numberDiff line numberDiff line change
@@ -134,7 +134,7 @@ export interface CommandEvent extends BaseMatrixEvent {
134134
};
135135
}
136136

137-
export interface CommandMessageContent {
137+
export type CommandMessageContent = {
138138
'm.relates_to'?: {
139139
rel_type: string;
140140
event_id: string;
@@ -143,11 +143,17 @@ export interface CommandMessageContent {
143143
format: 'org.matrix.custom.html';
144144
body: string;
145145
formatted_body: string;
146-
data: {
147-
toolCall: FunctionToolCall;
148-
eventId: string;
149-
};
150-
}
146+
} & (
147+
| {
148+
isStreamingFinished: true | undefined;
149+
data: {
150+
toolCall: FunctionToolCall;
151+
};
152+
}
153+
| {
154+
isStreamingFinished: false;
155+
}
156+
);
151157

152158
export interface CardMessageEvent extends BaseMatrixEvent {
153159
type: 'm.room.message';

packages/host/app/components/ai-assistant/apply-button/index.gts

+75-2
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,12 @@ import { eq } from '@cardstack/boxel-ui/helpers';
55
import { CheckMark, Exclamation } from '@cardstack/boxel-ui/icons';
66
import { setCssVar } from '@cardstack/boxel-ui/modifiers';
77

8-
export type ApplyButtonState = 'ready' | 'applying' | 'applied' | 'failed';
8+
export type ApplyButtonState =
9+
| 'ready'
10+
| 'applying'
11+
| 'applied'
12+
| 'failed'
13+
| 'preparing';
914

1015
interface Signature {
1116
Element: HTMLButtonElement | HTMLDivElement;
@@ -34,6 +39,17 @@ const AiAssistantApplyButton: TemplateOnlyComponent<Signature> = <template>
3439
<CheckMark width='16' height='16' />
3540
{{else if (eq @state 'failed')}}
3641
<Exclamation width='16' height='16' />
42+
{{else if (eq @state 'preparing')}}
43+
<BoxelButton
44+
@kind='secondary-dark'
45+
@size='small'
46+
class='apply-button'
47+
{{setCssVar boxel-button-text-color='var(--boxel-200)'}}
48+
data-test-apply-state='preparing'
49+
...attributes
50+
>
51+
Working…
52+
</BoxelButton>
3753
{{/if}}
3854
</div>
3955
{{/if}}
@@ -67,7 +83,64 @@ const AiAssistantApplyButton: TemplateOnlyComponent<Signature> = <template>
6783
width: 58px;
6884
border-radius: 100px;
6985
}
70-
.state-indicator:not(.applying) {
86+
87+
.state-indicator.preparing {
88+
width: 78px;
89+
padding: 1px;
90+
border-radius: 100px;
91+
}
92+
.state-indicator.preparing .apply-button {
93+
border: 0;
94+
min-width: 74px;
95+
}
96+
97+
.state-indicator.preparing::before {
98+
content: '';
99+
position: absolute;
100+
top: -105px;
101+
left: -55px;
102+
width: 250px;
103+
height: 250px;
104+
background: conic-gradient(
105+
#ffcc8f 0deg,
106+
#ff3966 45deg,
107+
#ff309e 90deg,
108+
#aa1dc9 135deg,
109+
#d7fad6 180deg,
110+
#5fdfea 225deg,
111+
#3d83f2 270deg,
112+
#5145e8 315deg,
113+
#ffcc8f 360deg
114+
);
115+
z-index: -1;
116+
animation: spin 4s infinite linear;
117+
}
118+
119+
.state-indicator.preparing::after {
120+
content: '';
121+
position: absolute;
122+
top: 1px;
123+
left: 1px;
124+
right: 1px;
125+
bottom: 1px;
126+
background: var(--ai-bot-message-background-color);
127+
border-radius: inherit;
128+
z-index: -1;
129+
}
130+
131+
.state-indicator.preparing {
132+
position: relative;
133+
display: inline-block;
134+
border-radius: 3rem;
135+
color: white;
136+
background: var(--boxel-700);
137+
border: none;
138+
cursor: pointer;
139+
z-index: 1;
140+
overflow: hidden;
141+
}
142+
143+
.state-indicator:not(.applying):not(.preparing) {
71144
width: 1.5rem;
72145
aspect-ratio: 1;
73146
border-radius: 50%;

packages/host/app/components/ai-assistant/apply-button/usage.gts

+5-2
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,9 @@ export default class AiAssistantApplyButtonUsage extends Component {
2323
this.state = 'failed';
2424
break;
2525
case 'failed':
26+
this.state = 'preparing';
27+
break;
28+
case 'preparing':
2629
this.state = 'ready';
2730
break;
2831
}
@@ -31,7 +34,7 @@ export default class AiAssistantApplyButtonUsage extends Component {
3134
<FreestyleUsage @name='AiAssistant::ApplyButton'>
3235
<:description>
3336
Displays button for applying change proposed by AI Assistant. Includes
34-
ready, applying, applied and failed states.
37+
ready, applying, applied, failed, and preparing states.
3538
</:description>
3639
<:example>
3740
<div class='example-container'>
@@ -45,7 +48,7 @@ export default class AiAssistantApplyButtonUsage extends Component {
4548
<Args.String
4649
@name='state'
4750
@value={{this.state}}
48-
@options={{array 'ready' 'applying' 'applied' 'failed'}}
51+
@options={{array 'ready' 'applying' 'applied' 'failed' 'preparing'}}
4952
@description='Button state'
5053
@onInput={{fn (mut this.state)}}
5154
/>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import { TemplateOnlyComponent } from '@ember/component/template-only';
2+
3+
import ApplyButton from '../ai-assistant/apply-button';
4+
5+
interface Signature {
6+
Element: HTMLDivElement;
7+
}
8+
9+
const RoomMessageCommand: TemplateOnlyComponent<Signature> = <template>
10+
<div ...attributes>
11+
<div class='command-button-bar'>
12+
<ApplyButton @state='preparing' data-test-command-apply='preparing' />
13+
</div>
14+
</div>
15+
16+
{{! template-lint-disable no-whitespace-for-layout }}
17+
{{! ignore the above error because ember-template-lint complains about the whitespace in the multi-line comment below }}
18+
<style scoped>
19+
.command-button-bar {
20+
display: flex;
21+
justify-content: flex-end;
22+
gap: var(--boxel-sp-xs);
23+
margin-top: var(--boxel-sp);
24+
}
25+
</style>
26+
</template>;
27+
28+
export default RoomMessageCommand;

packages/host/app/components/matrix/room-message.gts

+3
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import { type CardDef } from 'https://cardstack.com/base/card-api';
2525
import AiAssistantMessage from '../ai-assistant/message';
2626
import { aiBotUserId } from '../ai-assistant/panel';
2727

28+
import PreparingRoomMessageCommand from './preparing-room-message-command';
2829
import RoomMessageCommand from './room-message-command';
2930

3031
interface Signature {
@@ -137,6 +138,8 @@ export default class RoomMessage extends Component<Signature> {
137138
@failedCommandState={{this.failedCommandState}}
138139
@isError={{bool this.errorMessage}}
139140
/>
141+
{{else if @message.isPreparingCommand}}
142+
<PreparingRoomMessageCommand />
140143
{{/if}}
141144
</AiAssistantMessage>
142145
{{/if}}

0 commit comments

Comments
 (0)