Add a plugin API for AI Chat

Release-Notes: skip
Google-Bug-Id: b/437944720
Change-Id: I6bf89a58ccf638649d3eb6d8642c2b9d138f245e
diff --git a/polygerrit-ui/app/api/ai-code-review.ts b/polygerrit-ui/app/api/ai-code-review.ts
new file mode 100644
index 0000000..c3148a6
--- /dev/null
+++ b/polygerrit-ui/app/api/ai-code-review.ts
@@ -0,0 +1,66 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {ChangeInfo} from './rest-api';
+
+export declare interface AiCodeReviewPluginApi {
+  /**
+   * Must only be called once. You cannot register twice (throws an error).
+   * You cannot unregister.
+   */
+  register(provider: AiCodeReviewProvider): void;
+}
+
+export declare interface ChatRequest {
+  /**
+   * The prompt to be sent to the LLM.
+   */
+  prompt: string;
+  /**
+   * UUID of the conversation. To start a new conversation, the caller should
+   * generate a new UUID.
+   * To continue an existing conversation, the caller should provide the UUID of
+   * the conversation.
+   */
+  conversationId: string;
+  /**
+   * Plugins can choose what context they want to derive from the change and
+   * send along to their backends. `changeInfo` contains broadly all the
+   * information about the change, and the plugin can also make additional
+   * requests to the REST API (e.g. getting patch content) by using properties
+   * from the change info.
+   */
+  changeInfo: ChangeInfo;
+}
+
+/**
+ * The chat response may come in as a stream, so instead of just one response
+ * object this listener will get multiple calls until the response is completed.
+ */
+export declare interface ChatResponseListener {
+  /**
+   * Emits one piece of a streaming text response from the backend, to be
+   * interpreted as markdown by the web app and to be shown as is to the user.
+   */
+  emitText(text: string): void;
+  /**
+   * Emits an error message, indicating that the turn has failed. Will be
+   * immediately followed by a done() call.
+   */
+  emitError(error: string): void;
+  /**
+   * The turn is completed. The listener can be discarded.
+   */
+  done(): void;
+}
+
+export declare interface AiCodeReviewProvider {
+  /**
+   * If a AiCodeReviewProvider provider is registered that implements this
+   * method, then Gerrit will offer a side panel for the user to have an AI
+   * Chat conversation. Each chat() call is one turn of such a conversation.
+   */
+  chat?(req: ChatRequest, listener: ChatResponseListener): void;
+}
diff --git a/polygerrit-ui/app/api/plugin.ts b/polygerrit-ui/app/api/plugin.ts
index 6f6dfb6..934c87cd 100644
--- a/polygerrit-ui/app/api/plugin.ts
+++ b/polygerrit-ui/app/api/plugin.ts
@@ -18,6 +18,7 @@
 import {StylePluginApi} from './styles';
 import {SuggestionsPluginApi} from './suggestions';
 import {ChangeUpdatesPluginApi} from './change-updates';
+import {AiCodeReviewPluginApi} from './ai-code-review';
 
 export enum TargetElement {
   CHANGE_ACTIONS = 'changeactions',
@@ -65,6 +66,7 @@
    */
   url(): string;
   admin(): AdminPluginApi;
+  aiCodeReview(): AiCodeReviewPluginApi;
   annotationApi(): AnnotationPluginApi;
   attributeHelper(element: Element): AttributeHelperPluginApi;
   changeActions(): ChangeActionsPluginApi;
diff --git a/polygerrit-ui/app/elements/plugins/gr-ai-code-review-api/gr-ai-code-review-api.ts b/polygerrit-ui/app/elements/plugins/gr-ai-code-review-api/gr-ai-code-review-api.ts
new file mode 100644
index 0000000..dd7db5a
--- /dev/null
+++ b/polygerrit-ui/app/elements/plugins/gr-ai-code-review-api/gr-ai-code-review-api.ts
@@ -0,0 +1,48 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {PluginApi} from '../../../api/plugin';
+import {ReportingService} from '../../../services/gr-reporting/gr-reporting';
+import {PluginsModel} from '../../../models/plugins/plugins-model';
+import {
+  AiCodeReviewPluginApi,
+  AiCodeReviewProvider,
+} from '../../../api/ai-code-review';
+
+enum State {
+  NOT_REGISTERED,
+  REGISTERED,
+}
+
+/**
+ * Plugin API for AI Code Review.
+ *
+ * This object is returned to plugins that want to provide AI Code Review data.
+ * Plugins normally just call register() once at startup and then wait for
+ * calls on the provider interface.
+ */
+export class GrAiCodeReviewApi implements AiCodeReviewPluginApi {
+  private state = State.NOT_REGISTERED;
+
+  constructor(
+    private readonly reporting: ReportingService,
+    private readonly pluginsModel: PluginsModel,
+    readonly plugin: PluginApi
+  ) {
+    this.reporting.trackApi(this.plugin, 'ai-code-review', 'constructor');
+  }
+
+  register(provider: AiCodeReviewProvider): void {
+    this.reporting.trackApi(this.plugin, 'ai-code-review', 'register');
+    if (this.state === State.REGISTERED) {
+      throw new Error('Only one provider can be registered per plugin.');
+    }
+    this.state = State.REGISTERED;
+    this.pluginsModel.aiCodeReviewRegister({
+      pluginName: this.plugin.getPluginName(),
+      provider,
+    });
+  }
+}
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-public-js-api.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-public-js-api.ts
index 35c93e6..2a46546 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-public-js-api.ts
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-public-js-api.ts
@@ -37,6 +37,7 @@
 import {StylePluginApi} from '../../../api/styles';
 import {GrSuggestionsApi} from '../../plugins/gr-suggestions-api/gr-suggestions-api';
 import {GrChangeUpdatesApi} from '../../plugins/gr-change-updates-api/gr-change-updates-api';
+import {GrAiCodeReviewApi} from '../../plugins/gr-ai-code-review-api/gr-ai-code-review-api';
 
 const PLUGIN_NAME_NOT_SET = 'NULL';
 
@@ -179,6 +180,10 @@
     return `${origin}${base}/x/${this.getPluginName()}${tokenPart}`;
   }
 
+  aiCodeReview(): GrAiCodeReviewApi {
+    return new GrAiCodeReviewApi(this.report, this.pluginsModel, this);
+  }
+
   annotationApi(): AnnotationPluginApi {
     return new GrAnnotationActionsInterface(
       this.report,
diff --git a/polygerrit-ui/app/models/plugins/plugins-model.ts b/polygerrit-ui/app/models/plugins/plugins-model.ts
index 9df61bc..3ce60da 100644
--- a/polygerrit-ui/app/models/plugins/plugins-model.ts
+++ b/polygerrit-ui/app/models/plugins/plugins-model.ts
@@ -15,6 +15,7 @@
 import {CoverageProvider, TokenHoverListener} from '../../api/annotation';
 import {SuggestionsProvider} from '../../api/suggestions';
 import {ChangeUpdatesPublisher} from '../../api/change-updates';
+import {AiCodeReviewProvider} from '../../api/ai-code-review';
 
 export interface CoveragePlugin {
   pluginName: string;
@@ -32,6 +33,11 @@
   publisher: ChangeUpdatesPublisher;
 }
 
+export interface AiCodeReviewPlugin {
+  pluginName: string;
+  provider: AiCodeReviewProvider;
+}
+
 export interface SuggestionPlugin {
   pluginName: string;
   provider: SuggestionsProvider;
@@ -70,6 +76,11 @@
   checksPlugins: ChecksPlugin[];
 
   /**
+   * List of plugins that have called aiCodeReview().register().
+   */
+  aiCodeReviewPlugins: AiCodeReviewPlugin[];
+
+  /**
    * List of plugins that have called suggestions().register().
    */
   suggestionsPlugins: SuggestionPlugin[];
@@ -105,6 +116,11 @@
     state => state.changeUpdatesPlugins
   );
 
+  public aiCodeReviewPlugins$ = select(
+    this.state$,
+    state => state.aiCodeReviewPlugins
+  );
+
   public suggestionsPlugins$ = select(
     this.state$,
     state => state.suggestionsPlugins
@@ -118,6 +134,7 @@
       coveragePlugins: [],
       changeUpdatesPlugins: [],
       checksPlugins: [],
+      aiCodeReviewPlugins: [],
       suggestionsPlugins: [],
       tokenHighlightPlugins: [],
     });
@@ -175,6 +192,22 @@
     this.setState(nextState);
   }
 
+  aiCodeReviewRegister(plugin: AiCodeReviewPlugin) {
+    const nextState = {...this.getState()};
+    nextState.aiCodeReviewPlugins = [...nextState.aiCodeReviewPlugins];
+    const alreadyRegistered = nextState.aiCodeReviewPlugins.some(
+      p => p.pluginName === plugin.pluginName
+    );
+    if (alreadyRegistered) {
+      console.warn(
+        `${plugin.pluginName} tried to register twice as a AI Code Review provider. Ignored.`
+      );
+      return;
+    }
+    nextState.aiCodeReviewPlugins.push(plugin);
+    this.setState(nextState);
+  }
+
   suggestionsRegister(plugin: SuggestionPlugin) {
     const nextState = {...this.getState()};
     nextState.suggestionsPlugins = [...nextState.suggestionsPlugins];