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