api.ts

  1import {
  2  Conversation,
  3  ConversationWithState,
  4  StreamResponse,
  5  ChatRequest,
  6  GitDiffInfo,
  7  GitFileInfo,
  8  GitFileDiff,
  9  VersionInfo,
 10  CommitInfo,
 11} from "../types";
 12
 13class ApiService {
 14  private baseUrl = "/api";
 15
 16  // Common headers for state-changing requests (CSRF protection)
 17  private postHeaders = {
 18    "Content-Type": "application/json",
 19    "X-Shelley-Request": "1",
 20  };
 21
 22  async getConversations(): Promise<ConversationWithState[]> {
 23    const response = await fetch(`${this.baseUrl}/conversations`);
 24    if (!response.ok) {
 25      throw new Error(`Failed to get conversations: ${response.statusText}`);
 26    }
 27    return response.json();
 28  }
 29
 30  async getModels(): Promise<
 31    Array<{
 32      id: string;
 33      display_name?: string;
 34      source?: string;
 35      ready: boolean;
 36      max_context_tokens?: number;
 37    }>
 38  > {
 39    const response = await fetch(`${this.baseUrl}/models`);
 40    if (!response.ok) {
 41      throw new Error(`Failed to get models: ${response.statusText}`);
 42    }
 43    return response.json();
 44  }
 45
 46  async searchConversations(query: string): Promise<ConversationWithState[]> {
 47    const params = new URLSearchParams({
 48      q: query,
 49      search_content: "true",
 50    });
 51    const response = await fetch(`${this.baseUrl}/conversations?${params}`);
 52    if (!response.ok) {
 53      throw new Error(`Failed to search conversations: ${response.statusText}`);
 54    }
 55    return response.json();
 56  }
 57
 58  async sendMessageWithNewConversation(request: ChatRequest): Promise<{ conversation_id: string }> {
 59    const response = await fetch(`${this.baseUrl}/conversations/new`, {
 60      method: "POST",
 61      headers: this.postHeaders,
 62      body: JSON.stringify(request),
 63    });
 64    if (!response.ok) {
 65      throw new Error(`Failed to send message: ${response.statusText}`);
 66    }
 67    return response.json();
 68  }
 69
 70  async continueConversation(
 71    sourceConversationId: string,
 72    model?: string,
 73    cwd?: string,
 74  ): Promise<{ conversation_id: string }> {
 75    const response = await fetch(`${this.baseUrl}/conversations/continue`, {
 76      method: "POST",
 77      headers: this.postHeaders,
 78      body: JSON.stringify({
 79        source_conversation_id: sourceConversationId,
 80        model: model || "",
 81        cwd: cwd || "",
 82      }),
 83    });
 84    if (!response.ok) {
 85      throw new Error(`Failed to continue conversation: ${response.statusText}`);
 86    }
 87    return response.json();
 88  }
 89
 90  async getConversation(conversationId: string): Promise<StreamResponse> {
 91    const response = await fetch(`${this.baseUrl}/conversation/${conversationId}`);
 92    if (!response.ok) {
 93      throw new Error(`Failed to get messages: ${response.statusText}`);
 94    }
 95    return response.json();
 96  }
 97
 98  async sendMessage(conversationId: string, request: ChatRequest): Promise<void> {
 99    const response = await fetch(`${this.baseUrl}/conversation/${conversationId}/chat`, {
100      method: "POST",
101      headers: this.postHeaders,
102      body: JSON.stringify(request),
103    });
104    if (!response.ok) {
105      throw new Error(`Failed to send message: ${response.statusText}`);
106    }
107  }
108
109  createMessageStream(conversationId: string, lastSequenceId?: number): EventSource {
110    let url = `${this.baseUrl}/conversation/${conversationId}/stream`;
111    if (lastSequenceId !== undefined && lastSequenceId >= 0) {
112      url += `?last_sequence_id=${lastSequenceId}`;
113    }
114    return new EventSource(url);
115  }
116
117  async cancelConversation(conversationId: string): Promise<void> {
118    const response = await fetch(`${this.baseUrl}/conversation/${conversationId}/cancel`, {
119      method: "POST",
120      headers: { "X-Shelley-Request": "1" },
121    });
122    if (!response.ok) {
123      throw new Error(`Failed to cancel conversation: ${response.statusText}`);
124    }
125  }
126
127  async validateCwd(path: string): Promise<{ valid: boolean; error?: string }> {
128    const response = await fetch(`${this.baseUrl}/validate-cwd?path=${encodeURIComponent(path)}`);
129    if (!response.ok) {
130      throw new Error(`Failed to validate cwd: ${response.statusText}`);
131    }
132    return response.json();
133  }
134
135  async listDirectory(path?: string): Promise<{
136    path: string;
137    parent: string;
138    entries: Array<{ name: string; is_dir: boolean; git_head_subject?: string }>;
139    git_head_subject?: string;
140    error?: string;
141  }> {
142    const url = path
143      ? `${this.baseUrl}/list-directory?path=${encodeURIComponent(path)}`
144      : `${this.baseUrl}/list-directory`;
145    const response = await fetch(url);
146    if (!response.ok) {
147      throw new Error(`Failed to list directory: ${response.statusText}`);
148    }
149    return response.json();
150  }
151
152  async createDirectory(path: string): Promise<{ path?: string; error?: string }> {
153    const response = await fetch(`${this.baseUrl}/create-directory`, {
154      method: "POST",
155      headers: this.postHeaders,
156      body: JSON.stringify({ path }),
157    });
158    if (!response.ok) {
159      throw new Error(`Failed to create directory: ${response.statusText}`);
160    }
161    return response.json();
162  }
163
164  async getArchivedConversations(): Promise<Conversation[]> {
165    const response = await fetch(`${this.baseUrl}/conversations/archived`);
166    if (!response.ok) {
167      throw new Error(`Failed to get archived conversations: ${response.statusText}`);
168    }
169    return response.json();
170  }
171
172  async archiveConversation(conversationId: string): Promise<Conversation> {
173    const response = await fetch(`${this.baseUrl}/conversation/${conversationId}/archive`, {
174      method: "POST",
175      headers: { "X-Shelley-Request": "1" },
176    });
177    if (!response.ok) {
178      throw new Error(`Failed to archive conversation: ${response.statusText}`);
179    }
180    return response.json();
181  }
182
183  async unarchiveConversation(conversationId: string): Promise<Conversation> {
184    const response = await fetch(`${this.baseUrl}/conversation/${conversationId}/unarchive`, {
185      method: "POST",
186      headers: { "X-Shelley-Request": "1" },
187    });
188    if (!response.ok) {
189      throw new Error(`Failed to unarchive conversation: ${response.statusText}`);
190    }
191    return response.json();
192  }
193
194  async deleteConversation(conversationId: string): Promise<void> {
195    const response = await fetch(`${this.baseUrl}/conversation/${conversationId}/delete`, {
196      method: "POST",
197      headers: { "X-Shelley-Request": "1" },
198    });
199    if (!response.ok) {
200      throw new Error(`Failed to delete conversation: ${response.statusText}`);
201    }
202  }
203
204  async getConversationBySlug(slug: string): Promise<Conversation | null> {
205    const response = await fetch(
206      `${this.baseUrl}/conversation-by-slug/${encodeURIComponent(slug)}`,
207    );
208    if (response.status === 404) {
209      return null;
210    }
211    if (!response.ok) {
212      throw new Error(`Failed to get conversation by slug: ${response.statusText}`);
213    }
214    return response.json();
215  }
216
217  // Git diff APIs
218  async getGitDiffs(cwd: string): Promise<{ diffs: GitDiffInfo[]; gitRoot: string }> {
219    const response = await fetch(`${this.baseUrl}/git/diffs?cwd=${encodeURIComponent(cwd)}`);
220    if (!response.ok) {
221      const text = await response.text();
222      throw new Error(text || response.statusText);
223    }
224    return response.json();
225  }
226
227  async getGitDiffFiles(diffId: string, cwd: string): Promise<GitFileInfo[]> {
228    const response = await fetch(
229      `${this.baseUrl}/git/diffs/${diffId}/files?cwd=${encodeURIComponent(cwd)}`,
230    );
231    if (!response.ok) {
232      throw new Error(`Failed to get diff files: ${response.statusText}`);
233    }
234    return response.json();
235  }
236
237  async getGitFileDiff(diffId: string, filePath: string, cwd: string): Promise<GitFileDiff> {
238    const response = await fetch(
239      `${this.baseUrl}/git/file-diff/${diffId}/${filePath}?cwd=${encodeURIComponent(cwd)}`,
240    );
241    if (!response.ok) {
242      throw new Error(`Failed to get file diff: ${response.statusText}`);
243    }
244    return response.json();
245  }
246
247  async renameConversation(conversationId: string, slug: string): Promise<Conversation> {
248    const response = await fetch(`${this.baseUrl}/conversation/${conversationId}/rename`, {
249      method: "POST",
250      headers: this.postHeaders,
251      body: JSON.stringify({ slug }),
252    });
253    if (!response.ok) {
254      throw new Error(`Failed to rename conversation: ${response.statusText}`);
255    }
256    return response.json();
257  }
258
259  async getSubagents(conversationId: string): Promise<Conversation[]> {
260    const response = await fetch(`${this.baseUrl}/conversation/${conversationId}/subagents`);
261    if (!response.ok) {
262      throw new Error(`Failed to get subagents: ${response.statusText}`);
263    }
264    return response.json();
265  }
266
267  // Version check APIs
268  async checkVersion(forceRefresh = false): Promise<VersionInfo> {
269    const url = forceRefresh ? "/version-check?refresh=true" : "/version-check";
270    const response = await fetch(url);
271    if (!response.ok) {
272      throw new Error(`Failed to check version: ${response.statusText}`);
273    }
274    return response.json();
275  }
276
277  async getChangelog(currentTag: string, latestTag: string): Promise<CommitInfo[]> {
278    const params = new URLSearchParams({ current: currentTag, latest: latestTag });
279    const response = await fetch(`/version-changelog?${params}`);
280    if (!response.ok) {
281      throw new Error(`Failed to get changelog: ${response.statusText}`);
282    }
283    return response.json();
284  }
285
286  async upgrade(): Promise<{ status: string; message: string }> {
287    const response = await fetch("/upgrade", {
288      method: "POST",
289      headers: { "X-Shelley-Request": "1" },
290    });
291    if (!response.ok) {
292      const text = await response.text();
293      throw new Error(text || response.statusText);
294    }
295    return response.json();
296  }
297
298  async exit(): Promise<{ status: string; message: string }> {
299    const response = await fetch("/exit", {
300      method: "POST",
301      headers: { "X-Shelley-Request": "1" },
302    });
303    if (!response.ok) {
304      throw new Error(`Failed to exit: ${response.statusText}`);
305    }
306    return response.json();
307  }
308}
309
310export const api = new ApiService();
311
312// Custom models API
313export interface CustomModel {
314  model_id: string;
315  display_name: string;
316  provider_type: "anthropic" | "openai" | "openai-responses" | "gemini";
317  endpoint: string;
318  api_key: string;
319  model_name: string;
320  max_tokens: number;
321  tags: string; // Comma-separated tags (e.g., "slug" for slug generation)
322}
323
324export interface CreateCustomModelRequest {
325  display_name: string;
326  provider_type: "anthropic" | "openai" | "openai-responses" | "gemini";
327  endpoint: string;
328  api_key: string;
329  model_name: string;
330  max_tokens: number;
331  tags: string; // Comma-separated tags
332}
333
334export interface TestCustomModelRequest {
335  model_id?: string; // If provided with empty api_key, use stored key
336  provider_type: "anthropic" | "openai" | "openai-responses" | "gemini";
337  endpoint: string;
338  api_key: string;
339  model_name: string;
340}
341
342class CustomModelsApi {
343  private baseUrl = "/api";
344
345  private postHeaders = {
346    "Content-Type": "application/json",
347    "X-Shelley-Request": "1",
348  };
349
350  async getCustomModels(): Promise<CustomModel[]> {
351    const response = await fetch(`${this.baseUrl}/custom-models`);
352    if (!response.ok) {
353      throw new Error(`Failed to get custom models: ${response.statusText}`);
354    }
355    return response.json();
356  }
357
358  async createCustomModel(request: CreateCustomModelRequest): Promise<CustomModel> {
359    const response = await fetch(`${this.baseUrl}/custom-models`, {
360      method: "POST",
361      headers: this.postHeaders,
362      body: JSON.stringify(request),
363    });
364    if (!response.ok) {
365      throw new Error(`Failed to create custom model: ${response.statusText}`);
366    }
367    return response.json();
368  }
369
370  async updateCustomModel(
371    modelId: string,
372    request: Partial<CreateCustomModelRequest>,
373  ): Promise<CustomModel> {
374    const response = await fetch(`${this.baseUrl}/custom-models/${modelId}`, {
375      method: "PUT",
376      headers: this.postHeaders,
377      body: JSON.stringify(request),
378    });
379    if (!response.ok) {
380      throw new Error(`Failed to update custom model: ${response.statusText}`);
381    }
382    return response.json();
383  }
384
385  async deleteCustomModel(modelId: string): Promise<void> {
386    const response = await fetch(`${this.baseUrl}/custom-models/${modelId}`, {
387      method: "DELETE",
388      headers: { "X-Shelley-Request": "1" },
389    });
390    if (!response.ok) {
391      throw new Error(`Failed to delete custom model: ${response.statusText}`);
392    }
393  }
394
395  async duplicateCustomModel(modelId: string, displayName?: string): Promise<CustomModel> {
396    const response = await fetch(`${this.baseUrl}/custom-models/${modelId}/duplicate`, {
397      method: "POST",
398      headers: this.postHeaders,
399      body: JSON.stringify({ display_name: displayName }),
400    });
401    if (!response.ok) {
402      throw new Error(`Failed to duplicate custom model: ${response.statusText}`);
403    }
404    return response.json();
405  }
406
407  async testCustomModel(
408    request: TestCustomModelRequest,
409  ): Promise<{ success: boolean; message: string }> {
410    const response = await fetch(`${this.baseUrl}/custom-models-test`, {
411      method: "POST",
412      headers: this.postHeaders,
413      body: JSON.stringify(request),
414    });
415    if (!response.ok) {
416      throw new Error(`Failed to test custom model: ${response.statusText}`);
417    }
418    return response.json();
419  }
420}
421
422export const customModelsApi = new CustomModelsApi();