@akanjs/devkit 2.1.1-rc.1 → 2.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/aiEditor.test.ts +68 -0
- package/aiEditor.ts +183 -57
- package/cloud/cloudApi.ts +83 -50
- package/cloud/constants.ts +48 -0
- package/cloud/globalConfig.ts +109 -0
- package/cloud/index.ts +2 -0
- package/executors.ts +748 -164
- package/index.ts +2 -3
- package/linter.ts +308 -97
- package/package.json +2 -2
- package/prompter.ts +17 -4
- package/typecheck/typecheck.proc.ts +21 -0
- package/auth.ts +0 -41
- package/constants.ts +0 -32
package/aiEditor.test.ts
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
parseTypescriptFileBlocks,
|
|
4
|
+
preserveTypescriptResponseContent,
|
|
5
|
+
} from "./aiEditor";
|
|
6
|
+
|
|
7
|
+
describe("parseTypescriptFileBlocks", () => {
|
|
8
|
+
test("parses TypeScript file blocks with common fence variants", () => {
|
|
9
|
+
const writes = parseTypescriptFileBlocks(`
|
|
10
|
+
\`\`\`ts
|
|
11
|
+
|
|
12
|
+
// File: lib/car/car.constant.ts
|
|
13
|
+
|
|
14
|
+
export const car = "car";
|
|
15
|
+
\`\`\`
|
|
16
|
+
|
|
17
|
+
\`\`\`tsx
|
|
18
|
+
// File: lib/car/Car.Unit.tsx
|
|
19
|
+
export const CarUnit = () => null;
|
|
20
|
+
\`\`\`
|
|
21
|
+
`);
|
|
22
|
+
|
|
23
|
+
expect(writes).toEqual([
|
|
24
|
+
{
|
|
25
|
+
filePath: "lib/car/car.constant.ts",
|
|
26
|
+
content: 'export const car = "car";',
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
filePath: "lib/car/Car.Unit.tsx",
|
|
30
|
+
content: "export const CarUnit = () => null;",
|
|
31
|
+
},
|
|
32
|
+
]);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("keeps previous code response when validation responds with prose only", () => {
|
|
36
|
+
const previousContent = `
|
|
37
|
+
\`\`\`typescript
|
|
38
|
+
// File: lib/car/car.constant.ts
|
|
39
|
+
export const car = "car";
|
|
40
|
+
\`\`\`
|
|
41
|
+
`;
|
|
42
|
+
const nextContent =
|
|
43
|
+
"The generated file meets all specified requirements. No rewrite is necessary.";
|
|
44
|
+
|
|
45
|
+
expect(
|
|
46
|
+
preserveTypescriptResponseContent(previousContent, nextContent),
|
|
47
|
+
).toBe(previousContent);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("uses next code response when validation rewrites with parseable files", () => {
|
|
51
|
+
const previousContent = `
|
|
52
|
+
\`\`\`typescript
|
|
53
|
+
// File: lib/car/car.constant.ts
|
|
54
|
+
export const car = "car";
|
|
55
|
+
\`\`\`
|
|
56
|
+
`;
|
|
57
|
+
const nextContent = `
|
|
58
|
+
\`\`\`typescript
|
|
59
|
+
// File: lib/car/car.constant.ts
|
|
60
|
+
export const car = "updated";
|
|
61
|
+
\`\`\`
|
|
62
|
+
`;
|
|
63
|
+
|
|
64
|
+
expect(
|
|
65
|
+
preserveTypescriptResponseContent(previousContent, nextContent),
|
|
66
|
+
).toBe(nextContent);
|
|
67
|
+
});
|
|
68
|
+
});
|
package/aiEditor.ts
CHANGED
|
@@ -12,14 +12,17 @@ import { ChatOpenAI } from "@langchain/openai";
|
|
|
12
12
|
import { Logger } from "akanjs/common";
|
|
13
13
|
import chalk from "chalk";
|
|
14
14
|
|
|
15
|
-
import {
|
|
15
|
+
import { GlobalConfig } from "./cloud";
|
|
16
16
|
import type { Executor, WorkspaceExecutor } from "./executors";
|
|
17
17
|
import { Spinner } from "./spinner";
|
|
18
18
|
import type { FileContent } from "./types";
|
|
19
19
|
|
|
20
20
|
const MAX_ASK_TRY = 300;
|
|
21
21
|
|
|
22
|
-
export const supportedLlmModels = [
|
|
22
|
+
export const supportedLlmModels = [
|
|
23
|
+
"deepseek-chat",
|
|
24
|
+
"deepseek-reasoner",
|
|
25
|
+
] as const;
|
|
23
26
|
export type SupportedLlmModel = (typeof supportedLlmModels)[number];
|
|
24
27
|
|
|
25
28
|
interface EditOptions {
|
|
@@ -28,20 +31,68 @@ interface EditOptions {
|
|
|
28
31
|
maxTry?: number;
|
|
29
32
|
validate?: string[];
|
|
30
33
|
approve?: boolean;
|
|
34
|
+
fallbackToPreviousTypescript?: boolean;
|
|
31
35
|
}
|
|
32
36
|
|
|
37
|
+
export const parseTypescriptFileBlocks = (text: string): FileContent[] => {
|
|
38
|
+
const fileBlocks: FileContent[] = [];
|
|
39
|
+
const codeBlockRegex = /```(?:typescript|ts|tsx)\s*\n([\s\S]*?)```/gi;
|
|
40
|
+
const filePathRegex = /^\s*\/\/\s*File:\s*(.+?)\s*$/im;
|
|
41
|
+
|
|
42
|
+
for (const codeBlock of text.matchAll(codeBlockRegex)) {
|
|
43
|
+
const content = codeBlock[1]?.trim();
|
|
44
|
+
if (!content) continue;
|
|
45
|
+
|
|
46
|
+
const filePath = filePathRegex.exec(content)?.[1]?.trim();
|
|
47
|
+
if (!filePath) continue;
|
|
48
|
+
|
|
49
|
+
fileBlocks.push({
|
|
50
|
+
filePath,
|
|
51
|
+
content: content.replace(filePathRegex, "").trim(),
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return fileBlocks;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
export const preserveTypescriptResponseContent = (
|
|
59
|
+
previousContent: string,
|
|
60
|
+
nextContent: string,
|
|
61
|
+
) => {
|
|
62
|
+
const previousWrites = parseTypescriptFileBlocks(previousContent);
|
|
63
|
+
const nextWrites = parseTypescriptFileBlocks(nextContent);
|
|
64
|
+
if (previousWrites.length > 0 && nextWrites.length === 0)
|
|
65
|
+
return previousContent;
|
|
66
|
+
return nextContent;
|
|
67
|
+
};
|
|
68
|
+
|
|
33
69
|
export class AiSession {
|
|
34
70
|
static #cacheDir = "node_modules/.cache/akan/aiSession";
|
|
35
71
|
static #chat: ChatDeepSeek | ChatOpenAI | null = null;
|
|
36
|
-
static async init({
|
|
72
|
+
static async init({
|
|
73
|
+
temperature = 0,
|
|
74
|
+
useExisting = true,
|
|
75
|
+
}: {
|
|
76
|
+
temperature?: number;
|
|
77
|
+
useExisting?: boolean;
|
|
78
|
+
} = {}) {
|
|
37
79
|
if (useExisting) {
|
|
38
80
|
const llmConfig = await AiSession.getLlmConfig();
|
|
39
81
|
if (llmConfig) {
|
|
40
82
|
AiSession.#setChatModel(llmConfig.model, llmConfig.apiKey);
|
|
41
|
-
Logger.rawLog(
|
|
83
|
+
Logger.rawLog(
|
|
84
|
+
chalk.dim(
|
|
85
|
+
`🤖akan editor uses existing LLM config (${llmConfig.model})`,
|
|
86
|
+
),
|
|
87
|
+
);
|
|
42
88
|
return AiSession;
|
|
43
89
|
}
|
|
44
|
-
} else
|
|
90
|
+
} else
|
|
91
|
+
Logger.rawLog(
|
|
92
|
+
chalk.yellow(
|
|
93
|
+
"🤖akan-editor is not initialized. LLM configuration should be set first.",
|
|
94
|
+
),
|
|
95
|
+
);
|
|
45
96
|
|
|
46
97
|
const llmConfig = await AiSession.#requestLlmConfig();
|
|
47
98
|
const { model, apiKey } = llmConfig;
|
|
@@ -51,7 +102,11 @@ export class AiSession {
|
|
|
51
102
|
await session.setLlmConfig({ model, apiKey });
|
|
52
103
|
return session;
|
|
53
104
|
}
|
|
54
|
-
static #setChatModel(
|
|
105
|
+
static #setChatModel(
|
|
106
|
+
model: SupportedLlmModel,
|
|
107
|
+
apiKey: string,
|
|
108
|
+
{ temperature = 0 }: { temperature?: number } = {},
|
|
109
|
+
) {
|
|
55
110
|
AiSession.#chat = new ChatDeepSeek({
|
|
56
111
|
modelName: model,
|
|
57
112
|
temperature,
|
|
@@ -62,22 +117,26 @@ export class AiSession {
|
|
|
62
117
|
return AiSession;
|
|
63
118
|
}
|
|
64
119
|
static async getLlmConfig() {
|
|
65
|
-
|
|
66
|
-
return akanConfig.llm ?? null;
|
|
120
|
+
return await GlobalConfig.getLlmConfig();
|
|
67
121
|
}
|
|
68
|
-
static async setLlmConfig(
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
await
|
|
122
|
+
static async setLlmConfig(
|
|
123
|
+
llmConfig: { model: SupportedLlmModel; apiKey: string } | null,
|
|
124
|
+
) {
|
|
125
|
+
await GlobalConfig.setLlmConfig(llmConfig);
|
|
72
126
|
return AiSession;
|
|
73
127
|
}
|
|
74
128
|
static async #requestLlmConfig() {
|
|
75
|
-
const model = await select<SupportedLlmModel>({
|
|
129
|
+
const model = await select<SupportedLlmModel>({
|
|
130
|
+
message: "Select a LLM model",
|
|
131
|
+
choices: supportedLlmModels,
|
|
132
|
+
});
|
|
76
133
|
const apiKey = await input({ message: "Enter your API key" });
|
|
77
134
|
return { model, apiKey };
|
|
78
135
|
}
|
|
79
136
|
static async #validateApiKey(modelName: SupportedLlmModel, apiKey: string) {
|
|
80
|
-
const spinner = new Spinner("Validating LLM API key...", {
|
|
137
|
+
const spinner = new Spinner("Validating LLM API key...", {
|
|
138
|
+
prefix: `🤖akan-editor`,
|
|
139
|
+
}).start();
|
|
81
140
|
const chat = new ChatOpenAI({
|
|
82
141
|
modelName,
|
|
83
142
|
temperature: 0,
|
|
@@ -107,7 +166,15 @@ export class AiSession {
|
|
|
107
166
|
workspace: WorkspaceExecutor;
|
|
108
167
|
constructor(
|
|
109
168
|
type: string,
|
|
110
|
-
{
|
|
169
|
+
{
|
|
170
|
+
workspace,
|
|
171
|
+
cacheKey,
|
|
172
|
+
isContinued,
|
|
173
|
+
}: {
|
|
174
|
+
workspace: WorkspaceExecutor;
|
|
175
|
+
cacheKey?: string;
|
|
176
|
+
isContinued?: boolean;
|
|
177
|
+
},
|
|
111
178
|
) {
|
|
112
179
|
this.workspace = workspace;
|
|
113
180
|
this.sessionKey = `${type}${cacheKey ? `-${cacheKey}` : ""}`;
|
|
@@ -126,7 +193,10 @@ export class AiSession {
|
|
|
126
193
|
}
|
|
127
194
|
async #saveCache() {
|
|
128
195
|
const cacheFilePath = `${AiSession.#cacheDir}/${this.sessionKey}.json`;
|
|
129
|
-
await this.workspace.writeJson(
|
|
196
|
+
await this.workspace.writeJson(
|
|
197
|
+
cacheFilePath,
|
|
198
|
+
mapChatMessagesToStoredMessages(this.messageHistory),
|
|
199
|
+
);
|
|
130
200
|
}
|
|
131
201
|
async ask(
|
|
132
202
|
question: string,
|
|
@@ -142,7 +212,8 @@ export class AiSession {
|
|
|
142
212
|
if (!AiSession.#chat) await AiSession.init();
|
|
143
213
|
if (this.#cacheLoadPromise) await this.#cacheLoadPromise;
|
|
144
214
|
|
|
145
|
-
if (!AiSession.#chat)
|
|
215
|
+
if (!AiSession.#chat)
|
|
216
|
+
throw new Error("Failed to initialize the AI session");
|
|
146
217
|
const loader = new Spinner(`${AiSession.#chat.model} is thinking...`, {
|
|
147
218
|
prefix: `🤖akan-editor`,
|
|
148
219
|
}).start();
|
|
@@ -151,13 +222,15 @@ export class AiSession {
|
|
|
151
222
|
this.messageHistory.push(humanMessage);
|
|
152
223
|
const stream = await AiSession.#chat.stream(this.messageHistory);
|
|
153
224
|
let reasoningResponse = "",
|
|
154
|
-
fullResponse = ""
|
|
155
|
-
tokenIdx = 0;
|
|
225
|
+
fullResponse = "";
|
|
156
226
|
for await (const chunk of stream) {
|
|
157
|
-
if (loader.isSpinning())
|
|
227
|
+
if (loader.isSpinning())
|
|
228
|
+
loader.succeed(`${AiSession.#chat.model} responded`);
|
|
158
229
|
|
|
159
230
|
if (!fullResponse.length) {
|
|
160
|
-
const reasoningContent =
|
|
231
|
+
const reasoningContent =
|
|
232
|
+
(chunk.additional_kwargs as { reasoning_content?: string })
|
|
233
|
+
.reasoning_content ?? "";
|
|
161
234
|
if (reasoningContent.length) {
|
|
162
235
|
reasoningResponse += reasoningContent;
|
|
163
236
|
onReasoning(reasoningContent);
|
|
@@ -173,24 +246,45 @@ export class AiSession {
|
|
|
173
246
|
fullResponse += content;
|
|
174
247
|
onChunk(content); // Send individual chunks to callback
|
|
175
248
|
}
|
|
176
|
-
tokenIdx++;
|
|
177
249
|
}
|
|
178
250
|
fullResponse += "\n";
|
|
179
251
|
onChunk("\n");
|
|
180
252
|
this.messageHistory.push(new AIMessage(fullResponse));
|
|
181
253
|
return { content: fullResponse, messageHistory: this.messageHistory };
|
|
182
|
-
} catch
|
|
254
|
+
} catch {
|
|
183
255
|
loader.fail(`${AiSession.#chat.model} failed to respond`);
|
|
184
256
|
throw new Error("Failed to stream response");
|
|
185
257
|
}
|
|
186
258
|
}
|
|
187
|
-
async edit(
|
|
259
|
+
async edit(
|
|
260
|
+
question: string,
|
|
261
|
+
{
|
|
262
|
+
onChunk,
|
|
263
|
+
onReasoning,
|
|
264
|
+
maxTry = MAX_ASK_TRY,
|
|
265
|
+
validate,
|
|
266
|
+
approve,
|
|
267
|
+
fallbackToPreviousTypescript,
|
|
268
|
+
}: EditOptions = {},
|
|
269
|
+
) {
|
|
188
270
|
for (let tryCount = 0; tryCount < maxTry; tryCount++) {
|
|
189
271
|
let response = await this.ask(question, { onChunk, onReasoning });
|
|
190
272
|
if (validate?.length && tryCount === 0) {
|
|
191
273
|
const validateQuestion = `Double check if the response meets the requirements and conditions, and follow the instructions. If not, rewrite it.
|
|
192
274
|
${validate.map((v) => `- ${v}`).join("\n")}`;
|
|
193
|
-
|
|
275
|
+
const validateResponse = await this.ask(validateQuestion, {
|
|
276
|
+
onChunk,
|
|
277
|
+
onReasoning,
|
|
278
|
+
});
|
|
279
|
+
response = {
|
|
280
|
+
...validateResponse,
|
|
281
|
+
content: fallbackToPreviousTypescript
|
|
282
|
+
? preserveTypescriptResponseContent(
|
|
283
|
+
response.content,
|
|
284
|
+
validateResponse.content,
|
|
285
|
+
)
|
|
286
|
+
: validateResponse.content,
|
|
287
|
+
};
|
|
194
288
|
}
|
|
195
289
|
const isConfirmed = approve
|
|
196
290
|
? true
|
|
@@ -226,35 +320,72 @@ ${validate.map((v) => `- ${v}`).join("\n")}`;
|
|
|
226
320
|
// const toolMessages = messages.map(
|
|
227
321
|
// (message) => new ToolMessage({ content: message.content, tool_call_id: message.type })
|
|
228
322
|
// );
|
|
229
|
-
const toolMessages = messages.map(
|
|
323
|
+
const toolMessages = messages.map(
|
|
324
|
+
(message) => new HumanMessage(message.content),
|
|
325
|
+
);
|
|
230
326
|
this.messageHistory.push(...toolMessages);
|
|
231
327
|
return this;
|
|
232
328
|
}
|
|
233
|
-
async writeTypescripts(
|
|
234
|
-
|
|
329
|
+
async writeTypescripts(
|
|
330
|
+
question: string,
|
|
331
|
+
executor: Executor,
|
|
332
|
+
options: EditOptions = {},
|
|
333
|
+
) {
|
|
334
|
+
const content = await this.edit(question, {
|
|
335
|
+
...options,
|
|
336
|
+
fallbackToPreviousTypescript: true,
|
|
337
|
+
});
|
|
235
338
|
const writes = this.#getTypescriptCodes(content);
|
|
236
|
-
|
|
339
|
+
if (!writes.length)
|
|
340
|
+
throw new Error(
|
|
341
|
+
"No parseable TypeScript file blocks were found in the AI response. Include `// File: <path>` in each code block.",
|
|
342
|
+
);
|
|
343
|
+
for (const write of writes)
|
|
344
|
+
await executor.writeFile(write.filePath, write.content);
|
|
237
345
|
return await this.#tryFixTypescripts(writes, executor, options);
|
|
238
346
|
}
|
|
239
|
-
async #editTypescripts(
|
|
240
|
-
|
|
241
|
-
|
|
347
|
+
async #editTypescripts(
|
|
348
|
+
question: string,
|
|
349
|
+
options: EditOptions = {},
|
|
350
|
+
fallbackWrites?: FileContent[],
|
|
351
|
+
) {
|
|
352
|
+
const content = await this.edit(question, {
|
|
353
|
+
...options,
|
|
354
|
+
fallbackToPreviousTypescript: true,
|
|
355
|
+
});
|
|
356
|
+
const writes = this.#getTypescriptCodes(content);
|
|
357
|
+
if (!writes.length && fallbackWrites?.length) return fallbackWrites;
|
|
358
|
+
if (!writes.length)
|
|
359
|
+
throw new Error(
|
|
360
|
+
"No parseable TypeScript file blocks were found in the AI response. Include `// File: <path>` in each code block.",
|
|
361
|
+
);
|
|
362
|
+
return writes;
|
|
242
363
|
}
|
|
243
|
-
async #tryFixTypescripts(
|
|
364
|
+
async #tryFixTypescripts(
|
|
365
|
+
writes: FileContent[],
|
|
366
|
+
executor: Executor,
|
|
367
|
+
options: EditOptions = {},
|
|
368
|
+
) {
|
|
244
369
|
const MAX_EDIT_TRY = 5;
|
|
245
370
|
for (let tryCount = 0; tryCount < MAX_EDIT_TRY; tryCount++) {
|
|
246
|
-
const loader = new Spinner(`Type checking and linting...`, {
|
|
371
|
+
const loader = new Spinner(`Type checking and linting...`, {
|
|
372
|
+
prefix: `🤖akan-editor`,
|
|
373
|
+
}).start();
|
|
247
374
|
const fileChecks = await Promise.all(
|
|
248
375
|
writes.map(async ({ filePath }) => {
|
|
249
|
-
const
|
|
250
|
-
const
|
|
251
|
-
const
|
|
376
|
+
const lintResult = await executor.lint(filePath, { fix: true });
|
|
377
|
+
const typeCheckResult = await executor.typeCheckAsync(filePath);
|
|
378
|
+
const hasTypeErrors = typeCheckResult.fileErrors.length > 0;
|
|
379
|
+
const hasLintErrors = lintResult.errors.length > 0;
|
|
380
|
+
const needFix = hasTypeErrors || hasLintErrors;
|
|
252
381
|
return { filePath, typeCheckResult, lintResult, needFix };
|
|
253
382
|
}),
|
|
254
383
|
);
|
|
255
|
-
const
|
|
256
|
-
if (
|
|
257
|
-
loader.fail(
|
|
384
|
+
const hasAnyFix = fileChecks.some((fileCheck) => fileCheck.needFix);
|
|
385
|
+
if (hasAnyFix) {
|
|
386
|
+
loader.fail(
|
|
387
|
+
"Type checking and linting has some errors, try to fix them",
|
|
388
|
+
);
|
|
258
389
|
fileChecks.forEach((fileCheck) => {
|
|
259
390
|
Logger.rawLog(
|
|
260
391
|
`TypeCheck Result \n${fileCheck.typeCheckResult.message}\nLint Result \n${fileCheck.lintResult.message}`,
|
|
@@ -264,12 +395,17 @@ ${validate.map((v) => `- ${v}`).join("\n")}`;
|
|
|
264
395
|
{ type: "eslint", content: fileCheck.lintResult.message },
|
|
265
396
|
]);
|
|
266
397
|
});
|
|
267
|
-
writes = await this.#editTypescripts(
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
398
|
+
writes = await this.#editTypescripts(
|
|
399
|
+
"Fix the typescript and eslint errors",
|
|
400
|
+
{
|
|
401
|
+
...options,
|
|
402
|
+
validate: undefined,
|
|
403
|
+
approve: true,
|
|
404
|
+
},
|
|
405
|
+
writes,
|
|
406
|
+
);
|
|
407
|
+
for (const write of writes)
|
|
408
|
+
await executor.writeFile(write.filePath, write.content);
|
|
273
409
|
} else {
|
|
274
410
|
loader.succeed("Type checking and linting has no errors");
|
|
275
411
|
return writes;
|
|
@@ -278,17 +414,7 @@ ${validate.map((v) => `- ${v}`).join("\n")}`;
|
|
|
278
414
|
throw new Error("Failed to create scalar");
|
|
279
415
|
}
|
|
280
416
|
#getTypescriptCodes(text: string): FileContent[] {
|
|
281
|
-
|
|
282
|
-
if (!codes) return [];
|
|
283
|
-
const result = codes.map((code) => {
|
|
284
|
-
const content = /```(typescript|tsx)([\s\S]*?)```/.exec(code)?.[2];
|
|
285
|
-
if (!content) return null;
|
|
286
|
-
const filePath = /\/\/ File: (.*?)(?:\n|$)/.exec(content)?.[1]?.trim();
|
|
287
|
-
if (!filePath) return null;
|
|
288
|
-
const contentWithoutFilepath = content.replace(`// File: ${filePath}\n`, "").trim();
|
|
289
|
-
return { filePath, content: contentWithoutFilepath };
|
|
290
|
-
});
|
|
291
|
-
return result.filter((code) => code !== null) as FileContent[];
|
|
417
|
+
return parseTypescriptFileBlocks(text);
|
|
292
418
|
}
|
|
293
419
|
async editMarkdown(request: string, options: EditOptions = {}) {
|
|
294
420
|
const content = await this.edit(request, options);
|
package/cloud/cloudApi.ts
CHANGED
|
@@ -1,30 +1,45 @@
|
|
|
1
|
-
import
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
}
|
|
8
|
-
interface AccessToken {
|
|
9
|
-
jwt: string;
|
|
10
|
-
refreshToken: string | null;
|
|
11
|
-
expiresAt: Dayjs | null;
|
|
12
|
-
}
|
|
1
|
+
import {
|
|
2
|
+
type AccessToken,
|
|
3
|
+
type AccessTokenDto,
|
|
4
|
+
akanCloudHost,
|
|
5
|
+
type HostConfig,
|
|
6
|
+
} from "./constants";
|
|
7
|
+
import { GlobalConfig } from "./globalConfig";
|
|
13
8
|
|
|
14
9
|
class HttpClient {
|
|
15
10
|
readonly baseUrl: string;
|
|
16
|
-
|
|
11
|
+
readonly headers: Record<string, string> = {};
|
|
12
|
+
constructor(baseUrl: string, headers: Record<string, string> = {}) {
|
|
17
13
|
this.baseUrl = baseUrl;
|
|
14
|
+
this.headers = headers;
|
|
18
15
|
}
|
|
19
16
|
async get<T>(
|
|
20
17
|
url: string,
|
|
21
18
|
{ headers }: { headers?: Record<string, string> } = {},
|
|
22
19
|
): Promise<T> {
|
|
23
20
|
const response = await fetch(`${this.baseUrl}${url}`, {
|
|
24
|
-
headers: {
|
|
21
|
+
headers: {
|
|
22
|
+
"Content-Type": "application/json",
|
|
23
|
+
...this.headers,
|
|
24
|
+
...headers,
|
|
25
|
+
},
|
|
25
26
|
});
|
|
26
27
|
return response.json();
|
|
27
28
|
}
|
|
29
|
+
async getFile(
|
|
30
|
+
url: string,
|
|
31
|
+
localPath: string,
|
|
32
|
+
headers?: Record<string, string>,
|
|
33
|
+
): Promise<void> {
|
|
34
|
+
const response = await fetch(`${this.baseUrl}${url}`, {
|
|
35
|
+
headers: { ...this.headers, ...headers },
|
|
36
|
+
});
|
|
37
|
+
if (!response.ok)
|
|
38
|
+
throw new Error(
|
|
39
|
+
`Failed to download file: ${response.status} ${response.statusText}`,
|
|
40
|
+
);
|
|
41
|
+
await Bun.write(localPath, response);
|
|
42
|
+
}
|
|
28
43
|
async post<T>(
|
|
29
44
|
url: string,
|
|
30
45
|
data: unknown,
|
|
@@ -35,68 +50,86 @@ class HttpClient {
|
|
|
35
50
|
method: "POST",
|
|
36
51
|
body: isFormData ? data : JSON.stringify(data),
|
|
37
52
|
headers: isFormData
|
|
38
|
-
? headers
|
|
39
|
-
: { "Content-Type": "application/json", ...headers },
|
|
53
|
+
? { ...this.headers, ...headers }
|
|
54
|
+
: { "Content-Type": "application/json", ...this.headers, ...headers },
|
|
40
55
|
});
|
|
41
56
|
return response.json();
|
|
42
57
|
}
|
|
58
|
+
setHeaders(headers: Record<string, string>) {
|
|
59
|
+
Object.assign(this.headers, headers);
|
|
60
|
+
return this;
|
|
61
|
+
}
|
|
43
62
|
}
|
|
44
63
|
|
|
45
64
|
export class CloudApi {
|
|
46
|
-
readonly api: HttpClient;
|
|
65
|
+
readonly #api: HttpClient;
|
|
47
66
|
#accessToken: AccessToken | null = null;
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
67
|
+
|
|
68
|
+
static async fromHost(host?: string) {
|
|
69
|
+
const hostConfig = await GlobalConfig.getHostConfig(host);
|
|
70
|
+
return new CloudApi(hostConfig);
|
|
71
|
+
}
|
|
72
|
+
constructor(hostConfig: HostConfig) {
|
|
73
|
+
const host = akanCloudHost;
|
|
74
|
+
this.#api = new HttpClient(`${host}/api`);
|
|
75
|
+
this.#accessToken = hostConfig.auth?.accessToken ?? null;
|
|
76
|
+
if (this.#accessToken && !GlobalConfig.needRefreshToken(this.#accessToken))
|
|
77
|
+
this.#api.setHeaders({
|
|
78
|
+
Authorization: `Bearer ${this.#accessToken.jwt}`,
|
|
79
|
+
});
|
|
54
80
|
}
|
|
55
81
|
|
|
56
82
|
async uploadEnv(devProjectId: string, file: File): Promise<boolean> {
|
|
57
83
|
const formData = new FormData();
|
|
58
84
|
formData.append("devProjectId", devProjectId);
|
|
59
85
|
formData.append("file", file);
|
|
60
|
-
const
|
|
86
|
+
const data = await this.#api.post<boolean>(
|
|
61
87
|
`/uploadEnv/${devProjectId}`,
|
|
62
88
|
formData,
|
|
63
89
|
);
|
|
64
|
-
return
|
|
90
|
+
return data;
|
|
65
91
|
}
|
|
66
|
-
async downloadEnv(devProjectId: string): Promise<
|
|
67
|
-
|
|
68
|
-
`/downloadEnv/${devProjectId}`,
|
|
69
|
-
);
|
|
70
|
-
return response.success;
|
|
92
|
+
async downloadEnv(devProjectId: string, localPath: string): Promise<void> {
|
|
93
|
+
await this.#api.getFile(`/downloadEnv/${devProjectId}`, localPath);
|
|
71
94
|
}
|
|
72
|
-
async getRemoteAuthToken(remoteId: string): Promise<AccessToken> {
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
95
|
+
async getRemoteAuthToken(remoteId: string): Promise<AccessToken | null> {
|
|
96
|
+
try {
|
|
97
|
+
if (this.#accessToken) {
|
|
98
|
+
if (GlobalConfig.needRefreshToken(this.#accessToken))
|
|
99
|
+
return await this.refreshAuthToken();
|
|
100
|
+
else return await this.refreshAuthToken();
|
|
101
|
+
}
|
|
102
|
+
const accessToken = await this.#api.get<AccessTokenDto>(
|
|
103
|
+
`/getRemoteAuthToken/${remoteId}`,
|
|
104
|
+
);
|
|
105
|
+
this.#accessToken = GlobalConfig.toAccessToken(accessToken);
|
|
106
|
+
this.#api.setHeaders({
|
|
107
|
+
Authorization: `Bearer ${this.#accessToken.jwt}`,
|
|
108
|
+
});
|
|
109
|
+
return this.#accessToken;
|
|
110
|
+
} catch (_) {
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
84
113
|
}
|
|
85
114
|
async refreshAuthToken(): Promise<AccessToken> {
|
|
86
|
-
const response = await this
|
|
115
|
+
const response = await this.#api.post<AccessTokenDto>(
|
|
87
116
|
`/refreshRemoteAuthToken`,
|
|
88
117
|
{
|
|
89
118
|
refreshToken: this.#accessToken?.refreshToken,
|
|
90
119
|
},
|
|
91
120
|
);
|
|
92
|
-
this.#accessToken =
|
|
93
|
-
|
|
94
|
-
refreshToken: response.refreshToken,
|
|
95
|
-
expiresAt: response.expiresAt ? dayjs(response.expiresAt) : null,
|
|
96
|
-
};
|
|
121
|
+
this.#accessToken = GlobalConfig.toAccessToken(response);
|
|
122
|
+
this.#api.setHeaders({ Authorization: `Bearer ${this.#accessToken.jwt}` });
|
|
97
123
|
return this.#accessToken;
|
|
98
124
|
}
|
|
99
|
-
|
|
100
|
-
|
|
125
|
+
async getRemoteSelf(): Promise<{ id: string; nickname: string } | null> {
|
|
126
|
+
try {
|
|
127
|
+
const data = await this.#api.get<{ id: string; nickname: string }>(
|
|
128
|
+
`/getRemoteSelf`,
|
|
129
|
+
);
|
|
130
|
+
return data;
|
|
131
|
+
} catch {
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
101
134
|
}
|
|
102
135
|
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { Dayjs } from "dayjs";
|
|
2
|
+
import type { SupportedLlmModel } from "../aiEditor";
|
|
3
|
+
|
|
4
|
+
export const basePath = `${Bun.env.HOME ?? Bun.env.USERPROFILE}/.akan`;
|
|
5
|
+
export const configPath = `${basePath}/config.json`;
|
|
6
|
+
export const akanCloudHost =
|
|
7
|
+
process.env.AKAN_PUBLIC_OPERATION_MODE === "local" ? "http://localhost" : "https://cloud.akanjs.com";
|
|
8
|
+
export const akanCloudUrl = `${akanCloudHost}${process.env.AKAN_PUBLIC_OPERATION_MODE === "local" ? ":8282" : ""}/api`;
|
|
9
|
+
|
|
10
|
+
export interface HostConfig {
|
|
11
|
+
auth?: {
|
|
12
|
+
accessToken?: AccessToken;
|
|
13
|
+
self?: { id: string; nickname: string };
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
export interface HostConfigDto {
|
|
17
|
+
auth?: {
|
|
18
|
+
accessToken?: AccessTokenDto;
|
|
19
|
+
self?: { id: string; nickname: string };
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
export const defaultHostConfig: HostConfig = {};
|
|
23
|
+
export interface RemoteEnvServerConfig {
|
|
24
|
+
host: string;
|
|
25
|
+
username?: string;
|
|
26
|
+
port?: number;
|
|
27
|
+
}
|
|
28
|
+
export interface AkanGlobalConfig {
|
|
29
|
+
cloudHost: { [key: string]: HostConfigDto };
|
|
30
|
+
remoteEnvServers: Record<string, RemoteEnvServerConfig>;
|
|
31
|
+
llm: { model: SupportedLlmModel; apiKey: string } | null;
|
|
32
|
+
}
|
|
33
|
+
export const defaultAkanGlobalConfig: AkanGlobalConfig = {
|
|
34
|
+
cloudHost: {},
|
|
35
|
+
remoteEnvServers: {},
|
|
36
|
+
llm: null,
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
export interface AccessTokenDto {
|
|
40
|
+
jwt: string;
|
|
41
|
+
refreshToken: string | null;
|
|
42
|
+
expiresAt: string | null;
|
|
43
|
+
}
|
|
44
|
+
export interface AccessToken {
|
|
45
|
+
jwt: string;
|
|
46
|
+
refreshToken: string | null;
|
|
47
|
+
expiresAt: Dayjs | null;
|
|
48
|
+
}
|