@theia/ai-external-api 1.76.0-next.33

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/README.md +83 -0
  2. package/lib/browser/ai-external-api-frontend-contribution.d.ts +28 -0
  3. package/lib/browser/ai-external-api-frontend-contribution.d.ts.map +1 -0
  4. package/lib/browser/ai-external-api-frontend-contribution.js +88 -0
  5. package/lib/browser/ai-external-api-frontend-contribution.js.map +1 -0
  6. package/lib/browser/ai-external-api-frontend-contribution.spec.d.ts +2 -0
  7. package/lib/browser/ai-external-api-frontend-contribution.spec.d.ts.map +1 -0
  8. package/lib/browser/ai-external-api-frontend-contribution.spec.js +102 -0
  9. package/lib/browser/ai-external-api-frontend-contribution.spec.js.map +1 -0
  10. package/lib/browser/ai-external-api-frontend-module.d.ts +4 -0
  11. package/lib/browser/ai-external-api-frontend-module.d.ts.map +1 -0
  12. package/lib/browser/ai-external-api-frontend-module.js +47 -0
  13. package/lib/browser/ai-external-api-frontend-module.js.map +1 -0
  14. package/lib/browser/frontend-external-chat-session-provider.d.ts +63 -0
  15. package/lib/browser/frontend-external-chat-session-provider.d.ts.map +1 -0
  16. package/lib/browser/frontend-external-chat-session-provider.js +271 -0
  17. package/lib/browser/frontend-external-chat-session-provider.js.map +1 -0
  18. package/lib/browser/frontend-external-chat-session-provider.spec.d.ts +2 -0
  19. package/lib/browser/frontend-external-chat-session-provider.spec.d.ts.map +1 -0
  20. package/lib/browser/frontend-external-chat-session-provider.spec.js +421 -0
  21. package/lib/browser/frontend-external-chat-session-provider.spec.js.map +1 -0
  22. package/lib/browser/index.d.ts +3 -0
  23. package/lib/browser/index.d.ts.map +1 -0
  24. package/lib/browser/index.js +21 -0
  25. package/lib/browser/index.js.map +1 -0
  26. package/lib/common/external-chat-session-provider.d.ts +213 -0
  27. package/lib/common/external-chat-session-provider.d.ts.map +1 -0
  28. package/lib/common/external-chat-session-provider.js +147 -0
  29. package/lib/common/external-chat-session-provider.js.map +1 -0
  30. package/lib/common/index.d.ts +2 -0
  31. package/lib/common/index.d.ts.map +1 -0
  32. package/lib/common/index.js +20 -0
  33. package/lib/common/index.js.map +1 -0
  34. package/lib/node/ai-external-api-backend-module.d.ts +4 -0
  35. package/lib/node/ai-external-api-backend-module.d.ts.map +1 -0
  36. package/lib/node/ai-external-api-backend-module.js +38 -0
  37. package/lib/node/ai-external-api-backend-module.js.map +1 -0
  38. package/lib/node/ai-external-api-endpoint.d.ts +31 -0
  39. package/lib/node/ai-external-api-endpoint.d.ts.map +1 -0
  40. package/lib/node/ai-external-api-endpoint.js +189 -0
  41. package/lib/node/ai-external-api-endpoint.js.map +1 -0
  42. package/lib/node/ai-external-api-endpoint.spec.d.ts +2 -0
  43. package/lib/node/ai-external-api-endpoint.spec.d.ts.map +1 -0
  44. package/lib/node/ai-external-api-endpoint.spec.js +233 -0
  45. package/lib/node/ai-external-api-endpoint.spec.js.map +1 -0
  46. package/lib/node/external-chat-session-registry.d.ts +83 -0
  47. package/lib/node/external-chat-session-registry.d.ts.map +1 -0
  48. package/lib/node/external-chat-session-registry.js +266 -0
  49. package/lib/node/external-chat-session-registry.js.map +1 -0
  50. package/lib/node/external-chat-session-registry.spec.d.ts +2 -0
  51. package/lib/node/external-chat-session-registry.spec.d.ts.map +1 -0
  52. package/lib/node/external-chat-session-registry.spec.js +366 -0
  53. package/lib/node/external-chat-session-registry.spec.js.map +1 -0
  54. package/lib/node/index.d.ts +3 -0
  55. package/lib/node/index.d.ts.map +1 -0
  56. package/lib/node/index.js +21 -0
  57. package/lib/node/index.js.map +1 -0
  58. package/package.json +52 -0
  59. package/src/browser/ai-external-api-frontend-contribution.spec.ts +128 -0
  60. package/src/browser/ai-external-api-frontend-contribution.ts +90 -0
  61. package/src/browser/ai-external-api-frontend-module.ts +48 -0
  62. package/src/browser/frontend-external-chat-session-provider.spec.ts +491 -0
  63. package/src/browser/frontend-external-chat-session-provider.ts +277 -0
  64. package/src/browser/index.ts +18 -0
  65. package/src/common/external-chat-session-provider.ts +330 -0
  66. package/src/common/index.ts +17 -0
  67. package/src/node/ai-external-api-backend-module.ts +41 -0
  68. package/src/node/ai-external-api-endpoint.spec.ts +272 -0
  69. package/src/node/ai-external-api-endpoint.ts +195 -0
  70. package/src/node/external-chat-session-registry.spec.ts +413 -0
  71. package/src/node/external-chat-session-registry.ts +270 -0
  72. package/src/node/index.ts +18 -0
@@ -0,0 +1,270 @@
1
+ // *****************************************************************************
2
+ // Copyright (C) 2026 EclipseSource GmbH.
3
+ //
4
+ // This program and the accompanying materials are made available under the
5
+ // terms of the Eclipse Public License v. 2.0 which is available at
6
+ // http://www.eclipse.org/legal/epl-2.0.
7
+ //
8
+ // This Source Code may also be made available under the following Secondary
9
+ // Licenses when the conditions for such availability set forth in the Eclipse
10
+ // Public License v. 2.0 are satisfied: GNU General Public License, version 2
11
+ // with the GNU Classpath Exception which is available at
12
+ // https://www.gnu.org/software/classpath/license.html.
13
+ //
14
+ // SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
15
+ // *****************************************************************************
16
+
17
+ import { ChatSessionStatus } from '@theia/ai-chat/lib/common/chat-model';
18
+ import { Emitter, Event, URI } from '@theia/core';
19
+ import { ILogger } from '@theia/core/lib/common/logger';
20
+ import { inject, injectable, named } from '@theia/core/shared/inversify';
21
+ import {
22
+ ExternalChatPrompt, ExternalChatPromptResult, ExternalChatSessionCreateRequest, ExternalChatSessionCreateResult,
23
+ ExternalChatSessionDetail, ExternalChatSessionProvider, ExternalChatSessionSummary
24
+ } from '../common/external-chat-session-provider';
25
+
26
+ /**
27
+ * Aggregates the {@link ExternalChatSessionProvider}s of all connected frontends.
28
+ *
29
+ * Providers register when a frontend connects and are removed when its connection closes.
30
+ * Queries fan out to all providers; a failing or unresponsive provider is logged and skipped
31
+ * so that a broken, busy, or disconnecting frontend does not break the external API. Sessions
32
+ * reported by more than one provider (e.g. a session restored in several frontends) are
33
+ * deduplicated, preferring restored over persisted-only, in-progress over finished, and more
34
+ * recent over older reports. Session actions (open, restore) are routed to the preferred
35
+ * provider in the same order.
36
+ */
37
+ @injectable()
38
+ export class ExternalChatSessionRegistry {
39
+
40
+ /**
41
+ * Timeout in milliseconds after which an unresponsive provider is skipped in the query
42
+ * fan-outs. Only the read-only queries are bounded: an action call may legitimately take
43
+ * longer, and abandoning it could send it twice.
44
+ */
45
+ protected readonly queryTimeout = 10_000;
46
+
47
+ @inject(ILogger) @named('ai-external-api:ExternalChatSessionRegistry')
48
+ protected readonly logger: ILogger;
49
+
50
+ protected readonly providers = new Set<ExternalChatSessionProvider>();
51
+
52
+ protected readonly onDidChangeSessionsEmitter = new Emitter<void>();
53
+ /** Fired when the set of connected frontends changes or a frontend reports a session change. */
54
+ readonly onDidChangeSessions: Event<void> = this.onDidChangeSessionsEmitter.event;
55
+
56
+ addProvider(provider: ExternalChatSessionProvider): void {
57
+ this.providers.add(provider);
58
+ this.notifySessionsChanged();
59
+ }
60
+
61
+ removeProvider(provider: ExternalChatSessionProvider): void {
62
+ if (this.providers.delete(provider)) {
63
+ this.notifySessionsChanged();
64
+ }
65
+ }
66
+
67
+ /** Announces that the sessions visible through this registry changed, see {@link onDidChangeSessions}. */
68
+ notifySessionsChanged(): void {
69
+ this.onDidChangeSessionsEmitter.fire();
70
+ }
71
+
72
+ async getSessions(): Promise<ExternalChatSessionSummary[]> {
73
+ const results = await this.queryProviders(provider => provider.getSessions());
74
+ const merged = new Map<string, ExternalChatSessionSummary>();
75
+ for (const summary of results.flat()) {
76
+ const existing = merged.get(summary.id);
77
+ if (!existing || this.prefer(summary, existing)) {
78
+ merged.set(summary.id, summary);
79
+ }
80
+ }
81
+ return Array.from(merged.values())
82
+ .sort((a, b) => (b.lastInteraction ?? 0) - (a.lastInteraction ?? 0));
83
+ }
84
+
85
+ /**
86
+ * Returns the detail of the session from the preferred frontend that knows it, or
87
+ * `undefined` if no connected frontend knows it. The frontends are ranked by their session
88
+ * summaries, so that only the preferred frontend has to report the conversation.
89
+ */
90
+ async getSession(sessionId: string): Promise<ExternalChatSessionDetail | undefined> {
91
+ for (const provider of await this.getProvidersFor(sessionId)) {
92
+ try {
93
+ const session = await this.withQueryTimeout(provider.getSession(sessionId), 'get a session');
94
+ if (session) {
95
+ return session;
96
+ }
97
+ } catch (error) {
98
+ this.logger.warn('Failed to read a session from a frontend, trying the next one.', error);
99
+ }
100
+ }
101
+ return undefined;
102
+ }
103
+
104
+ /**
105
+ * Opens the session in the preferred frontend that knows it, restoring it if necessary.
106
+ * Returns `false` if no connected frontend knows the session.
107
+ */
108
+ async openSession(sessionId: string): Promise<boolean> {
109
+ for (const provider of await this.getProvidersFor(sessionId)) {
110
+ try {
111
+ if (await provider.openSession(sessionId)) {
112
+ return true;
113
+ }
114
+ } catch (error) {
115
+ this.logger.warn('Failed to open a session in a frontend, trying the next one.', error);
116
+ }
117
+ }
118
+ return false;
119
+ }
120
+
121
+ /**
122
+ * Restores the session in the preferred frontend that knows it and returns its detail.
123
+ * Returns `undefined` if no connected frontend knows the session.
124
+ */
125
+ async restoreSession(sessionId: string): Promise<ExternalChatSessionDetail | undefined> {
126
+ for (const provider of await this.getProvidersFor(sessionId)) {
127
+ try {
128
+ const session = await provider.restoreSession(sessionId);
129
+ if (session) {
130
+ return session;
131
+ }
132
+ } catch (error) {
133
+ this.logger.warn('Failed to restore a session in a frontend, trying the next one.', error);
134
+ }
135
+ }
136
+ return undefined;
137
+ }
138
+
139
+ /**
140
+ * Sends the prompt to the given session in the preferred frontend that knows it.
141
+ * The first definitive answer wins: in particular, a `busy` rejection by the preferred
142
+ * frontend is not retried against less preferred copies of the session.
143
+ * Returns `undefined` if no connected frontend knows the session.
144
+ */
145
+ async sendPrompt(sessionId: string, prompt: ExternalChatPrompt): Promise<ExternalChatPromptResult | undefined> {
146
+ for (const provider of await this.getProvidersFor(sessionId)) {
147
+ try {
148
+ const result = await provider.sendPrompt(sessionId, prompt);
149
+ if (result) {
150
+ return result;
151
+ }
152
+ } catch (error) {
153
+ this.logger.warn('Failed to send a prompt to a frontend, trying the next one.', error);
154
+ }
155
+ }
156
+ return undefined;
157
+ }
158
+
159
+ /**
160
+ * Creates a session in a frontend matching the requested workspace. Without a requested
161
+ * workspace, the session is created in the only connected frontend, and if the connected
162
+ * frontends have different workspaces open, the request is rejected as ambiguous.
163
+ * Rejected with `noFrontend` when no frontend is connected or none responds.
164
+ */
165
+ async createSession(request: ExternalChatSessionCreateRequest): Promise<ExternalChatSessionCreateResult> {
166
+ if (this.providers.size === 0) {
167
+ return { failure: 'noFrontend' };
168
+ }
169
+ const candidates = await this.getProvidersForWorkspace(request.workspace);
170
+ if (candidates.length === 0) {
171
+ return { failure: request.workspace ? 'workspaceNotFound' : 'noFrontend' };
172
+ }
173
+ if (!request.workspace && new Set(candidates.map(candidate => candidate.workspace)).size > 1) {
174
+ return { failure: 'ambiguousWorkspace' };
175
+ }
176
+ for (const candidate of candidates) {
177
+ try {
178
+ return await candidate.provider.createSession(request);
179
+ } catch (error) {
180
+ this.logger.warn('Failed to create a session in a frontend, trying the next one.', error);
181
+ }
182
+ }
183
+ throw new Error('All matching frontends failed to create the session.');
184
+ }
185
+
186
+ /** Returns the providers whose workspace matches the given one, or all providers if none is given. */
187
+ protected async getProvidersForWorkspace(workspace: string | undefined): Promise<{ provider: ExternalChatSessionProvider; workspace?: string }[]> {
188
+ const requested = workspace === undefined ? undefined : this.normalizeWorkspace(workspace);
189
+ const candidates: { provider: ExternalChatSessionProvider; workspace?: string }[] = [];
190
+ await Promise.all(Array.from(this.providers.values(), async provider => {
191
+ try {
192
+ const providerWorkspace = await this.withQueryTimeout(provider.getWorkspace(), 'query a workspace');
193
+ const normalized = providerWorkspace === undefined ? undefined : this.normalizeWorkspace(providerWorkspace);
194
+ if (requested === undefined || requested === normalized) {
195
+ candidates.push({ provider, workspace: normalized });
196
+ }
197
+ } catch (error) {
198
+ this.logger.warn('Failed to query a frontend session provider, skipping it.', error);
199
+ }
200
+ }));
201
+ return candidates;
202
+ }
203
+
204
+ /**
205
+ * Normalizes a workspace URI for comparison: URIs that denote the same workspace but differ
206
+ * in their path encoding or in a trailing separator normalize to the same string.
207
+ */
208
+ protected normalizeWorkspace(workspace: string): string {
209
+ const uri = new URI(workspace).normalizePath();
210
+ const path = uri.path.toString();
211
+ return path.length > 1 && path.endsWith('/') ? uri.withPath(path.slice(0, -1)).toString() : uri.toString();
212
+ }
213
+
214
+ /** Returns the providers that know the given session, most preferred report first. */
215
+ protected async getProvidersFor(sessionId: string): Promise<ExternalChatSessionProvider[]> {
216
+ const candidates: { provider: ExternalChatSessionProvider; session: ExternalChatSessionSummary }[] = [];
217
+ await Promise.all(Array.from(this.providers.values(), async provider => {
218
+ try {
219
+ const session = await this.withQueryTimeout(provider.getSessionSummary(sessionId), 'query a session summary');
220
+ if (session) {
221
+ candidates.push({ provider, session });
222
+ }
223
+ } catch (error) {
224
+ this.logger.warn('Failed to query a frontend session provider, skipping it.', error);
225
+ }
226
+ }));
227
+ return candidates
228
+ .sort((a, b) => this.prefer(a.session, b.session) ? -1 : this.prefer(b.session, a.session) ? 1 : 0)
229
+ .map(candidate => candidate.provider);
230
+ }
231
+
232
+ protected async queryProviders<T>(query: (provider: ExternalChatSessionProvider) => Promise<T | undefined>): Promise<T[]> {
233
+ const results: T[] = [];
234
+ await Promise.all(Array.from(this.providers.values(), async provider => {
235
+ try {
236
+ const result = await this.withQueryTimeout(query(provider), 'query a frontend');
237
+ if (result !== undefined) {
238
+ results.push(result);
239
+ }
240
+ } catch (error) {
241
+ this.logger.warn('Failed to query a frontend session provider, skipping it.', error);
242
+ }
243
+ }));
244
+ return results;
245
+ }
246
+
247
+ /**
248
+ * Rejects when the query does not answer within {@link queryTimeout}. Theia's RPC has no
249
+ * request timeout of its own, so an unresponsive frontend would otherwise stall the
250
+ * external API request that fans out to it indefinitely.
251
+ */
252
+ protected withQueryTimeout<T>(query: Promise<T>, description: string): Promise<T> {
253
+ let timer: NodeJS.Timeout | undefined;
254
+ const timeout = new Promise<never>((_, reject) => {
255
+ timer = setTimeout(() => reject(new Error(`A frontend did not ${description} within ${this.queryTimeout} ms.`)), this.queryTimeout);
256
+ });
257
+ return Promise.race([query, timeout]).finally(() => clearTimeout(timer));
258
+ }
259
+
260
+ protected prefer(candidate: ExternalChatSessionSummary, existing: ExternalChatSessionSummary): boolean {
261
+ if (candidate.restored !== existing.restored) {
262
+ return candidate.restored;
263
+ }
264
+ const candidateInProgress = ChatSessionStatus.isInProgress(candidate.status);
265
+ if (candidateInProgress !== ChatSessionStatus.isInProgress(existing.status)) {
266
+ return candidateInProgress;
267
+ }
268
+ return (candidate.lastInteraction ?? 0) > (existing.lastInteraction ?? 0);
269
+ }
270
+ }
@@ -0,0 +1,18 @@
1
+ // *****************************************************************************
2
+ // Copyright (C) 2026 EclipseSource GmbH.
3
+ //
4
+ // This program and the accompanying materials are made available under the
5
+ // terms of the Eclipse Public License v. 2.0 which is available at
6
+ // http://www.eclipse.org/legal/epl-2.0.
7
+ //
8
+ // This Source Code may also be made available under the following Secondary
9
+ // Licenses when the conditions for such availability set forth in the Eclipse
10
+ // Public License v. 2.0 are satisfied: GNU General Public License, version 2
11
+ // with the GNU Classpath Exception which is available at
12
+ // https://www.gnu.org/software/classpath/license.html.
13
+ //
14
+ // SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
15
+ // *****************************************************************************
16
+
17
+ export * from './ai-external-api-endpoint';
18
+ export * from './external-chat-session-registry';