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): EventSource {
110    return new EventSource(`${this.baseUrl}/conversation/${conversationId}/stream`);
111  }
112
113  async cancelConversation(conversationId: string): Promise<void> {
114    const response = await fetch(`${this.baseUrl}/conversation/${conversationId}/cancel`, {
115      method: "POST",
116      headers: { "X-Shelley-Request": "1" },
117    });
118    if (!response.ok) {
119      throw new Error(`Failed to cancel conversation: ${response.statusText}`);
120    }
121  }
122
123  async validateCwd(path: string): Promise<{ valid: boolean; error?: string }> {
124    const response = await fetch(`${this.baseUrl}/validate-cwd?path=${encodeURIComponent(path)}`);
125    if (!response.ok) {
126      throw new Error(`Failed to validate cwd: ${response.statusText}`);
127    }
128    return response.json();
129  }
130
131  async listDirectory(path?: string): Promise<{
132    path: string;
133    parent: string;
134    entries: Array<{ name: string; is_dir: boolean; git_head_subject?: string }>;
135    git_head_subject?: string;
136    error?: string;
137  }> {
138    const url = path
139      ? `${this.baseUrl}/list-directory?path=${encodeURIComponent(path)}`
140      : `${this.baseUrl}/list-directory`;
141    const response = await fetch(url);
142    if (!response.ok) {
143      throw new Error(`Failed to list directory: ${response.statusText}`);
144    }
145    return response.json();
146  }
147
148  async createDirectory(path: string): Promise<{ path?: string; error?: string }> {
149    const response = await fetch(`${this.baseUrl}/create-directory`, {
150      method: "POST",
151      headers: this.postHeaders,
152      body: JSON.stringify({ path }),
153    });
154    if (!response.ok) {
155      throw new Error(`Failed to create directory: ${response.statusText}`);
156    }
157    return response.json();
158  }
159
160  async getArchivedConversations(): Promise<Conversation[]> {
161    const response = await fetch(`${this.baseUrl}/conversations/archived`);
162    if (!response.ok) {
163      throw new Error(`Failed to get archived conversations: ${response.statusText}`);
164    }
165    return response.json();
166  }
167
168  async archiveConversation(conversationId: string): Promise<Conversation> {
169    const response = await fetch(`${this.baseUrl}/conversation/${conversationId}/archive`, {
170      method: "POST",
171      headers: { "X-Shelley-Request": "1" },
172    });
173    if (!response.ok) {
174      throw new Error(`Failed to archive conversation: ${response.statusText}`);
175    }
176    return response.json();
177  }
178
179  async unarchiveConversation(conversationId: string): Promise<Conversation> {
180    const response = await fetch(`${this.baseUrl}/conversation/${conversationId}/unarchive`, {
181      method: "POST",
182      headers: { "X-Shelley-Request": "1" },
183    });
184    if (!response.ok) {
185      throw new Error(`Failed to unarchive conversation: ${response.statusText}`);
186    }
187    return response.json();
188  }
189
190  async deleteConversation(conversationId: string): Promise<void> {
191    const response = await fetch(`${this.baseUrl}/conversation/${conversationId}/delete`, {
192      method: "POST",
193      headers: { "X-Shelley-Request": "1" },
194    });
195    if (!response.ok) {
196      throw new Error(`Failed to delete conversation: ${response.statusText}`);
197    }
198  }
199
200  async getConversationBySlug(slug: string): Promise<Conversation | null> {
201    const response = await fetch(
202      `${this.baseUrl}/conversation-by-slug/${encodeURIComponent(slug)}`,
203    );
204    if (response.status === 404) {
205      return null;
206    }
207    if (!response.ok) {
208      throw new Error(`Failed to get conversation by slug: ${response.statusText}`);
209    }
210    return response.json();
211  }
212
213  // Git diff APIs
214  async getGitDiffs(cwd: string): Promise<{ diffs: GitDiffInfo[]; gitRoot: string }> {
215    const response = await fetch(`${this.baseUrl}/git/diffs?cwd=${encodeURIComponent(cwd)}`);
216    if (!response.ok) {
217      const text = await response.text();
218      throw new Error(text || response.statusText);
219    }
220    return response.json();
221  }
222
223  async getGitDiffFiles(diffId: string, cwd: string): Promise<GitFileInfo[]> {
224    const response = await fetch(
225      `${this.baseUrl}/git/diffs/${diffId}/files?cwd=${encodeURIComponent(cwd)}`,
226    );
227    if (!response.ok) {
228      throw new Error(`Failed to get diff files: ${response.statusText}`);
229    }
230    return response.json();
231  }
232
233  async getGitFileDiff(diffId: string, filePath: string, cwd: string): Promise<GitFileDiff> {
234    const response = await fetch(
235      `${this.baseUrl}/git/file-diff/${diffId}/${filePath}?cwd=${encodeURIComponent(cwd)}`,
236    );
237    if (!response.ok) {
238      throw new Error(`Failed to get file diff: ${response.statusText}`);
239    }
240    return response.json();
241  }
242
243  async renameConversation(conversationId: string, slug: string): Promise<Conversation> {
244    const response = await fetch(`${this.baseUrl}/conversation/${conversationId}/rename`, {
245      method: "POST",
246      headers: this.postHeaders,
247      body: JSON.stringify({ slug }),
248    });
249    if (!response.ok) {
250      throw new Error(`Failed to rename conversation: ${response.statusText}`);
251    }
252    return response.json();
253  }
254
255  async getSubagents(conversationId: string): Promise<Conversation[]> {
256    const response = await fetch(`${this.baseUrl}/conversation/${conversationId}/subagents`);
257    if (!response.ok) {
258      throw new Error(`Failed to get subagents: ${response.statusText}`);
259    }
260    return response.json();
261  }
262
263  // Version check APIs
264  async checkVersion(forceRefresh = false): Promise<VersionInfo> {
265    const url = forceRefresh ? "/version-check?refresh=true" : "/version-check";
266    const response = await fetch(url);
267    if (!response.ok) {
268      throw new Error(`Failed to check version: ${response.statusText}`);
269    }
270    return response.json();
271  }
272
273  async getChangelog(currentTag: string, latestTag: string): Promise<CommitInfo[]> {
274    const params = new URLSearchParams({ current: currentTag, latest: latestTag });
275    const response = await fetch(`/version-changelog?${params}`);
276    if (!response.ok) {
277      throw new Error(`Failed to get changelog: ${response.statusText}`);
278    }
279    return response.json();
280  }
281
282  async upgrade(): Promise<{ status: string; message: string }> {
283    const response = await fetch("/upgrade", {
284      method: "POST",
285      headers: { "X-Shelley-Request": "1" },
286    });
287    if (!response.ok) {
288      const text = await response.text();
289      throw new Error(text || response.statusText);
290    }
291    return response.json();
292  }
293
294  async exit(): Promise<{ status: string; message: string }> {
295    const response = await fetch("/exit", {
296      method: "POST",
297      headers: { "X-Shelley-Request": "1" },
298    });
299    if (!response.ok) {
300      throw new Error(`Failed to exit: ${response.statusText}`);
301    }
302    return response.json();
303  }
304}
305
306export const api = new ApiService();
307
308// Custom models API
309export interface CustomModel {
310  model_id: string;
311  display_name: string;
312  provider_type: "anthropic" | "openai" | "openai-responses" | "gemini";
313  endpoint: string;
314  api_key: string;
315  model_name: string;
316  max_tokens: number;
317  tags: string; // Comma-separated tags (e.g., "slug" for slug generation)
318}
319
320export interface CreateCustomModelRequest {
321  display_name: string;
322  provider_type: "anthropic" | "openai" | "openai-responses" | "gemini";
323  endpoint: string;
324  api_key: string;
325  model_name: string;
326  max_tokens: number;
327  tags: string; // Comma-separated tags
328}
329
330export interface TestCustomModelRequest {
331  model_id?: string; // If provided with empty api_key, use stored key
332  provider_type: "anthropic" | "openai" | "openai-responses" | "gemini";
333  endpoint: string;
334  api_key: string;
335  model_name: string;
336}
337
338class CustomModelsApi {
339  private baseUrl = "/api";
340
341  private postHeaders = {
342    "Content-Type": "application/json",
343    "X-Shelley-Request": "1",
344  };
345
346  async getCustomModels(): Promise<CustomModel[]> {
347    const response = await fetch(`${this.baseUrl}/custom-models`);
348    if (!response.ok) {
349      throw new Error(`Failed to get custom models: ${response.statusText}`);
350    }
351    return response.json();
352  }
353
354  async createCustomModel(request: CreateCustomModelRequest): Promise<CustomModel> {
355    const response = await fetch(`${this.baseUrl}/custom-models`, {
356      method: "POST",
357      headers: this.postHeaders,
358      body: JSON.stringify(request),
359    });
360    if (!response.ok) {
361      throw new Error(`Failed to create custom model: ${response.statusText}`);
362    }
363    return response.json();
364  }
365
366  async updateCustomModel(
367    modelId: string,
368    request: Partial<CreateCustomModelRequest>,
369  ): Promise<CustomModel> {
370    const response = await fetch(`${this.baseUrl}/custom-models/${modelId}`, {
371      method: "PUT",
372      headers: this.postHeaders,
373      body: JSON.stringify(request),
374    });
375    if (!response.ok) {
376      throw new Error(`Failed to update custom model: ${response.statusText}`);
377    }
378    return response.json();
379  }
380
381  async deleteCustomModel(modelId: string): Promise<void> {
382    const response = await fetch(`${this.baseUrl}/custom-models/${modelId}`, {
383      method: "DELETE",
384      headers: { "X-Shelley-Request": "1" },
385    });
386    if (!response.ok) {
387      throw new Error(`Failed to delete custom model: ${response.statusText}`);
388    }
389  }
390
391  async duplicateCustomModel(modelId: string, displayName?: string): Promise<CustomModel> {
392    const response = await fetch(`${this.baseUrl}/custom-models/${modelId}/duplicate`, {
393      method: "POST",
394      headers: this.postHeaders,
395      body: JSON.stringify({ display_name: displayName }),
396    });
397    if (!response.ok) {
398      throw new Error(`Failed to duplicate custom model: ${response.statusText}`);
399    }
400    return response.json();
401  }
402
403  async testCustomModel(
404    request: TestCustomModelRequest,
405  ): Promise<{ success: boolean; message: string }> {
406    const response = await fetch(`${this.baseUrl}/custom-models-test`, {
407      method: "POST",
408      headers: this.postHeaders,
409      body: JSON.stringify(request),
410    });
411    if (!response.ok) {
412      throw new Error(`Failed to test custom model: ${response.statusText}`);
413    }
414    return response.json();
415  }
416}
417
418export const customModelsApi = new CustomModelsApi();