Skip to content

Commit db7d0af

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

File tree

12 files changed

+216
-66
lines changed

12 files changed

+216
-66
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 packages/ai-bot/lib/responder.ts

+41-12
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,8 @@ 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';
18+
import type OpenAI from 'openai';
1219

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

@@ -19,6 +26,8 @@ export class Responder {
1926
initialMessageReplaced = false;
2027
client: MatrixClient;
2128
roomId: string;
29+
includesFunctionToolCall = false;
30+
latestContent?: string;
2231
messagePromises: Promise<ISendEventResponse | void>[] = [];
2332
debouncedMessageSender: (
2433
content: string,
@@ -35,14 +44,22 @@ export class Responder {
3544
eventToUpdate: string | undefined,
3645
isStreamingFinished = false,
3746
) => {
47+
this.latestContent = content;
48+
let dataOverrides: Record<string, string | boolean> = {
49+
isStreamingFinished: isStreamingFinished,
50+
};
51+
if (this.includesFunctionToolCall) {
52+
dataOverrides = {
53+
...dataOverrides,
54+
msgtype: APP_BOXEL_COMMAND_MSGTYPE,
55+
};
56+
}
3857
const messagePromise = sendMessage(
3958
this.client,
4059
this.roomId,
4160
content,
4261
eventToUpdate,
43-
{
44-
isStreamingFinished: isStreamingFinished,
45-
},
62+
dataOverrides,
4663
);
4764
this.messagePromises.push(messagePromise);
4865
await messagePromise;
@@ -63,9 +80,17 @@ export class Responder {
6380
this.initialMessageId = initialMessage.event_id;
6481
}
6582

66-
async onChunk(chunk: {
67-
usage?: { prompt_tokens: number; completion_tokens: number };
68-
}) {
83+
async onChunk(chunk: OpenAI.Chat.Completions.ChatCompletionChunk) {
84+
log.debug('onChunk: ', JSON.stringify(chunk, null, 2));
85+
if (chunk.choices[0].delta?.tool_calls?.[0]?.function) {
86+
if (!this.includesFunctionToolCall) {
87+
this.includesFunctionToolCall = true;
88+
await this.debouncedMessageSender(
89+
this.latestContent || '',
90+
this.initialMessageId,
91+
);
92+
}
93+
}
6994
// This usage value is set *once* and *only once* at the end of the conversation
7095
// It will be null at all other times.
7196
if (chunk.usage) {
@@ -76,6 +101,7 @@ export class Responder {
76101
}
77102

78103
async onContent(snapshot: string) {
104+
log.debug('onContent: ', snapshot);
79105
await this.debouncedMessageSender(
80106
cleanContent(snapshot),
81107
this.initialMessageId,
@@ -87,6 +113,7 @@ export class Responder {
87113
role: string;
88114
tool_calls?: ChatCompletionMessageToolCall[];
89115
}) {
116+
log.debug('onMessage: ', msg);
90117
if (msg.role === 'assistant') {
91118
await this.handleFunctionToolCalls(msg);
92119
}
@@ -111,14 +138,14 @@ export class Responder {
111138
for (const toolCall of msg.tool_calls || []) {
112139
log.debug('[Room Timeline] Function call', toolCall);
113140
try {
114-
let optionPromise = sendOption(
141+
let commandMessagePromise = sendCommandMessage(
115142
this.client,
116143
this.roomId,
117144
this.deserializeToolCall(toolCall),
118-
this.initialMessageReplaced ? undefined : this.initialMessageId,
145+
this.initialMessageId,
119146
);
120-
this.messagePromises.push(optionPromise);
121-
await optionPromise;
147+
this.messagePromises.push(commandMessagePromise);
148+
await commandMessagePromise;
122149
this.initialMessageReplaced = true;
123150
} catch (error) {
124151
Sentry.captureException(error);
@@ -127,7 +154,7 @@ export class Responder {
127154
this.client,
128155
this.roomId,
129156
error,
130-
this.initialMessageReplaced ? undefined : this.initialMessageId,
157+
this.initialMessageId,
131158
);
132159
this.messagePromises.push(errorPromise);
133160
await errorPromise;
@@ -136,6 +163,7 @@ export class Responder {
136163
}
137164

138165
async onError(error: OpenAIError | string) {
166+
log.debug('onError: ', error);
139167
Sentry.captureException(error);
140168
return await sendError(
141169
this.client,
@@ -146,6 +174,7 @@ export class Responder {
146174
}
147175

148176
async finalize(finalContent: string | void | null | undefined) {
177+
log.debug('finalize: ', finalContent);
149178
if (finalContent) {
150179
finalContent = cleanContent(finalContent);
151180
await this.debouncedMessageSender(

packages/ai-bot/main.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ import {
2626
setTitle,
2727
roomTitleAlreadySet,
2828
} from './lib/set-title';
29-
import { Responder } from './lib/send-response';
29+
import { Responder } from './lib/responder';
3030
import { handleDebugCommands } from './lib/debug';
3131
import { MatrixClient, updateStateEvent } from './lib/matrix';
3232
import type { MatrixEvent as DiscreteMatrixEvent } from 'https://cardstack.com/base/matrix-event';

packages/ai-bot/tests/responding-test.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { module, test, assert } from 'qunit';
2-
import { Responder } from '../lib/send-response';
2+
import { Responder } from '../lib/responder';
33
import { IContent } from 'matrix-js-sdk';
44
import { MatrixClient } from '../lib/matrix';
55
import FakeTimers from '@sinonjs/fake-timers';

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;

0 commit comments

Comments
 (0)