Merge pull request #3261 from gitbutlerapp/Convert-cloud-api-object-to-class

Convert cloud api object to class
This commit is contained in:
Mattias Granlund 2024-03-21 20:35:59 +01:00 committed by GitHub
commit 41aecc8121
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 199 additions and 256 deletions

View File

@ -1,10 +1,10 @@
import { MessageRole, type AIClient, type PromptMessage } from '$lib/backend/aiClient';
import type { ModelKind } from '$lib/backend/aiService';
import type { getCloudApiClient } from '$lib/backend/cloud';
import type { CloudClient } from '$lib/backend/cloud';
export class ButlerAIClient implements AIClient {
constructor(
private cloud: ReturnType<typeof getCloudApiClient>,
private cloud: CloudClient,
private userToken: string,
private modelKind: ModelKind
) {}
@ -12,7 +12,7 @@ export class ButlerAIClient implements AIClient {
async evaluate(prompt: string) {
const messages: PromptMessage[] = [{ role: MessageRole.User, content: prompt }];
const response = await this.cloud.ai.evaluatePrompt(this.userToken, {
const response = await this.cloud.evaluateAIPrompt(this.userToken, {
messages,
max_tokens: 400,
model_kind: this.modelKind

View File

@ -1,3 +1,4 @@
import { CloudClient } from './cloud';
import { AnthropicAIClient } from '$lib/backend/aiClients/anthropic';
import { ButlerAIClient } from '$lib/backend/aiClients/butler';
import { OpenAIClient } from '$lib/backend/aiClients/openAI';
@ -9,7 +10,6 @@ import {
ModelKind,
OpenAIModelName
} from '$lib/backend/aiService';
import { getCloudApiClient } from '$lib/backend/cloud';
import * as toasts from '$lib/utils/toasts';
import { expect, test, describe, vi } from 'vitest';
import type { AIClient } from '$lib/backend/aiClient';
@ -42,7 +42,7 @@ class DummyGitConfigService implements GitConfigService {
}
const fetchMock = vi.fn();
const cloud = getCloudApiClient({ fetch: fetchMock });
const cloud = new CloudClient(fetchMock);
class DummyAIClient implements AIClient {
constructor(private response = 'lorem ipsum') {}

View File

@ -5,7 +5,7 @@ import { splitMessage } from '$lib/utils/commitMessage';
import * as toasts from '$lib/utils/toasts';
import OpenAI from 'openai';
import type { AIClient } from '$lib/backend/aiClient';
import type { getCloudApiClient } from '$lib/backend/cloud';
import type { CloudClient } from '$lib/backend/cloud';
import type { GitConfigService } from '$lib/backend/gitConfigService';
const diffLengthLimit = 20000;
@ -87,7 +87,7 @@ type SummarizeBranchOpts = {
export class AIService {
constructor(
private gitConfig: GitConfigService,
private cloud: ReturnType<typeof getCloudApiClient>
private cloud: CloudClient
) {}
async validateConfiguration(userToken?: string): Promise<boolean> {

View File

@ -1,8 +1,7 @@
import { isLoading, invoke } from './ipc';
import { nanoid } from 'nanoid';
import { invoke } from './ipc';
import type { PromptMessage } from '$lib/backend/aiClient';
import type { ModelKind } from '$lib/backend/aiService';
import { PUBLIC_API_BASE_URL, PUBLIC_CHAIN_API } from '$env/static/public';
import { PUBLIC_API_BASE_URL } from '$env/static/public';
const apiUrl = new URL('/api/', new URL(PUBLIC_API_BASE_URL));
@ -10,12 +9,6 @@ function getUrl(path: string) {
return new URL(path, apiUrl).toString();
}
const chainApiUrl = new URL(PUBLIC_CHAIN_API);
function getChainUrl(path: string) {
return new URL(path, chainApiUrl).toString();
}
export type Feedback = {
id: number;
user_id: number;
@ -67,40 +60,6 @@ async function parseResponseJSON(response: Response) {
}
}
type FetchMiddleware = (f: typeof fetch) => typeof fetch;
function fetchWith(fetch: typeof window.fetch, ...middlewares: FetchMiddleware[]) {
return middlewares.reduce((f, middleware) => middleware(f), fetch);
}
function withRequestId(fetch: any) {
return async (url: RequestInfo | URL, options: any) => {
const requestId = nanoid();
if (!options) options = {};
options.headers = {
...options?.headers,
'X-Request-Id': requestId
};
const result = fetch(url, options);
return result;
};
}
function withLog(fetch: any) {
return async (url: RequestInfo | URL, options: any) => {
const item = { name: url.toString(), startedAt: new Date() };
try {
isLoading.push(item);
const resp = await fetch(url, options);
return resp;
} catch (e: any) {
console.error('fetch', e);
throw e;
} finally {
isLoading.pop(item);
}
};
}
interface EvaluatePromptParams {
messages: PromptMessage[];
temperature?: number;
@ -108,188 +67,175 @@ interface EvaluatePromptParams {
model_kind?: ModelKind;
}
export function getCloudApiClient(
{ fetch: realFetch }: { fetch: typeof window.fetch } = {
fetch: window.fetch
export class CloudClient {
fetch: ((input: RequestInfo | URL, init?: RequestInit | undefined) => Promise<Response>) &
((input: RequestInfo | URL, init?: RequestInit | undefined) => Promise<Response>);
constructor(realFetch: typeof window.fetch = window.fetch) {
this.fetch = realFetch;
}
) {
const fetch = fetchWith(realFetch, withRequestId, withLog);
return {
login: {
token: {
create: (): Promise<LoginToken> =>
fetch(getUrl('login/token.json'), {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({})
})
.then(parseResponseJSON)
.then((token) => {
const url = new URL(token.url);
url.host = apiUrl.host;
return {
...token,
url: url.toString()
};
})
async createLoginToken(): Promise<LoginToken> {
const response = await this.fetch(getUrl('login/token.json'), {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
user: {
get: (token: string): Promise<User> =>
fetch(getUrl(`login/user/${token}.json`), {
method: 'GET'
}).then(parseResponseJSON)
}
},
feedback: {
create: async (
token: string | undefined,
params: {
email?: string;
message: string;
context?: string;
logs?: Blob | File;
data?: Blob | File;
repo?: Blob | File;
}
): Promise<Feedback> => {
const formData = new FormData();
formData.append('message', params.message);
if (params.email) formData.append('email', params.email);
if (params.context) formData.append('context', params.context);
if (params.logs) formData.append('logs', params.logs);
if (params.repo) formData.append('repo', params.repo);
if (params.data) formData.append('data', params.data);
const headers: HeadersInit = token ? { 'X-Auth-Token': token } : {};
return fetch(getUrl(`feedback`), {
method: 'PUT',
headers,
body: formData
}).then(parseResponseJSON);
}
},
user: {
get: (token: string): Promise<User> =>
fetch(getUrl(`user.json`), {
method: 'GET',
headers: {
'X-Auth-Token': token
}
}).then(parseResponseJSON),
update: async (token: string, params: { name?: string; picture?: File }) => {
const formData = new FormData();
if (params.name) {
formData.append('name', params.name);
}
if (params.picture) {
formData.append('avatar', params.picture);
}
return fetch(getUrl(`user.json`), {
method: 'PUT',
headers: {
'X-Auth-Token': token
},
body: formData
}).then(parseResponseJSON);
}
},
ai: {
evaluatePrompt: (token: string, params: EvaluatePromptParams): Promise<{ message: string }> =>
fetch(getUrl('evaluate_prompt/predict.json'), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Auth-Token': token
},
body: JSON.stringify(params)
}).then(parseResponseJSON)
},
chat: {
new: (token: string, repositoryId: string): Promise<{ id: string }> =>
fetch(getChainUrl(`${repositoryId}/chat`), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Auth-Token': token
}
}).then(parseResponseJSON),
history: (
token: string,
repositoryId: string,
chatId: string
): Promise<{ history: []; sequence: number }> =>
fetch(getChainUrl(`${repositoryId}/chat/${chatId}`), {
method: 'GET',
headers: {
'Content-Type': 'application/json',
'X-Auth-Token': token
}
}).then(parseResponseJSON),
newMessage: (
token: string,
repositoryId: string,
chatId: string,
message: string
): Promise<{ sequence: number }> =>
fetch(getChainUrl(`${repositoryId}/chat/${chatId}`), {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
'X-Auth-Token': token
},
body: JSON.stringify({ text: message })
}).then(parseResponseJSON)
},
projects: {
create: (
token: string,
params: { name: string; description?: string; uid?: string }
): Promise<Project> =>
fetch(getUrl('projects.json'), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Auth-Token': token
},
body: JSON.stringify(params)
}).then(parseResponseJSON),
update: (
token: string,
repositoryId: string,
params: { name: string; description?: string }
): Promise<Project> =>
fetch(getUrl(`projects/${repositoryId}.json`), {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'X-Auth-Token': token
},
body: JSON.stringify(params)
}).then(parseResponseJSON),
list: (token: string): Promise<Project[]> =>
fetch(getUrl('projects.json'), {
method: 'GET',
headers: {
'X-Auth-Token': token
}
}).then(parseResponseJSON),
get: (token: string, repositoryId: string): Promise<Project> =>
fetch(getUrl(`projects/${repositoryId}.json`), {
method: 'GET',
headers: {
'X-Auth-Token': token
}
}).then(parseResponseJSON),
delete: (token: string, repositoryId: string): Promise<void> =>
fetch(getUrl(`projects/${repositoryId}.json`), {
method: 'DELETE',
headers: {
'X-Auth-Token': token
}
}).then(parseResponseJSON)
body: JSON.stringify({})
});
const token = await parseResponseJSON(response);
const url = new URL(token.url);
url.host = apiUrl.host;
return {
...token,
url: url.toString()
};
}
async getLoginUser(token: string): Promise<User> {
const response = await this.fetch(getUrl(`login/user/${token}.json`), {
method: 'GET'
});
return parseResponseJSON(response);
}
async createFeedback(
token: string | undefined,
params: {
email?: string;
message: string;
context?: string;
logs?: Blob | File;
data?: Blob | File;
repo?: Blob | File;
}
};
): Promise<Feedback> {
const formData = new FormData();
formData.append('message', params.message);
if (params.email) formData.append('email', params.email);
if (params.context) formData.append('context', params.context);
if (params.logs) formData.append('logs', params.logs);
if (params.repo) formData.append('repo', params.repo);
if (params.data) formData.append('data', params.data);
const headers: HeadersInit = token ? { 'X-Auth-Token': token } : {};
const response = await this.fetch(getUrl(`feedback`), {
method: 'PUT',
headers,
body: formData
});
return parseResponseJSON(response);
}
async getUser(token: string): Promise<User> {
const response = await this.fetch(getUrl(`user.json`), {
method: 'GET',
headers: {
'X-Auth-Token': token
}
});
return parseResponseJSON(response);
}
async updateUser(token: string, params: { name?: string; picture?: File }): Promise<any> {
const formData = new FormData();
if (params.name) {
formData.append('name', params.name);
}
if (params.picture) {
formData.append('avatar', params.picture);
}
const response = await this.fetch(getUrl(`user.json`), {
method: 'PUT',
headers: {
'X-Auth-Token': token
},
body: formData
});
return parseResponseJSON(response);
}
async evaluateAIPrompt(
token: string,
params: EvaluatePromptParams
): Promise<{ message: string }> {
const response = await this.fetch(getUrl('evaluate_prompt/predict.json'), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Auth-Token': token
},
body: JSON.stringify(params)
});
return parseResponseJSON(response);
}
async createProject(
token: string,
params: {
name: string;
description?: string;
uid?: string;
}
): Promise<Project> {
const response = await this.fetch(getUrl('projects.json'), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Auth-Token': token
},
body: JSON.stringify(params)
});
return parseResponseJSON(response);
}
async updateProject(
token: string,
repositoryId: string,
params: {
name: string;
description?: string;
}
): Promise<Project> {
const response = await this.fetch(getUrl(`projects/${repositoryId}.json`), {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'X-Auth-Token': token
},
body: JSON.stringify(params)
});
return parseResponseJSON(response);
}
async listProjects(token: string): Promise<Project[]> {
const response = await this.fetch(getUrl('projects.json'), {
method: 'GET',
headers: {
'X-Auth-Token': token
}
});
return parseResponseJSON(response);
}
async getProject(token: string, repositoryId: string): Promise<Project> {
const response = await this.fetch(getUrl(`projects/${repositoryId}.json`), {
method: 'GET',
headers: {
'X-Auth-Token': token
}
});
return parseResponseJSON(response);
}
async deleteProject(token: string, repositoryId: string): Promise<void> {
const response = await this.fetch(getUrl(`projects/${repositoryId}.json`), {
method: 'DELETE',
headers: {
'X-Auth-Token': token
}
});
return parseResponseJSON(response);
}
}
export async function syncToCloud(projectId: string | undefined) {

View File

@ -1,7 +1,7 @@
<script lang="ts">
import SectionCard from './SectionCard.svelte';
import WelcomeSigninAction from './WelcomeSigninAction.svelte';
import { getCloudApiClient } from '$lib/backend/cloud';
import { CloudClient } from '$lib/backend/cloud';
import Link from '$lib/components/Link.svelte';
import Spacer from '$lib/components/Spacer.svelte';
import Toggle from '$lib/components/Toggle.svelte';
@ -17,9 +17,9 @@
export let project: Project;
const userService = getContextByClass(UserService);
const cloud = getContextByClass(CloudClient);
const user = userService.user;
const cloud = getCloudApiClient();
const aiGenEnabled = projectAiGenEnabled(project.id);
const aiGenAutoBranchNamingEnabled = projectAiGenAutoBranchNamingEnabled(project.id);
@ -30,7 +30,7 @@
onMount(async () => {
if (!project?.api) return;
if (!$user) return;
const cloudProject = await cloud.projects.get($user.access_token, project.api.repository_id);
const cloudProject = await cloud.getProject($user.access_token, project.api.repository_id);
if (cloudProject === project.api) return;
dispatch('updated', { ...project, api: { ...cloudProject, sync: project.api.sync } });
});
@ -40,7 +40,7 @@
try {
const cloudProject =
project.api ??
(await cloud.projects.create($user.access_token, {
(await cloud.createProject($user.access_token, {
name: project.title,
description: project.description,
uid: project.id

View File

@ -1,6 +1,6 @@
<script lang="ts">
import Button from './Button.svelte';
import { getCloudApiClient, type LoginToken } from '$lib/backend/cloud';
import { CloudClient, type LoginToken } from '$lib/backend/cloud';
import { UserService } from '$lib/stores/user';
import { getContextByClass } from '$lib/utils/context';
import * as toasts from '$lib/utils/toasts';
@ -8,7 +8,7 @@
import { createEventDispatcher } from 'svelte';
import { derived, writable } from 'svelte/store';
const cloud = getCloudApiClient();
const cloud = getContextByClass(CloudClient);
const userService = getContextByClass(UserService);
const user = userService.user;
@ -21,7 +21,7 @@
let signUpOrLoginLoading = false;
async function pollForUser(token: string) {
const apiUser = await cloud.login.user.get(token).catch(() => null);
const apiUser = await cloud.getLoginUser(token).catch(() => null);
if (apiUser) {
userService.setUser(apiUser);
return;
@ -36,7 +36,7 @@
async function onSignUpOrLoginClick() {
signUpOrLoginLoading = true;
try {
token.set(await cloud.login.token.create());
token.set(await cloud.createLoginToken());
} catch (err: any) {
console.error(err);
toasts.error('Could not create login token');

View File

@ -1,5 +1,6 @@
<script lang="ts">
import TextArea from './TextArea.svelte';
import { CloudClient } from '$lib/backend/cloud';
import { invoke } from '$lib/backend/ipc';
import * as zip from '$lib/backend/zip';
import Button from '$lib/components/Button.svelte';
@ -9,11 +10,9 @@
import { getContextByClass } from '$lib/utils/context';
import * as toasts from '$lib/utils/toasts';
import { getVersion } from '@tauri-apps/api/app';
import type { getCloudApiClient } from '$lib/backend/cloud';
import { page } from '$app/stores';
export let cloud: ReturnType<typeof getCloudApiClient>;
const cloud = getContextByClass(CloudClient);
const userService = getContextByClass(UserService);
const user = userService.user;
@ -76,7 +75,7 @@
? zip.projectData({ projectId }).then((path) => readZipFile(path, 'project.zip'))
: undefined
]).then(async ([logs, data, repo]) =>
cloud.feedback.create($user?.access_token, {
cloud.createFeedback($user?.access_token, {
email,
message,
context,

View File

@ -1,16 +1,14 @@
import { resetPostHog, setPostHogUser } from '$lib/analytics/posthog';
import { resetSentry, setSentryUser } from '$lib/analytics/sentry';
import { getCloudApiClient, type User } from '$lib/backend/cloud';
import { invoke } from '$lib/backend/ipc';
import { observableToStore } from '$lib/rxjs/store';
import { sleep } from '$lib/utils/sleep';
import { openExternalUrl } from '$lib/utils/url';
import { BehaviorSubject, Observable, Subject, distinct, map, merge, shareReplay } from 'rxjs';
import type { CloudClient, User } from '$lib/backend/cloud';
import type { Readable } from 'svelte/motion';
export class UserService {
private readonly cloud = getCloudApiClient();
readonly reset$ = new Subject<User | undefined>();
readonly loading$ = new BehaviorSubject(false);
@ -35,7 +33,7 @@ export class UserService {
user: Readable<User | undefined>;
error: Readable<string | undefined>;
constructor() {
constructor(private cloud: CloudClient) {
[this.user, this.error] = observableToStore(this.user$);
}
@ -60,7 +58,7 @@ export class UserService {
this.logout();
this.loading$.next(true);
try {
const token = await this.cloud.login.token.create();
const token = await this.cloud.createLoginToken();
openExternalUrl(token.url);
// Assumed min time for login flow
@ -78,7 +76,7 @@ export class UserService {
private async pollForUser(token: string): Promise<User | undefined> {
let apiUser: User | null;
for (let i = 0; i < 120; i++) {
apiUser = await this.cloud.login.user.get(token).catch(() => null);
apiUser = await this.cloud.getLoginUser(token).catch(() => null);
if (apiUser) {
this.setUser(apiUser);
return apiUser;

View File

@ -3,6 +3,7 @@
import { AIService } from '$lib/backend/aiService';
import { AuthService } from '$lib/backend/auth';
import { CloudClient } from '$lib/backend/cloud';
import { GitConfigService } from '$lib/backend/gitConfigService';
import { ProjectService } from '$lib/backend/projects';
import { PromptService } from '$lib/backend/prompt';
@ -24,7 +25,6 @@
import { goto } from '$app/navigation';
export let data: LayoutData;
$: ({ cloud } = data);
const userSettings = loadUserSettings();
initTheme(userSettings);
@ -38,6 +38,7 @@
$: setContext(AIService, data.aiService);
$: setContext(PromptService, data.promptService);
$: setContext(AuthService, data.authService);
$: setContext(CloudClient, data.cloud);
let shareIssueModal: ShareIssueModal;
@ -68,7 +69,7 @@
<slot />
</div>
<Toaster />
<ShareIssueModal bind:this={shareIssueModal} {cloud} />
<ShareIssueModal bind:this={shareIssueModal} />
<ToastController />
<AppUpdater />
<PromptModal />

View File

@ -2,7 +2,7 @@ import { initPostHog } from '$lib/analytics/posthog';
import { initSentry } from '$lib/analytics/sentry';
import { AIService } from '$lib/backend/aiService';
import { AuthService } from '$lib/backend/auth';
import { getCloudApiClient } from '$lib/backend/cloud';
import { CloudClient } from '$lib/backend/cloud';
import { GitConfigService } from '$lib/backend/gitConfigService';
import { ProjectService } from '$lib/backend/projects';
import { PromptService } from '$lib/backend/prompt';
@ -39,11 +39,12 @@ export async function load({ fetch: realFetch }: { fetch: typeof fetch }) {
// https://github.com/sveltejs/kit/issues/905
const defaultPath = await (await import('@tauri-apps/api/path')).homeDir();
const cloud = new CloudClient(realFetch);
const authService = new AuthService();
const projectService = new ProjectService(defaultPath);
const updaterService = new UpdaterService();
const promptService = new PromptService();
const userService = new UserService();
const userService = new UserService(cloud);
const user$ = userService.user$;
// We're declaring a remoteUrl$ observable here that is written to by `BaseBranchService`. This
@ -55,8 +56,6 @@ export async function load({ fetch: realFetch }: { fetch: typeof fetch }) {
const remoteUrl$ = new BehaviorSubject<string | undefined>(undefined);
const githubService = new GitHubService(userService.accessToken$, remoteUrl$);
const cloud = getCloudApiClient({ fetch: realFetch });
const gitConfig = new GitConfigService();
const aiService = new AIService(gitConfig, cloud);

View File

@ -57,7 +57,7 @@
async function onDetailsUpdated(e: { detail: Project }) {
const api =
$user && e.detail.api
? await cloud.projects.update($user?.access_token, e.detail.api.repository_id, {
? await cloud.updateProject($user?.access_token, e.detail.api.repository_id, {
name: e.detail.title,
description: e.detail.description
})

View File

@ -60,7 +60,7 @@
$: if ($user && !loaded) {
loaded = true;
cloud.user.get($user?.access_token).then((cloudUser) => {
cloud.getUser($user?.access_token).then((cloudUser) => {
cloudUser.github_access_token = $user?.github_access_token; // prevent overwriting with null
userService.setUser(cloudUser);
});
@ -88,7 +88,7 @@
const picture = formData.get('picture') as File | undefined;
try {
const updatedUser = await cloud.user.update($user.access_token, {
const updatedUser = await cloud.updateUser($user.access_token, {
name: newName,
picture: picture
});