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