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();