Skip to content

Commit 8260d0b

Browse files
OrKoNDevtools-frontend LUCI CQ
authored andcommitted
[AI Assistance] Implement file editing
Bug: 393268664 Change-Id: I2461ecdecef9461c8cc9e3f611403af813566c76 Reviewed-on: https://chromium-review.googlesource.com/c/devtools/devtools-frontend/+/6278506 Commit-Queue: Alex Rudenko <[email protected]> Reviewed-by: Ergün Erdoğmuş <[email protected]>
1 parent 8648a37 commit 8260d0b

File tree

3 files changed

+171
-61
lines changed

3 files changed

+171
-61
lines changed

front_end/panels/ai_assistance/AiAssistancePanel.ts

Lines changed: 10 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ import {
3838
NetworkAgent,
3939
RequestContext,
4040
} from './agents/NetworkAgent.js';
41-
import {PatchAgent, ProjectContext} from './agents/PatchAgent.js';
41+
import {PatchAgent} from './agents/PatchAgent.js';
4242
import {CallTreeContext, PerformanceAgent} from './agents/PerformanceAgent.js';
4343
import {InsightContext, PerformanceInsightsAgent} from './agents/PerformanceInsightsAgent.js';
4444
import {NodeContext, StylingAgent, StylingAgentWithFunctionCalling} from './agents/StylingAgent.js';
@@ -664,6 +664,7 @@ export class AiAssistancePanel extends UI.Panel.Panel {
664664
multimodalInputEnabled:
665665
isAiAssistanceMultimodalInputEnabled() && this.#currentAgent?.type === AgentType.STYLING,
666666
imageInput: this.#imageInput,
667+
projectName: this.#project?.displayName() ?? '',
667668
onTextSubmit: async (text: string, imageInput?: Host.AidaClient.Part) => {
668669
this.#imageInput = '';
669670
Host.userMetrics.actionTaken(Host.UserMetrics.Action.AiAssistanceQuerySubmitted);
@@ -977,32 +978,23 @@ export class AiAssistancePanel extends UI.Panel.Panel {
977978
}
978979

979980
async #onApplyToWorkspace(): Promise<void> {
981+
const changeSummary = this.#getChangeSummary();
982+
if (!changeSummary) {
983+
throw new Error('Change summary does not exist');
984+
}
980985
if (!this.#project) {
981986
throw new Error('Project does not exist');
982987
}
983988
const agent = new PatchAgent({
984989
aidaClient: this.#aidaClient,
985990
serverSideLoggingEnabled: this.#serverSideLoggingEnabled,
991+
project: this.#project,
986992
});
987993
this.#patchSuggestionLoading = true;
988994
this.requestUpdate();
989-
const prompt =
990-
`I have applied the following CSS changes to my page in Chrome DevTools, what are the files in my source code that I need to change to apply the same change?
991-
992-
\`\`\`css
993-
${this.#getChangeSummary()}
994-
\`\`\`
995-
996-
Try searching using the selectors and if nothing matches, try to find a semantically appropriate place to change.
997-
Output one filename per line and nothing else!
998-
`;
999-
1000-
let response;
1001-
for await (response of agent.run(prompt, {
1002-
selected: new ProjectContext(this.#project),
1003-
})) {
1004-
}
1005-
this.#patchSuggestion = response?.type === ResponseType.ANSWER ? response.text : 'Could not find files';
995+
const responses = await Array.fromAsync(agent.applyChanges(changeSummary));
996+
const response = responses.at(-1);
997+
this.#patchSuggestion = response?.type === ResponseType.ANSWER ? response.text : 'Could not update files';
1006998
this.#patchSuggestionLoading = false;
1007999
this.requestUpdate();
10081000
}

front_end/panels/ai_assistance/agents/PatchAgent.test.ts

Lines changed: 37 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,23 +6,36 @@ import * as Platform from '../../../core/platform/platform.js';
66
import {mockAidaClient, type MockAidaResponse} from '../../../testing/AiAssistanceHelpers.js';
77
import {describeWithEnvironment} from '../../../testing/EnvironmentHelpers.js';
88
import {createFileSystemUISourceCode} from '../../../testing/UISourceCodeHelpers.js';
9-
import {type ActionResponse, PatchAgent, ProjectContext, type ResponseData, ResponseType} from '../ai_assistance.js';
9+
import {type ActionResponse, FileUpdateAgent, PatchAgent, type ResponseData, ResponseType} from '../ai_assistance.js';
1010

11+
/**
12+
* TODO: the following tests have to be added:
13+
*
14+
* - listFiles should have restricted view on files (node_modules etc).
15+
* - searchInFiles should work with dirty UiSourceCodes.
16+
* - updateFiles should verify better that working copies are updated.
17+
*/
1118
describeWithEnvironment('PatchAgent', () => {
12-
async function testAgent(mock: Array<[MockAidaResponse, ...MockAidaResponse[]]>): Promise<ResponseData[]> {
19+
async function testAgent(
20+
mock: Array<[MockAidaResponse, ...MockAidaResponse[]]>,
21+
fileAgentMock?: Array<[MockAidaResponse, ...MockAidaResponse[]]>): Promise<ResponseData[]> {
1322
const {project, uiSourceCode} = createFileSystemUISourceCode({
1423
url: Platform.DevToolsPath.urlString`file:///path/to/overrides/example.html`,
1524
mimeType: 'text/html',
1625
content: 'content',
1726
});
1827

19-
uiSourceCode.setWorkingCopyGetter(() => 'content working copy');
28+
uiSourceCode.setWorkingCopy('content working copy');
2029

2130
const agent = new PatchAgent({
2231
aidaClient: mockAidaClient(mock),
32+
project,
33+
fileUpdateAgent: new FileUpdateAgent({
34+
aidaClient: mockAidaClient(fileAgentMock),
35+
})
2336
});
2437

25-
return await Array.fromAsync(agent.run('test input', {selected: new ProjectContext(project)}));
38+
return await Array.fromAsync(agent.applyChanges('summary'));
2639
}
2740

2841
it('calls listFiles', async () => {
@@ -66,4 +79,24 @@ describeWithEnvironment('PatchAgent', () => {
6679
canceled: false
6780
});
6881
});
82+
83+
it('calls updateFiles', async () => {
84+
const responses = await testAgent(
85+
[
86+
[{
87+
explanation: '',
88+
functionCalls: [{name: 'updateFiles', args: {files: ['//path/to/overrides/example.html']}}]
89+
}],
90+
[{
91+
explanation: 'done',
92+
}]
93+
],
94+
[[{
95+
explanation: 'file updated',
96+
}]]);
97+
98+
const action = responses.find(response => response.type === ResponseType.ACTION);
99+
assert.exists(action);
100+
assert.deepEqual(action, {type: 'action' as ActionResponse['type'], output: '{"success":true}', canceled: false});
101+
});
69102
});

front_end/panels/ai_assistance/agents/PatchAgent.ts

Lines changed: 124 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -5,44 +5,19 @@
55
import * as Host from '../../../core/host/host.js';
66
import * as TextUtils from '../../../models/text_utils/text_utils.js';
77
import type * as Workspace from '../../../models/workspace/workspace.js';
8-
import type * as Lit from '../../../ui/lit/lit.js';
8+
import {debugLog} from '../debug.js';
99

1010
import {
1111
type AgentOptions as BaseAgentOptions,
1212
AgentType,
1313
AiAgent,
1414
type ContextResponse,
15-
ConversationContext,
15+
type ConversationContext,
1616
type RequestOptions,
1717
type ResponseData,
18+
ResponseType,
1819
} from './AiAgent.js';
1920

20-
export class ProjectContext extends ConversationContext<Workspace.Workspace.Project> {
21-
readonly #project: Workspace.Workspace.Project;
22-
23-
constructor(project: Workspace.Workspace.Project) {
24-
super();
25-
this.#project = project;
26-
}
27-
28-
getOrigin(): string {
29-
// TODO
30-
return 'test';
31-
}
32-
33-
getItem(): Workspace.Workspace.Project {
34-
return this.#project;
35-
}
36-
37-
override getIcon(): HTMLElement {
38-
return document.createElement('span');
39-
}
40-
41-
override getTitle(): string|ReturnType<typeof Lit.Directives.until> {
42-
return this.#project.displayName();
43-
}
44-
}
45-
4621
function getFiles(project: Workspace.Workspace.Project):
4722
{files: string[], map: Map<string, Workspace.UISourceCode.UISourceCode>} {
4823
const files = [];
@@ -60,13 +35,14 @@ function getFiles(project: Workspace.Workspace.Project):
6035
}
6136

6237
export class PatchAgent extends AiAgent<Workspace.Workspace.Project> {
63-
#project: ConversationContext<Workspace.Workspace.Project>|undefined;
38+
#project: Workspace.Workspace.Project;
39+
#fileUpdateAgent: FileUpdateAgent;
40+
#changeSummary = '';
6441

6542
override async *
6643
// eslint-disable-next-line require-yield
6744
handleContextDetails(_select: ConversationContext<Workspace.Workspace.Project>|null):
6845
AsyncGenerator<ContextResponse, void, void> {
69-
// TODO: Implement
7046
return;
7147
}
7248

@@ -85,8 +61,10 @@ export class PatchAgent extends AiAgent<Workspace.Workspace.Project> {
8561
};
8662
}
8763

88-
constructor(opts: BaseAgentOptions) {
64+
constructor(opts: BaseAgentOptions&{fileUpdateAgent?: FileUpdateAgent, project: Workspace.Workspace.Project}) {
8965
super(opts);
66+
this.#project = opts.project;
67+
this.#fileUpdateAgent = opts.fileUpdateAgent ?? new FileUpdateAgent(opts);
9068
this.declareFunction<Record<never, unknown>>('listFiles', {
9169
description: 'Returns a list of all files in the project.',
9270
parameters: {
@@ -101,7 +79,7 @@ export class PatchAgent extends AiAgent<Workspace.Workspace.Project> {
10179
error: 'No project available',
10280
};
10381
}
104-
const project = this.#project.getItem();
82+
const project = this.#project;
10583
const {files} = getFiles(project);
10684
return {
10785
result: {
@@ -146,13 +124,17 @@ export class PatchAgent extends AiAgent<Workspace.Workspace.Project> {
146124
error: 'No project available',
147125
};
148126
}
149-
const project = this.#project.getItem();
127+
const project = this.#project;
150128
const {map} = getFiles(project);
151129
const matches = [];
152130
for (const [filepath, file] of map.entries()) {
131+
await file.requestContentData();
132+
debugLog('searching in', filepath, 'for', params.query);
133+
const content = file.isDirty() ? file.workingCopyContentData() : await file.requestContentData();
153134
const results = TextUtils.TextUtils.performSearchInContentData(
154-
file.workingCopyContentData(), params.query, params.caseSensitive ?? true, params.isRegex ?? false);
135+
content, params.query, params.caseSensitive ?? true, params.isRegex ?? false);
155136
for (const result of results) {
137+
debugLog('matches in', filepath);
156138
matches.push({
157139
filepath,
158140
lineNumber: result.lineNumber,
@@ -168,13 +150,116 @@ export class PatchAgent extends AiAgent<Workspace.Workspace.Project> {
168150
};
169151
},
170152
});
153+
154+
this.declareFunction<{
155+
files: string[],
156+
}>('updateFiles', {
157+
description: 'When called this function performs necesary updates to files',
158+
parameters: {
159+
type: Host.AidaClient.ParametersTypes.OBJECT,
160+
description: '',
161+
nullable: false,
162+
properties: {
163+
files: {
164+
type: Host.AidaClient.ParametersTypes.ARRAY,
165+
description: 'List of file names from the project',
166+
nullable: false,
167+
items: {type: Host.AidaClient.ParametersTypes.STRING, description: 'File name'}
168+
}
169+
},
170+
},
171+
handler: async args => {
172+
debugLog('updateFiles', args.files);
173+
if (!this.#project) {
174+
return {
175+
error: 'No project available',
176+
};
177+
}
178+
const project = this.#project;
179+
const {map} = getFiles(project);
180+
for (const file of args.files.slice(0, 3)) {
181+
debugLog('updating', file);
182+
const uiSourceCode = map.get(file);
183+
if (!uiSourceCode) {
184+
debugLog(file, 'not found');
185+
continue;
186+
}
187+
const prompt = `I have applied the following CSS changes to my page in Chrome DevTools.
188+
189+
\`\`\`css
190+
${this.#changeSummary}
191+
\`\`\`
192+
193+
Following '===' I provide the source code file. Update the file to apply the same change to it.
194+
CRITICAL: Output the entire file with changes without any other modifications! DO NOT USE MARKDOWN.
195+
196+
===
197+
${uiSourceCode.workingCopyContentData().text}
198+
`;
199+
let response;
200+
for await (response of this.#fileUpdateAgent.run(prompt, {selected: null})) {
201+
}
202+
debugLog('response', response);
203+
if (response?.type !== ResponseType.ANSWER) {
204+
debugLog('wrong response type', response);
205+
continue;
206+
}
207+
const updated = response.text;
208+
uiSourceCode.setWorkingCopy(updated);
209+
debugLog('updated', updated);
210+
}
211+
return {
212+
result: {
213+
success: true,
214+
}
215+
};
216+
},
217+
});
218+
}
219+
220+
async * applyChanges(changeSummary: string): AsyncGenerator<ResponseData, void, void> {
221+
this.#changeSummary = changeSummary;
222+
const prompt =
223+
`I have applied the following CSS changes to my page in Chrome DevTools, what are the files in my source code that I need to change to apply the same change?
224+
225+
\`\`\`css
226+
${changeSummary}
227+
\`\`\`
228+
229+
Try searching using the selectors and if nothing matches, try to find a semantically appropriate place to change.
230+
Consider updating files containing styles like CSS files first!
231+
Call the updateFiles with the list of files to be updated once you are done.
232+
`;
233+
234+
yield* this.run(prompt, {
235+
selected: null,
236+
});
237+
}
238+
}
239+
240+
/**
241+
* This is an inner "agent" to apply a change to one file.
242+
*/
243+
export class FileUpdateAgent extends AiAgent<Workspace.Workspace.Project> {
244+
override async *
245+
// eslint-disable-next-line require-yield
246+
handleContextDetails(_select: ConversationContext<Workspace.Workspace.Project>|null):
247+
AsyncGenerator<ContextResponse, void, void> {
248+
return;
171249
}
172250

173-
override async * run(initialQuery: string, options: {
174-
signal?: AbortSignal, selected: ConversationContext<Workspace.Workspace.Project>|null,
175-
}): AsyncGenerator<ResponseData, void, void> {
176-
this.#project = options.selected ?? undefined;
251+
override readonly type = AgentType.PATCH;
252+
readonly preamble = undefined;
253+
readonly clientFeature = Host.AidaClient.ClientFeature.CHROME_PATCH_AGENT;
177254

178-
return yield* super.run(initialQuery, options);
255+
get userTier(): string|undefined {
256+
return 'TESTERS';
257+
}
258+
259+
get options(): RequestOptions {
260+
return {
261+
temperature: undefined,
262+
modelId: undefined,
263+
};
179264
}
180265
}

0 commit comments

Comments
 (0)