Skip to content

Commit d62461b

Browse files
authored
feat: add altText mode to ai utilities (#662)
1 parent f98121d commit d62461b

19 files changed

Lines changed: 559 additions & 462 deletions

File tree

.changeset/rude-lobsters-grab.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@blinkk/root-cms': patch
3+
---
4+
5+
feat: add ai alt text generation

packages/root-cms/core/ai.ts

Lines changed: 50 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,11 @@ import {vertexAI} from '@genkit-ai/vertexai';
55
import {Timestamp} from 'firebase-admin/firestore';
66
import {Genkit, genkit, MessageData} from 'genkit';
77
import {logger} from 'genkit/logging';
8-
import {ChatPrompt, SendPromptOptions} from '../shared/ai/prompts.js';
8+
import {
9+
ChatPrompt,
10+
AiResponse,
11+
SendPromptOptions,
12+
} from '../shared/ai/prompts.js';
913
import {RootCMSClient} from './client.js';
1014
import {CMSPluginOptions} from './plugin.js';
1115

@@ -64,7 +68,10 @@ export class Chat {
6468
options: SendPromptOptions
6569
): Promise<MessageData[]> {
6670
const messages = this.history;
67-
if (messages.length === 0) {
71+
const hasSystemPrompt = messages.some(
72+
(msg) => msg.role === 'system' && msg.content.length > 0
73+
);
74+
if (!hasSystemPrompt) {
6875
messages.push({
6976
role: 'system',
7077
content: [{text: await this.buildSystemPrompt(options)}],
@@ -89,10 +96,14 @@ export class Chat {
8996
}
9097

9198
/** Builds the request sent to the AI based on the `ChatMode`. */
92-
private async buildChatRequest(
99+
private async buildGenerateRequest(
93100
prompt: ChatPrompt | ChatPrompt[],
94101
options: SendPromptOptions
95-
) {
102+
): Promise<{
103+
messages: MessageData[];
104+
model: string;
105+
prompt: ChatPrompt | ChatPrompt[];
106+
}> {
96107
if (options.mode === 'edit') {
97108
return {
98109
messages: await this.buildMessages(options),
@@ -111,8 +122,8 @@ export class Chat {
111122
async sendPrompt(
112123
prompt: ChatPrompt | ChatPrompt[],
113124
options: SendPromptOptions = {}
114-
): Promise<string> {
115-
const chatRequest = await this.buildChatRequest(prompt, options);
125+
): Promise<AiResponse> {
126+
const chatRequest = await this.buildGenerateRequest(prompt, options);
116127
// TODO: Use streaming responses per https://genkit.dev/docs/models/#streaming
117128
// to improve UI performance.
118129
const res = await this.ai.generate({
@@ -125,7 +136,11 @@ export class Chat {
125136
history: this.history,
126137
modifiedAt: Timestamp.now(),
127138
});
128-
return res.text;
139+
// Using the `output` property provides both data and text responses.
140+
if (options.mode === 'edit') {
141+
return res.output as AiResponse & {editData?: any};
142+
}
143+
return {message: res.text, data: null};
129144
}
130145

131146
dbDoc() {
@@ -147,15 +162,28 @@ export class Chat {
147162
// Edit mode prompts.
148163
if (options.mode === 'edit') {
149164
const rootDir = process.cwd();
150-
const rootCmsDefs = fs.readFileSync(
151-
path.resolve(rootDir, 'root-cms.d.ts'),
152-
{
153-
encoding: 'utf8',
154-
}
155-
);
156-
const text = (await import('../shared/ai/prompts/edit.txt')).default;
157-
text.replace('{{ROOT_CMS_DEFS}}', rootCmsDefs);
158-
return text;
165+
// The `root-cms.d.ts` file may not be bundled with the server code,
166+
// so check whether it exists first before attempting to add it to the prompt.
167+
const rootCmsDefsPath = path.resolve(rootDir, 'root-cms.d.ts');
168+
const rootCmsDefs = fs.existsSync(rootCmsDefsPath)
169+
? fs.readFileSync(rootCmsDefsPath, {
170+
encoding: 'utf8',
171+
})
172+
: null;
173+
const text = [(await import('../shared/ai/prompts/edit.txt')).default];
174+
if (rootCmsDefs) {
175+
text.push(
176+
'Here is the `root-cms.d.ts` file for this project:',
177+
'```',
178+
rootCmsDefs,
179+
'```'
180+
);
181+
}
182+
return text.join('\n');
183+
}
184+
185+
if (options.mode === 'altText') {
186+
return (await import('../shared/ai/prompts/altText.txt')).default;
159187
}
160188

161189
// Chat mode (default) prompts.
@@ -188,7 +216,7 @@ export class ChatClient {
188216
this.user = user;
189217
}
190218

191-
async createChat(): Promise<Chat> {
219+
private async createChat(): Promise<Chat> {
192220
const chatId = crypto.randomUUID();
193221
// Save chat to db so that user has a chat history and can enable "sharing"
194222
// with others. Store the model used with the metadata.
@@ -204,7 +232,11 @@ export class ChatClient {
204232
return chat;
205233
}
206234

207-
async getChat(chatId: string): Promise<Chat> {
235+
async getOrCreateChat(chatId?: string): Promise<Chat> {
236+
return chatId ? this.getChat(chatId) : this.createChat();
237+
}
238+
239+
private async getChat(chatId: string): Promise<Chat> {
208240
// Fetch chat from db to preserve the conversation's history.
209241
const docRef = this.dbCollection().doc(chatId);
210242
const chatDoc = await docRef.get();

packages/root-cms/core/api.ts

Lines changed: 23 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
11
import {Server, Request, Response} from '@blinkk/root';
22
import {multipartMiddleware} from '@blinkk/root/middleware';
3-
import {ChatPrompt, SendPromptOptions} from '../shared/ai/prompts.js';
4-
import {Chat, ChatClient, RootAiModel} from './ai.js';
3+
import {
4+
ChatPrompt,
5+
AiResponse,
6+
SendPromptOptions,
7+
} from '../shared/ai/prompts.js';
8+
import {ChatClient, RootAiModel} from './ai.js';
59
import {RootCMSClient} from './client.js';
610
import {runCronJobs} from './cron.js';
711
import {arrayToCsv, csvToArray} from './csv.js';
@@ -15,6 +19,13 @@ export interface ChatApiRequest {
1519
options?: SendPromptOptions;
1620
}
1721

22+
export interface ChatApiResponse {
23+
success: boolean;
24+
chatId: string;
25+
response: AiResponse;
26+
error?: string;
27+
}
28+
1829
export interface ApiOptions {
1930
getRenderer: (req: Request) => Promise<AppModule>;
2031
}
@@ -258,19 +269,16 @@ export function api(server: Server, options: ApiOptions) {
258269
try {
259270
const cmsClient = new RootCMSClient(req.rootConfig!);
260271
const chatClient = new ChatClient(cmsClient, req.user.email);
261-
let chat: Chat;
262-
if (reqBody.chatId) {
263-
chat = await chatClient.getChat(reqBody.chatId);
264-
} else {
265-
chat = await chatClient.createChat();
266-
}
267-
const chatResponse = await chat.sendPrompt(prompt, {
268-
mode: reqBody.options?.mode || 'chat',
269-
editData: reqBody.options?.editData,
270-
});
271-
res
272-
.status(200)
273-
.json({success: true, chatId: chat.id, response: chatResponse});
272+
const chat = await chatClient.getOrCreateChat(reqBody.chatId);
273+
const apiResponse: ChatApiResponse = {
274+
success: true,
275+
chatId: chat.id,
276+
response: await chat.sendPrompt(prompt, {
277+
mode: reqBody.options?.mode || 'chat',
278+
editData: reqBody.options?.editData,
279+
}),
280+
};
281+
res.status(200).json(apiResponse);
274282
} catch (err) {
275283
console.error(err.stack || err);
276284
res.status(500).json({success: false, error: 'UNKNOWN'});

packages/root-cms/core/app.tsx

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -229,11 +229,7 @@ export async function renderSignIn(
229229
firebaseConfig: options.cmsConfig.firebaseConfig,
230230
};
231231
const mainHtml = renderToString(
232-
<SignIn
233-
title="Sign in"
234-
ctx={ctx}
235-
favicon={options.cmsConfig.favicon}
236-
/>
232+
<SignIn title="Sign in" ctx={ctx} favicon={options.cmsConfig.favicon} />
237233
);
238234
let html = `<!doctype html>\n${mainHtml}`;
239235
const nonce = generateNonce();

packages/root-cms/shared/ai/prompts.ts

Lines changed: 4 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -9,14 +9,12 @@ export type ChatPrompt = Part | Part[];
99
* - chat: Used in the "Chat with Root AI" sidebar tool.
1010
* - edit: Used in the "Edit with AI" modal dialog for editing JSON files.
1111
*/
12-
export type ChatMode = 'chat' | 'edit';
12+
export type ChatMode = 'chat' | 'edit' | 'altText';
1313

14-
/** The parsed response from the AI */
15-
export interface ParsedChatResponse {
16-
/** The textual response from the AI. For chat requests, this is the full response. For other types of requests, this contains just the AI's "message" response, whereas any other data will be included in the `data` property. */
14+
export interface AiResponse {
1715
message: string;
18-
/** JSON data returned by the AI. */
19-
data: Record<string, any>;
16+
data: Record<string, any> | null;
17+
error?: string;
2018
}
2119

2220
/**
@@ -29,29 +27,3 @@ export interface SendPromptOptions {
2927
/** Data sent for edit mode requests. */
3028
editData?: Record<string, any>;
3129
}
32-
33-
/**
34-
* Parses the response from the AI request. The response structure differs
35-
* depending on the prompt and the mode. For example, chat mode receives a text
36-
* response. Edit mode receives a response containing JSON, which must be
37-
* parsed out from the text response.
38-
*/
39-
export function parseResponse(
40-
response: string,
41-
mode?: ChatMode
42-
): ParsedChatResponse {
43-
// TODO(jeremydw): Research whether we can require the AI or genkit to respond in structured
44-
// JSON format, so that we don't have to parse the response ourselves.
45-
// Use https://genkit.dev/docs/models/#structured-output to enforce structured output
46-
// instead of parsing the response.
47-
if (mode === 'edit') {
48-
const jsonMatch = response.match(/^```json\n?(.*?)\n?```$/s);
49-
const responseAsJson = JSON.parse(jsonMatch ? jsonMatch[1] : response);
50-
return {
51-
message: responseAsJson.message,
52-
data: responseAsJson.data,
53-
};
54-
}
55-
// Chat messages don't contain any data, just the chat response.
56-
return {message: response, data: {}};
57-
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
Create a descriptive and concise alt text for the attached image.
2+
3+
- The alt text should be a brief but comprehensive description of the image, including key subjects, the setting, and any relevant details or actions.
4+
- The alt text should not exceed 125 characters.
5+
- Only provide one generation, and include only that data in the response. No surrounding text, clarifications, etc. Just the alt text.

packages/root-cms/shared/ai/prompts/edit.txt

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ Input Requirements & Handling:
1010

1111
- The input you receive will be broken into three parts. (1) `root-cms-d.ts` (the TypeScript interface), (2) the existing JSON file you are editing (or an empty JSON file when generating new content), and (3) the user input (screenshots, chat messages, instructions, etc.). You must ensure that the resulting JSON file is compatible with the structure defined by the TypeScript interface and contains all of the marketing content provided by the user input.
1212

13-
- When you provide your response, if the user instructs you to only edit one part of the original JSON, make sure to leave the rest of the JSON file untouched. Your response, however, should always contain the _entire_ JSON file combining the original contents with your new edits (think of it like you're applying a PATCH operation and the response is the entire file).
13+
- When you provide your response, if the user instructs you to only edit one part of the original JSON, make sure to leave the rest of the JSON file untouched. You must return the entire JSON document after applying changes. Never omit, rename, reorder or invent top-level keys. Copy all unmodified fields exactly as they appear in the input.
1414

1515
Root CMS JSON File Structure:
1616

@@ -111,16 +111,10 @@ When adding new items to arrays that use `oneOf` fields, try your best to figure
111111

112112
Additionally, when adding new items to existing JSON data, you must carefully check the structure of those items as defined by `root-cms.d.ts`. Avoid inventing new fields for content types, use only those defined by the `root-cms.d.ts`. For example, if you are adding a button to a module, carefully check the button structure defined in `root-cms.d.ts` and only populate fields that are defined there.
113113

114-
5. Here is the `root-cms.d.ts` file for this project:
115-
116-
```
117-
{{ROOT_CMS_DEFS}}
118-
```
119-
120114
Finally, when you provide your response, it MUST be structured in the following way for handling by the chat client.
121115

122116
{
123-
"data": <The JSON generated by you via the instructions above>,
117+
"data": <The JSON data generated by you via the instructions above>,
124118
"message": <A brief message that describes what you did.>
125119
}
126120

packages/root-cms/signin/styles/global.css

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,7 @@ body.menu\:open {
9090
.bootstrap-error {
9191
font-size: 14px;
9292
font-weight: 400;
93+
line-height: 20px;
9394
position: absolute;
9495
bottom: 100px;
9596
left: 0;
@@ -98,6 +99,7 @@ body.menu\:open {
9899
animation: 0.5s forwards bootstrap-loading-error;
99100
animation-delay: 1s;
100101
opacity: 0;
102+
padding: 0 24px;
101103
}
102104

103105
@keyframes bootstrap-loading-error {

packages/root-cms/ui/components/AiEditModal/AiEditModal.css

Lines changed: 27 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -5,48 +5,62 @@
55
gap: 12px;
66
}
77

8-
.AiEditModal__JsonInput,
9-
.AiEditModal__JsonInput .mantine-JsonInput-wrapper,
10-
.AiEditModal__JsonInput .mantine-JsonInput-input {
11-
height: 100%;
12-
}
13-
148
.AiEditModal__JsonInput .mantine-JsonInput-input {
159
font-size: 0.875rem;
1610
line-height: 1.5;
1711
}
1812

1913
.AiEditModal__SplitPanel {
2014
display: grid;
21-
grid-template-columns: 50% 50%;
15+
grid-template-columns: 1fr 1fr;
2216
gap: 24px;
17+
height: 680px;
18+
max-height: calc(100vh - 280px);
19+
}
20+
21+
.AiEditModal__SplitPanel__JsonPanel {
22+
display: flex;
23+
flex-direction: column;
24+
height: 100%;
2325
}
2426

2527
.AiEditModal__JsonEditor {
2628
height: 100%;
27-
overflow: auto;
29+
width: 100%;
30+
}
31+
32+
.AiEditModal__JsonInput,
33+
.AiEditModal__JsonInput .mantine-JsonInput-wrapper,
34+
.AiEditModal__JsonInput .mantine-JsonInput-input {
35+
height: 100%;
2836
}
2937

3038
.AiEditModal__SplitPanel__ChatPanel {
3139
height: 100%;
3240
overflow: auto;
3341
display: flex;
3442
flex-direction: column;
35-
height: calc(max(100vh - 300px, 400px));
3643
}
3744

38-
.AiEditModal__Tabs,
39-
.AiEditModal__Tabs .mantine-Tabs-root,
45+
.AiEditModal__Tabs {
46+
display: flex;
47+
flex: 1;
48+
flex-direction: column;
49+
}
50+
4051
.AiEditModal__Tabs .mantine-Tabs-body {
52+
display: flex;
53+
flex: 1 1 0;
4154
height: 100%;
55+
min-height: 0;
4256
}
4357

4458
.AiEditModal__JsonDiffViewer {
45-
border: 1px solid rgb(206, 212, 218);
4659
border-radius: 4px;
60+
border: 1px solid rgb(206, 212, 218);
4761
height: 100%;
48-
max-height: calc(max(100vh - 300px, 400px));
4962
overflow: auto;
63+
width: 100%;
5064
}
5165

5266
.AiEditModal__JsonDiffViewer .JsDiff__container {

0 commit comments

Comments
 (0)