@astra-code/astra-ai 0.1.5 → 0.1.7
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/dist/app/App.js +287 -100
- package/dist/app/App.js.map +1 -1
- package/dist/lib/backendClient.js +105 -17
- package/dist/lib/backendClient.js.map +1 -1
- package/dist/lib/voice.js +93 -1
- package/dist/lib/voice.js.map +1 -1
- package/package.json +1 -1
- package/src/app/App.tsx +287 -82
- package/src/lib/backendClient.ts +106 -17
- package/src/lib/voice.ts +97 -1
- package/src/types/events.ts +1 -0
package/src/lib/backendClient.ts
CHANGED
|
@@ -7,7 +7,7 @@ import {
|
|
|
7
7
|
} from "./config.js";
|
|
8
8
|
import type {AgentEvent, AuthSession, ChatMessage} from "../types/events.js";
|
|
9
9
|
import type {WorkspaceFile} from "./workspaceScanner.js";
|
|
10
|
-
import {loadSession} from "./sessionStore.js";
|
|
10
|
+
import {loadSession, saveSession} from "./sessionStore.js";
|
|
11
11
|
|
|
12
12
|
type JsonRecord = Record<string, unknown>;
|
|
13
13
|
|
|
@@ -22,14 +22,55 @@ export type SessionSummary = {
|
|
|
22
22
|
export class BackendClient {
|
|
23
23
|
private readonly baseUrl: string;
|
|
24
24
|
private authToken: string | null;
|
|
25
|
+
private refreshToken: string | null;
|
|
26
|
+
private onTokenRefreshed: ((session: AuthSession) => void) | null = null;
|
|
25
27
|
|
|
26
28
|
public constructor(baseUrl = getBackendUrl()) {
|
|
27
29
|
this.baseUrl = baseUrl;
|
|
28
|
-
|
|
30
|
+
const stored = loadSession();
|
|
31
|
+
this.authToken = stored?.access_token?.trim() || null;
|
|
32
|
+
this.refreshToken = stored?.refresh_token?.trim() || null;
|
|
29
33
|
}
|
|
30
34
|
|
|
31
35
|
public setAuthSession(session: AuthSession | null): void {
|
|
32
36
|
this.authToken = session?.access_token?.trim() || null;
|
|
37
|
+
this.refreshToken = session?.refresh_token?.trim() || null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
public setOnTokenRefreshed(cb: (session: AuthSession) => void): void {
|
|
41
|
+
this.onTokenRefreshed = cb;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
private async tryRefreshToken(): Promise<boolean> {
|
|
45
|
+
if (!this.refreshToken) return false;
|
|
46
|
+
try {
|
|
47
|
+
const res = await fetch(`${this.baseUrl}/api/auth/refresh`, {
|
|
48
|
+
method: "POST",
|
|
49
|
+
headers: {"content-type": "application/json"},
|
|
50
|
+
body: JSON.stringify({refresh_token: this.refreshToken})
|
|
51
|
+
});
|
|
52
|
+
if (!res.ok) return false;
|
|
53
|
+
const data = (await res.json()) as Record<string, unknown>;
|
|
54
|
+
const newAccess = typeof data.access_token === "string" ? data.access_token.trim() : null;
|
|
55
|
+
if (!newAccess) return false;
|
|
56
|
+
const newRefresh = typeof data.refresh_token === "string" ? data.refresh_token.trim() : this.refreshToken;
|
|
57
|
+
this.authToken = newAccess;
|
|
58
|
+
this.refreshToken = newRefresh;
|
|
59
|
+
// Merge with stored session and persist
|
|
60
|
+
const stored = loadSession();
|
|
61
|
+
if (stored) {
|
|
62
|
+
const refreshed: AuthSession = {
|
|
63
|
+
...stored,
|
|
64
|
+
access_token: newAccess,
|
|
65
|
+
refresh_token: newRefresh ?? undefined
|
|
66
|
+
};
|
|
67
|
+
saveSession(refreshed);
|
|
68
|
+
this.onTokenRefreshed?.(refreshed);
|
|
69
|
+
}
|
|
70
|
+
return true;
|
|
71
|
+
} catch {
|
|
72
|
+
return false;
|
|
73
|
+
}
|
|
33
74
|
}
|
|
34
75
|
|
|
35
76
|
public async get(path: string, params?: Record<string, string>): Promise<JsonRecord> {
|
|
@@ -162,6 +203,24 @@ export class BackendClient {
|
|
|
162
203
|
return out;
|
|
163
204
|
}
|
|
164
205
|
|
|
206
|
+
public async getCliSettings(): Promise<Record<string, unknown>> {
|
|
207
|
+
try {
|
|
208
|
+
const data = await this.get("/api/user/cli-settings");
|
|
209
|
+
return (data.cli_settings as Record<string, unknown>) ?? {};
|
|
210
|
+
} catch {
|
|
211
|
+
return {};
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
public async updateCliSettings(settings: Record<string, unknown>): Promise<Record<string, unknown>> {
|
|
216
|
+
try {
|
|
217
|
+
const data = await this.patch("/api/user/cli-settings", settings);
|
|
218
|
+
return (data.cli_settings as Record<string, unknown>) ?? {};
|
|
219
|
+
} catch {
|
|
220
|
+
return {};
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
165
224
|
public async deleteSession(sessionId: string): Promise<void> {
|
|
166
225
|
await this.delete(`/api/sessions/${sessionId}`);
|
|
167
226
|
}
|
|
@@ -191,6 +250,7 @@ export class BackendClient {
|
|
|
191
250
|
workspaceTree?: string[];
|
|
192
251
|
workspaceFiles?: WorkspaceFile[];
|
|
193
252
|
model?: string;
|
|
253
|
+
signal?: AbortSignal;
|
|
194
254
|
}): AsyncGenerator<AgentEvent, void, void> {
|
|
195
255
|
const model = payload.model ?? getDefaultModel();
|
|
196
256
|
const body = {
|
|
@@ -212,20 +272,33 @@ export class BackendClient {
|
|
|
212
272
|
user_id: payload.user.user_id,
|
|
213
273
|
org_id: payload.user.org_id
|
|
214
274
|
};
|
|
215
|
-
return this.streamSse("/api/agent/chat/stream", body);
|
|
275
|
+
return this.streamSse("/api/agent/chat/stream", body, payload.signal);
|
|
216
276
|
}
|
|
217
277
|
|
|
218
278
|
private async request(url: string, method: string, payload?: JsonRecord): Promise<JsonRecord> {
|
|
219
|
-
const
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
279
|
+
const makeHeaders = (): Record<string, string> => {
|
|
280
|
+
const h: Record<string, string> = {"content-type": "application/json"};
|
|
281
|
+
if (this.authToken) h.authorization = `Bearer ${this.authToken}`;
|
|
282
|
+
return h;
|
|
283
|
+
};
|
|
284
|
+
|
|
285
|
+
let response = await fetch(url, {
|
|
224
286
|
method,
|
|
225
|
-
headers,
|
|
287
|
+
headers: makeHeaders(),
|
|
226
288
|
body: payload ? JSON.stringify(payload) : null
|
|
227
289
|
});
|
|
228
290
|
|
|
291
|
+
if (response.status === 401) {
|
|
292
|
+
const refreshed = await this.tryRefreshToken();
|
|
293
|
+
if (refreshed) {
|
|
294
|
+
response = await fetch(url, {
|
|
295
|
+
method,
|
|
296
|
+
headers: makeHeaders(),
|
|
297
|
+
body: payload ? JSON.stringify(payload) : null
|
|
298
|
+
});
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
229
302
|
if (!response.ok) {
|
|
230
303
|
const detail = (await response.text()).trim();
|
|
231
304
|
throw new Error(`Backend error ${response.status}: ${detail || response.statusText}`);
|
|
@@ -238,17 +311,33 @@ export class BackendClient {
|
|
|
238
311
|
return JSON.parse(text) as JsonRecord;
|
|
239
312
|
}
|
|
240
313
|
|
|
241
|
-
private async *streamSse(path: string, payload: JsonRecord): AsyncGenerator<AgentEvent> {
|
|
242
|
-
const
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
314
|
+
private async *streamSse(path: string, payload: JsonRecord, signal?: AbortSignal): AsyncGenerator<AgentEvent> {
|
|
315
|
+
const makeHeaders = (): Record<string, string> => {
|
|
316
|
+
const h: Record<string, string> = {"content-type": "application/json"};
|
|
317
|
+
if (this.authToken) h.authorization = `Bearer ${this.authToken}`;
|
|
318
|
+
return h;
|
|
319
|
+
};
|
|
320
|
+
const sig = signal ?? null;
|
|
321
|
+
|
|
322
|
+
let response = await fetch(`${this.baseUrl}${path}`, {
|
|
247
323
|
method: "POST",
|
|
248
|
-
headers,
|
|
249
|
-
body: JSON.stringify(payload)
|
|
324
|
+
headers: makeHeaders(),
|
|
325
|
+
body: JSON.stringify(payload),
|
|
326
|
+
signal: sig
|
|
250
327
|
});
|
|
251
328
|
|
|
329
|
+
if (response.status === 401) {
|
|
330
|
+
const refreshed = await this.tryRefreshToken();
|
|
331
|
+
if (refreshed) {
|
|
332
|
+
response = await fetch(`${this.baseUrl}${path}`, {
|
|
333
|
+
method: "POST",
|
|
334
|
+
headers: makeHeaders(),
|
|
335
|
+
body: JSON.stringify(payload),
|
|
336
|
+
signal: sig
|
|
337
|
+
});
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
252
341
|
if (!response.ok) {
|
|
253
342
|
const detail = (await response.text()).trim();
|
|
254
343
|
throw new Error(`Backend error ${response.status}: ${detail || response.statusText}`);
|
package/src/lib/voice.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import {spawn, spawnSync} from "child_process";
|
|
2
2
|
import {randomUUID} from "crypto";
|
|
3
|
+
import {existsSync, readFileSync, writeFileSync} from "fs";
|
|
3
4
|
import {basename, join} from "path";
|
|
4
5
|
import {tmpdir} from "os";
|
|
5
6
|
import {readFile, rm} from "fs/promises";
|
|
@@ -10,6 +11,101 @@ const VOICE_TEXT_LIMIT = 600;
|
|
|
10
11
|
const DEFAULT_STT_MODEL = process.env.ASTRA_STT_MODEL?.trim() || "whisper-1";
|
|
11
12
|
const DEFAULT_CHUNK_SECONDS = Number(process.env.ASTRA_STT_CHUNK_SECONDS ?? "2.5");
|
|
12
13
|
|
|
14
|
+
// Module-level device cache — resolved once per process lifetime.
|
|
15
|
+
// undefined = not yet resolved; null = not configured; string = resolved device (e.g. ":1")
|
|
16
|
+
let _cachedDevice: string | null | undefined = undefined;
|
|
17
|
+
|
|
18
|
+
/** Read/write the .astra file at the workspace root. */
|
|
19
|
+
const ASTRA_FILE_NAME = ".astra";
|
|
20
|
+
|
|
21
|
+
const readAstraFile = (workspaceRoot: string): Record<string, string> => {
|
|
22
|
+
const filePath = join(workspaceRoot, ASTRA_FILE_NAME);
|
|
23
|
+
if (!existsSync(filePath)) return {};
|
|
24
|
+
const lines = readFileSync(filePath, "utf8").split("\n");
|
|
25
|
+
const result: Record<string, string> = {};
|
|
26
|
+
for (const line of lines) {
|
|
27
|
+
const eq = line.indexOf("=");
|
|
28
|
+
if (eq > 0) result[line.slice(0, eq).trim()] = line.slice(eq + 1).trim();
|
|
29
|
+
}
|
|
30
|
+
return result;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export const writeAstraKey = (workspaceRoot: string, key: string, value: string): void => {
|
|
34
|
+
const filePath = join(workspaceRoot, ASTRA_FILE_NAME);
|
|
35
|
+
const existing = existsSync(filePath) ? readFileSync(filePath, "utf8") : "";
|
|
36
|
+
const lines = existing.split("\n").filter(l => l.trim() && !l.startsWith(`${key}=`));
|
|
37
|
+
lines.push(`${key}=${value}`);
|
|
38
|
+
writeFileSync(filePath, lines.join("\n") + "\n");
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Resolve the mic device string for ffmpeg (e.g. ":1").
|
|
43
|
+
* Priority: in-process cache → .astra file → backend cli_settings → null (needs onboarding).
|
|
44
|
+
* Writes the value to .astra when fetched from the backend so future runs are instant.
|
|
45
|
+
*/
|
|
46
|
+
export const resolveAudioDevice = async (workspaceRoot: string): Promise<string | null> => {
|
|
47
|
+
if (_cachedDevice !== undefined) return _cachedDevice;
|
|
48
|
+
|
|
49
|
+
// 1. Read local .astra cache
|
|
50
|
+
const local = readAstraFile(workspaceRoot);
|
|
51
|
+
if (local.ASTRA_STT_DEVICE) {
|
|
52
|
+
_cachedDevice = local.ASTRA_STT_DEVICE;
|
|
53
|
+
return _cachedDevice;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// 2. Fetch from backend
|
|
57
|
+
const token = loadSession()?.access_token;
|
|
58
|
+
if (token) {
|
|
59
|
+
try {
|
|
60
|
+
const resp = await fetch(`${getBackendUrl()}/api/user/cli-settings`, {
|
|
61
|
+
headers: {Authorization: `Bearer ${token}`}
|
|
62
|
+
});
|
|
63
|
+
if (resp.ok) {
|
|
64
|
+
const data = (await resp.json()) as {cli_settings?: {audio_device_index?: number}};
|
|
65
|
+
const idx = data.cli_settings?.audio_device_index;
|
|
66
|
+
if (typeof idx === "number") {
|
|
67
|
+
const device = `:${idx}`;
|
|
68
|
+
writeAstraKey(workspaceRoot, "ASTRA_STT_DEVICE", device);
|
|
69
|
+
_cachedDevice = device;
|
|
70
|
+
return _cachedDevice;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
} catch {
|
|
74
|
+
// Silently fall through to onboarding
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
_cachedDevice = null;
|
|
79
|
+
return null;
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
/** Call after onboarding saves a new device so the running process uses it immediately. */
|
|
83
|
+
export const setAudioDevice = (device: string): void => {
|
|
84
|
+
_cachedDevice = device;
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
/** List AVFoundation audio devices. Returns [] on non-macOS or if ffmpeg is missing. */
|
|
88
|
+
export const listAvfAudioDevices = (): Array<{index: number; name: string}> => {
|
|
89
|
+
if (process.platform !== "darwin") return [];
|
|
90
|
+
const result = spawnSync(
|
|
91
|
+
"ffmpeg",
|
|
92
|
+
["-f", "avfoundation", "-list_devices", "true", "-i", ""],
|
|
93
|
+
{encoding: "utf8", timeout: 8000}
|
|
94
|
+
);
|
|
95
|
+
const output = (result.stderr ?? "") + (result.stdout ?? "");
|
|
96
|
+
const devices: Array<{index: number; name: string}> = [];
|
|
97
|
+
let inAudio = false;
|
|
98
|
+
for (const line of output.split("\n")) {
|
|
99
|
+
if (line.includes("AVFoundation audio devices")) { inAudio = true; continue; }
|
|
100
|
+
if (inAudio) {
|
|
101
|
+
const m = line.match(/\[(\d+)\]\s+(.+)$/);
|
|
102
|
+
if (m?.[1] && m[2]) devices.push({index: parseInt(m[1], 10), name: m[2].trim()});
|
|
103
|
+
else if (devices.length) break;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return devices;
|
|
107
|
+
};
|
|
108
|
+
|
|
13
109
|
const safeText = (text: string): string => text.replace(/\s+/g, " ").trim().slice(0, VOICE_TEXT_LIMIT);
|
|
14
110
|
|
|
15
111
|
const commandExists = (binary: string): boolean => {
|
|
@@ -39,7 +135,7 @@ const captureAudioChunk = async (seconds: number): Promise<string> => {
|
|
|
39
135
|
if (process.platform === "darwin") {
|
|
40
136
|
// Prefer ffmpeg on macOS (works on Apple Silicon/Homebrew setups).
|
|
41
137
|
if (commandExists("ffmpeg")) {
|
|
42
|
-
const micDevice = process.env.ASTRA_STT_DEVICE?.trim()
|
|
138
|
+
const micDevice = _cachedDevice ?? process.env.ASTRA_STT_DEVICE?.trim() ?? ":0";
|
|
43
139
|
cmd = `ffmpeg -hide_banner -loglevel error -f avfoundation -i "${micDevice}" -ar 16000 -ac 1 -t ${seconds} "${outPath}"`;
|
|
44
140
|
} else if (commandExists("rec")) {
|
|
45
141
|
cmd = `rec -q -r 16000 -c 1 "${outPath}" trim 0 ${seconds}`;
|