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