@olares/lares-mobile 0.15.7 → 0.15.9
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/package.json +2 -2
- package/src/App.vue +254 -144
- package/src/chat/ChatBar.vue +134 -33
- package/src/chat/Composer.vue +245 -213
- package/src/chat/Deliverables.vue +83 -11
- package/src/chat/HistoryPanel.vue +40 -1
- package/src/chat/QuestionCard.vue +3 -3
- package/src/chat/Transcript.vue +162 -4
- package/src/chat/format.js +11 -0
- package/src/chat/playback.js +41 -0
- package/src/chat/voice.js +169 -39
- package/src/desktop/DesktopPreview.vue +10 -2
- package/src/desktop/DesktopShell.vue +2 -0
- package/src/host.js +131 -21
- package/src/index.d.ts +5 -0
- package/src/mobile/MobileNewSession.vue +6 -117
- package/src/mobile/MobileShell.vue +57 -69
- package/src/preview/Preview.vue +30 -16
- package/src/preview/PreviewBody.vue +4 -1
- package/src/settings/AgentSettings.vue +16 -8
- package/src/settings/Sheet.vue +4 -0
package/src/chat/voice.js
CHANGED
|
@@ -1,21 +1,57 @@
|
|
|
1
|
-
import { TranscriptQueue } from "@olares/lares-core/voice/client";
|
|
2
1
|
import {
|
|
3
2
|
formatElapsed,
|
|
4
3
|
isRecordingTooShort,
|
|
5
4
|
pickRecordingMime,
|
|
6
|
-
RECORDING_AUDIO,
|
|
7
5
|
} from "@olares/lares-core/voice/recorder";
|
|
8
6
|
|
|
9
7
|
export { formatElapsed };
|
|
10
8
|
|
|
11
|
-
|
|
12
|
-
|
|
9
|
+
/** Phone mic. Bare booleans are exact and iOS rejects the headset profile. */
|
|
10
|
+
const DEVICE_AUDIO = {
|
|
11
|
+
channelCount: { ideal: 1 },
|
|
12
|
+
echoCancellation: { ideal: true },
|
|
13
|
+
noiseSuppression: { ideal: true },
|
|
14
|
+
autoGainControl: { ideal: true },
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
const CHUNK_MS = 250;
|
|
18
|
+
|
|
19
|
+
function micFailure(err) {
|
|
20
|
+
const name = err?.name || "";
|
|
21
|
+
if (name === "NotAllowedError" || name === "PermissionDeniedError") return "voice_permission_denied";
|
|
22
|
+
if (name === "SecurityError") return "voice_unsupported";
|
|
23
|
+
return "voice_failed";
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async function openDeviceMic(mediaDevices) {
|
|
27
|
+
try {
|
|
28
|
+
return await mediaDevices.getUserMedia({ audio: DEVICE_AUDIO });
|
|
29
|
+
} catch (err) {
|
|
30
|
+
if (micFailure(err) !== "voice_failed") throw err;
|
|
31
|
+
return mediaDevices.getUserMedia({ audio: true });
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function createVoiceCapture({
|
|
36
|
+
transcribe,
|
|
37
|
+
submit,
|
|
38
|
+
onPhase,
|
|
39
|
+
onTrace,
|
|
40
|
+
now = Date.now,
|
|
41
|
+
media,
|
|
42
|
+
}) {
|
|
43
|
+
const mediaDevices = media?.mediaDevices ?? globalThis.navigator?.mediaDevices;
|
|
44
|
+
const Recorder = media?.MediaRecorder ?? globalThis.MediaRecorder;
|
|
13
45
|
let recorder = null;
|
|
14
46
|
let stream = null;
|
|
15
47
|
let chunks = [];
|
|
16
48
|
let started = 0;
|
|
17
49
|
let timer = 0;
|
|
50
|
+
let errorTimer = 0;
|
|
51
|
+
let finishTimer = 0;
|
|
18
52
|
let cancel = false;
|
|
53
|
+
let released = false;
|
|
54
|
+
let finished = false;
|
|
19
55
|
let dead = false;
|
|
20
56
|
let abort = null;
|
|
21
57
|
let phase = "idle";
|
|
@@ -25,6 +61,14 @@ export function createVoiceCapture({ transcribe, getDraft, setDraft, onPhase })
|
|
|
25
61
|
onPhase?.(next, extra);
|
|
26
62
|
};
|
|
27
63
|
|
|
64
|
+
const trace = (event, fields) => {
|
|
65
|
+
try {
|
|
66
|
+
onTrace?.(event, fields);
|
|
67
|
+
} catch {
|
|
68
|
+
/* a trace must not change the take */
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
|
|
28
72
|
const stopTimer = () => {
|
|
29
73
|
if (timer) {
|
|
30
74
|
clearInterval(timer);
|
|
@@ -40,96 +84,182 @@ export function createVoiceCapture({ transcribe, getDraft, setDraft, onPhase })
|
|
|
40
84
|
|
|
41
85
|
const fail = (code) => {
|
|
42
86
|
if (dead) return;
|
|
87
|
+
if (errorTimer) clearTimeout(errorTimer);
|
|
43
88
|
emit("error", { error: code, elapsed: 0 });
|
|
44
|
-
setTimeout(() => {
|
|
89
|
+
errorTimer = setTimeout(() => {
|
|
90
|
+
errorTimer = 0;
|
|
45
91
|
if (!dead && phase === "error") emit("idle", { error: "", elapsed: 0 });
|
|
46
92
|
}, 3200);
|
|
47
93
|
};
|
|
48
94
|
|
|
49
95
|
const finish = async () => {
|
|
96
|
+
if (finished || dead) return;
|
|
97
|
+
finished = true;
|
|
50
98
|
stopTimer();
|
|
51
|
-
const took =
|
|
52
|
-
const type = recorder?.mimeType || "audio/webm";
|
|
99
|
+
const took = now() - started;
|
|
100
|
+
const type = recorder?.mimeType || chunks[0]?.type || "audio/webm";
|
|
53
101
|
const blobChunks = chunks;
|
|
54
102
|
chunks = [];
|
|
55
103
|
release();
|
|
56
|
-
if (dead) return;
|
|
57
104
|
if (cancel) {
|
|
58
|
-
emit("idle", { elapsed: 0 });
|
|
105
|
+
emit("idle", { error: "", elapsed: 0 });
|
|
59
106
|
return;
|
|
60
107
|
}
|
|
61
108
|
const blob = new Blob(blobChunks, { type });
|
|
109
|
+
trace("blob", { bytes: blob.size, type, ms: took, chunks: blobChunks.length });
|
|
62
110
|
if (isRecordingTooShort(took, blob.size)) {
|
|
111
|
+
trace("too-short", { bytes: blob.size, ms: took });
|
|
63
112
|
fail("voice_too_short");
|
|
64
113
|
return;
|
|
65
114
|
}
|
|
66
|
-
emit("transcribing", { elapsed: took });
|
|
115
|
+
emit("transcribing", { elapsed: took, error: "" });
|
|
67
116
|
abort = new AbortController();
|
|
68
117
|
try {
|
|
69
|
-
const text = await transcribe(blob, abort.signal);
|
|
70
|
-
if (dead) return;
|
|
71
|
-
|
|
72
|
-
|
|
118
|
+
const text = String(await transcribe(blob, abort.signal) ?? "").trim();
|
|
119
|
+
if (dead || cancel) return;
|
|
120
|
+
if (!text) {
|
|
121
|
+
trace("no-input", {});
|
|
122
|
+
fail("voice_no_input");
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
trace("transcript", { chars: text.length, head: text.slice(0, 40) });
|
|
126
|
+
emit("idle", { error: "", elapsed: 0 });
|
|
127
|
+
submit?.(text);
|
|
73
128
|
} catch (err) {
|
|
74
|
-
if (dead || abort.signal.aborted) return;
|
|
75
|
-
|
|
129
|
+
if (dead || abort.signal.aborted || cancel) return;
|
|
130
|
+
const code = err instanceof Error ? err.message : "voice_failed";
|
|
131
|
+
trace("transcribe-error", { code });
|
|
132
|
+
fail(code);
|
|
76
133
|
} finally {
|
|
77
134
|
abort = null;
|
|
78
135
|
}
|
|
79
136
|
};
|
|
80
137
|
|
|
138
|
+
const scheduleFinish = () => {
|
|
139
|
+
if (finishTimer) clearTimeout(finishTimer);
|
|
140
|
+
finishTimer = setTimeout(() => {
|
|
141
|
+
finishTimer = 0;
|
|
142
|
+
void finish();
|
|
143
|
+
}, 30);
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
const endRecorder = () => {
|
|
147
|
+
if (!recorder || recorder.state === "inactive") {
|
|
148
|
+
scheduleFinish();
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
try {
|
|
152
|
+
recorder.requestData?.();
|
|
153
|
+
} catch {
|
|
154
|
+
/* the final dataavailable still arrives from stop() */
|
|
155
|
+
}
|
|
156
|
+
try {
|
|
157
|
+
recorder.stop();
|
|
158
|
+
} catch {
|
|
159
|
+
scheduleFinish();
|
|
160
|
+
}
|
|
161
|
+
};
|
|
162
|
+
|
|
81
163
|
return {
|
|
82
164
|
get phase() {
|
|
83
165
|
return phase;
|
|
84
166
|
},
|
|
85
167
|
async start() {
|
|
86
|
-
if (dead || phase === "recording" || phase === "transcribing") return;
|
|
87
|
-
if (typeof
|
|
168
|
+
if (dead || phase === "preparing" || phase === "recording" || phase === "transcribing") return;
|
|
169
|
+
if (typeof Recorder === "undefined" || !mediaDevices?.getUserMedia) {
|
|
88
170
|
fail("voice_unsupported");
|
|
89
171
|
return;
|
|
90
172
|
}
|
|
91
173
|
cancel = false;
|
|
174
|
+
released = false;
|
|
175
|
+
finished = false;
|
|
176
|
+
trace("open", {});
|
|
177
|
+
emit("preparing", { elapsed: 0, error: "" });
|
|
92
178
|
let next;
|
|
93
179
|
try {
|
|
94
|
-
next = await
|
|
95
|
-
} catch {
|
|
96
|
-
|
|
180
|
+
next = await openDeviceMic(mediaDevices);
|
|
181
|
+
} catch (err) {
|
|
182
|
+
const code = micFailure(err);
|
|
183
|
+
trace("mic-fail", { code, name: err?.name || "" });
|
|
184
|
+
if (!dead) fail(code);
|
|
97
185
|
return;
|
|
98
186
|
}
|
|
99
187
|
if (dead || cancel) {
|
|
100
188
|
next.getTracks().forEach((track) => track.stop());
|
|
189
|
+
if (!dead && phase === "preparing") emit("idle", { error: "", elapsed: 0 });
|
|
101
190
|
return;
|
|
102
191
|
}
|
|
103
192
|
stream = next;
|
|
104
193
|
chunks = [];
|
|
105
|
-
const mime = pickRecordingMime();
|
|
106
|
-
|
|
194
|
+
const mime = pickRecordingMime(Recorder);
|
|
195
|
+
try {
|
|
196
|
+
recorder = new Recorder(stream, mime ? { mimeType: mime } : undefined);
|
|
197
|
+
} catch {
|
|
198
|
+
release();
|
|
199
|
+
fail("voice_unsupported");
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
107
202
|
recorder.ondataavailable = (event) => {
|
|
108
203
|
if (event.data?.size) chunks.push(event.data);
|
|
204
|
+
if (recorder?.state === "inactive") scheduleFinish();
|
|
109
205
|
};
|
|
110
|
-
recorder.onstop = () =>
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
206
|
+
recorder.onstop = () => scheduleFinish();
|
|
207
|
+
started = now();
|
|
208
|
+
try {
|
|
209
|
+
recorder.start(CHUNK_MS);
|
|
210
|
+
} catch {
|
|
211
|
+
trace("record-fail", { mime });
|
|
212
|
+
release();
|
|
213
|
+
fail("voice_unsupported");
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
trace("recording", { mime: recorder.mimeType || mime || "" });
|
|
217
|
+
if (released || cancel) {
|
|
218
|
+
endRecorder();
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
114
221
|
emit("recording", { elapsed: 0, error: "" });
|
|
115
|
-
|
|
116
|
-
timer = setInterval(() => emit("recording", { elapsed: Date.now() - started }), 200);
|
|
222
|
+
timer = setInterval(() => emit("recording", { elapsed: now() - started, error: "" }), 200);
|
|
117
223
|
},
|
|
224
|
+
/**
|
|
225
|
+
* Finger-up finishes the take. The mic opens asynchronously, so the finger
|
|
226
|
+
* often lifts while still preparing — that is not a cancel.
|
|
227
|
+
*/
|
|
118
228
|
stop(aborting = false) {
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
229
|
+
if (phase === "transcribing") {
|
|
230
|
+
if (aborting) {
|
|
231
|
+
cancel = true;
|
|
232
|
+
abort?.abort();
|
|
233
|
+
}
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
if (phase !== "preparing" && phase !== "recording") {
|
|
237
|
+
trace("stop-ignored", { aborting: Boolean(aborting), phase });
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
trace("stop", {
|
|
241
|
+
aborting: Boolean(aborting),
|
|
242
|
+
phase,
|
|
243
|
+
recorder: recorder?.state || "none",
|
|
244
|
+
});
|
|
245
|
+
if (aborting) {
|
|
246
|
+
cancel = true;
|
|
247
|
+
released = false;
|
|
248
|
+
if (recorder && recorder.state !== "inactive") recorder.stop();
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
released = true;
|
|
252
|
+
stopTimer();
|
|
253
|
+
if (recorder && recorder.state !== "inactive") {
|
|
254
|
+
emit("transcribing", { elapsed: now() - started, error: "" });
|
|
255
|
+
endRecorder();
|
|
125
256
|
}
|
|
126
|
-
},
|
|
127
|
-
toggle() {
|
|
128
|
-
if (phase === "recording") this.stop(false);
|
|
129
|
-
else if (phase === "idle" || phase === "error") void this.start();
|
|
130
257
|
},
|
|
131
258
|
dispose() {
|
|
132
259
|
dead = true;
|
|
260
|
+
cancel = true;
|
|
261
|
+
if (errorTimer) clearTimeout(errorTimer);
|
|
262
|
+
if (finishTimer) clearTimeout(finishTimer);
|
|
133
263
|
stopTimer();
|
|
134
264
|
abort?.abort();
|
|
135
265
|
if (recorder && recorder.state !== "inactive") recorder.stop();
|
|
@@ -2,9 +2,16 @@
|
|
|
2
2
|
<section class="desktop-preview" :aria-label="t('preview')">
|
|
3
3
|
<header class="desktop-preview__bar">
|
|
4
4
|
<span class="desktop-preview__path" :title="path">{{ path }}</span>
|
|
5
|
+
<a
|
|
6
|
+
v-if="filesHref"
|
|
7
|
+
class="desktop-preview__action"
|
|
8
|
+
:href="filesHref"
|
|
9
|
+
target="_blank"
|
|
10
|
+
rel="noopener noreferrer"
|
|
11
|
+
>{{ t("openInFiles") }}</a>
|
|
5
12
|
<a
|
|
6
13
|
v-if="downloadHref"
|
|
7
|
-
class="desktop-
|
|
14
|
+
class="desktop-preview__action"
|
|
8
15
|
:href="downloadHref"
|
|
9
16
|
download
|
|
10
17
|
>{{ t("download") }}</a>
|
|
@@ -40,6 +47,7 @@ export default {
|
|
|
40
47
|
error: { type: String, default: "" },
|
|
41
48
|
mediaSrc: { type: String, default: "" },
|
|
42
49
|
downloadHref: { type: String, default: "" },
|
|
50
|
+
filesHref: { type: String, default: "" },
|
|
43
51
|
hrefFor: { type: Function, default: null },
|
|
44
52
|
t: { type: Function, required: true },
|
|
45
53
|
},
|
|
@@ -53,6 +61,6 @@ export default {
|
|
|
53
61
|
.desktop-preview { position:absolute; z-index:8; display:flex; min-width:0; min-height:0; flex-direction:column; inset:0; padding:12px 20px 20px; background:var(--q-background-1); }
|
|
54
62
|
.desktop-preview__bar { display:flex; min-width:0; flex:none; align-items:center; gap:12px; padding-bottom:10px; }
|
|
55
63
|
.desktop-preview__path { min-width:0; flex:1; overflow:hidden; padding:0 4px; color:var(--q-ink-3); font-size:12px; line-height:18px; text-overflow:ellipsis; white-space:nowrap; }
|
|
56
|
-
.desktop-
|
|
64
|
+
.desktop-preview__action { flex:none; color:var(--q-blue-default); font-size:12px; line-height:18px; text-decoration:none; }
|
|
57
65
|
.desktop-preview__content { display:flex; min-height:0; flex:1; flex-direction:column; overflow:hidden; border:1px solid var(--q-separator); border-radius:14px; background:var(--q-background-2); }
|
|
58
66
|
</style>
|
|
@@ -44,6 +44,7 @@
|
|
|
44
44
|
ref="transcript"
|
|
45
45
|
:items="state.viewItems"
|
|
46
46
|
:running="state.running"
|
|
47
|
+
:running-since="state.runningSince"
|
|
47
48
|
:loading="state.historyLoading || state.starting"
|
|
48
49
|
:previews="state.previews"
|
|
49
50
|
:media-url="mediaUrl"
|
|
@@ -95,6 +96,7 @@
|
|
|
95
96
|
:error="state.preview.error"
|
|
96
97
|
:media-src="state.previewMediaSrc"
|
|
97
98
|
:download-href="state.previewDownloadHref"
|
|
99
|
+
:files-href="state.previewFilesHref"
|
|
98
100
|
:href-for="previewHref"
|
|
99
101
|
:t="t"
|
|
100
102
|
@retry="$emit('open-file', state.preview.path)"
|
package/src/host.js
CHANGED
|
@@ -2,12 +2,18 @@ import { hostTarget, hostUrl, MODELS_PATH, probeHost } from "@olares/lares-core/
|
|
|
2
2
|
import { callRpc, consumeMux, ensureSession, loadTranscript, MUX_PATH, sendPrompt } from "@olares/lares-core/larepass/chat";
|
|
3
3
|
import { commandsUrl, normalizeCommands } from "@olares/lares-core/larepass/commands";
|
|
4
4
|
import { normalizeReferenceCandidates, referencesUrl } from "@olares/lares-core/larepass/references";
|
|
5
|
-
import { muxWsUrl } from "@olares/lares-core/larepass/mux";
|
|
5
|
+
import { muxWsUrl, remoteStreamOpen, workspaceBaseline } from "@olares/lares-core/larepass/mux";
|
|
6
6
|
import { RESPOND_PATH } from "@olares/lares-core/larepass/rpc";
|
|
7
7
|
import { createHostSettings } from "@olares/lares-core/larepass/settings";
|
|
8
|
-
import {
|
|
8
|
+
import {
|
|
9
|
+
downloadFileUrl,
|
|
10
|
+
isPreviewPayload,
|
|
11
|
+
previewMetaUrl,
|
|
12
|
+
rawFileUrl,
|
|
13
|
+
} from "@olares/lares-core/files/preview-workspace";
|
|
9
14
|
import { FILES_UPLOAD_PATH, uploadFile } from "@olares/lares-core/files/upload-client";
|
|
10
15
|
import { API as VOICE_API, postTranscribe } from "@olares/lares-core/voice/client";
|
|
16
|
+
import { postReport, REPORT_PATH } from "@olares/lares-core/log/report";
|
|
11
17
|
|
|
12
18
|
function pageOrigin() {
|
|
13
19
|
try {
|
|
@@ -35,6 +41,72 @@ function socketProtocols(ports) {
|
|
|
35
41
|
return value ? String(value) : undefined;
|
|
36
42
|
}
|
|
37
43
|
|
|
44
|
+
function listWorkspaces(ports) {
|
|
45
|
+
const Socket = globalThis.WebSocket;
|
|
46
|
+
if (typeof Socket !== "function") {
|
|
47
|
+
return Promise.resolve({
|
|
48
|
+
ok: false,
|
|
49
|
+
status: "error",
|
|
50
|
+
error: { code: "no-websocket", message: "WebSocket is not available" },
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
const streamId = typeof globalThis.crypto?.randomUUID === "function"
|
|
54
|
+
? globalThis.crypto.randomUUID()
|
|
55
|
+
: `ws-${Date.now()}`;
|
|
56
|
+
return openMuxSocket(hostUrl({
|
|
57
|
+
baseUrl: hostTarget(ports).baseUrl,
|
|
58
|
+
proxyPrefix: hostTarget(ports).proxyPrefix,
|
|
59
|
+
path: MUX_PATH,
|
|
60
|
+
}), undefined, socketProtocols(ports)).then((opened) => {
|
|
61
|
+
if (!opened.ok || !opened.body) {
|
|
62
|
+
return {
|
|
63
|
+
ok: false,
|
|
64
|
+
status: opened.status || "error",
|
|
65
|
+
error: opened.error || { code: "workspace", message: "workspace follow failed" },
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
const socket = opened.body;
|
|
69
|
+
return new Promise((resolve) => {
|
|
70
|
+
let settled = false;
|
|
71
|
+
const finish = (result) => {
|
|
72
|
+
if (settled) return;
|
|
73
|
+
settled = true;
|
|
74
|
+
clearTimeout(timer);
|
|
75
|
+
try {
|
|
76
|
+
socket.send(JSON.stringify({ type: "cancel", streamId }));
|
|
77
|
+
} catch {
|
|
78
|
+
// already closing
|
|
79
|
+
}
|
|
80
|
+
try {
|
|
81
|
+
socket.close();
|
|
82
|
+
} catch {
|
|
83
|
+
// already closing
|
|
84
|
+
}
|
|
85
|
+
resolve(result);
|
|
86
|
+
};
|
|
87
|
+
const timer = setTimeout(() => {
|
|
88
|
+
finish({
|
|
89
|
+
ok: false,
|
|
90
|
+
status: "error",
|
|
91
|
+
error: { code: "workspace", message: "workspace follow timed out" },
|
|
92
|
+
});
|
|
93
|
+
}, 8000);
|
|
94
|
+
socket.addEventListener("message", (event) => {
|
|
95
|
+
let message;
|
|
96
|
+
try {
|
|
97
|
+
message = JSON.parse(String(event.data ?? ""));
|
|
98
|
+
} catch {
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
const baseline = workspaceBaseline(message);
|
|
102
|
+
if (!baseline) return;
|
|
103
|
+
finish({ ok: true, status: "ok", value: baseline });
|
|
104
|
+
});
|
|
105
|
+
socket.send(JSON.stringify(remoteStreamOpen(streamId, "workspace/follow")));
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
|
|
38
110
|
function openMuxSocket(httpUrl, signal, protocols) {
|
|
39
111
|
const Socket = globalThis.WebSocket;
|
|
40
112
|
if (typeof Socket !== "function") {
|
|
@@ -82,19 +154,24 @@ function openMuxSocket(httpUrl, signal, protocols) {
|
|
|
82
154
|
});
|
|
83
155
|
}
|
|
84
156
|
|
|
157
|
+
/** Audio and file bodies go out as bytes; only plain objects are JSON. */
|
|
158
|
+
function isBinaryBody(body) {
|
|
159
|
+
return (typeof Blob !== "undefined" && body instanceof Blob)
|
|
160
|
+
|| body instanceof ArrayBuffer
|
|
161
|
+
|| ArrayBuffer.isView(body)
|
|
162
|
+
|| (typeof FormData !== "undefined" && body instanceof FormData);
|
|
163
|
+
}
|
|
164
|
+
|
|
85
165
|
export async function defaultRequest(url, init = {}) {
|
|
166
|
+
const raw = init.body === undefined || typeof init.body === "string" || isBinaryBody(init.body);
|
|
86
167
|
const res = await fetch(url, {
|
|
87
168
|
method: init.method ?? "GET",
|
|
88
|
-
body: init.body
|
|
89
|
-
? undefined
|
|
90
|
-
: typeof init.body === "string"
|
|
91
|
-
? init.body
|
|
92
|
-
: JSON.stringify(init.body),
|
|
169
|
+
body: raw ? init.body : JSON.stringify(init.body),
|
|
93
170
|
credentials: "include",
|
|
94
171
|
redirect: "manual",
|
|
95
172
|
signal: init.signal,
|
|
96
173
|
headers: {
|
|
97
|
-
...(init.body === undefined ? {} : { "content-type": "application/json" }),
|
|
174
|
+
...(init.body === undefined || isBinaryBody(init.body) ? {} : { "content-type": "application/json" }),
|
|
98
175
|
...init.headers,
|
|
99
176
|
},
|
|
100
177
|
});
|
|
@@ -118,14 +195,20 @@ export function createHostClient(ports = {}) {
|
|
|
118
195
|
const { baseUrl, proxyPrefix } = hostTarget(ports);
|
|
119
196
|
const send = ports.request ?? defaultRequest;
|
|
120
197
|
const urlFor = (path) => hostUrl({ baseUrl, proxyPrefix, path });
|
|
121
|
-
const
|
|
122
|
-
|
|
198
|
+
const direct = (url, init) => {
|
|
199
|
+
if (ports.missing) {
|
|
200
|
+
return Promise.resolve({ ok: false, status: 0, body: { error: { code: "missing" } } });
|
|
201
|
+
}
|
|
202
|
+
return send(url, init);
|
|
203
|
+
};
|
|
204
|
+
const request = (path, init) => direct(urlFor(path), init);
|
|
205
|
+
const rpc = (method, payload) => (
|
|
206
|
+
method === "workspace.list" ? listWorkspaces(ports) : callRpc(request, method, payload)
|
|
207
|
+
);
|
|
123
208
|
return {
|
|
124
209
|
urlFor,
|
|
125
210
|
probe() {
|
|
126
|
-
if (
|
|
127
|
-
return Promise.resolve({ status: "missing" });
|
|
128
|
-
}
|
|
211
|
+
if (ports.missing) return Promise.resolve({ status: "missing" });
|
|
129
212
|
return probeHost((path) => request(path), MODELS_PATH);
|
|
130
213
|
},
|
|
131
214
|
rpc,
|
|
@@ -168,32 +251,59 @@ export function createHostClient(ports = {}) {
|
|
|
168
251
|
const code = body?.error?.code || "file_preview_failed";
|
|
169
252
|
throw new Error(code);
|
|
170
253
|
}
|
|
254
|
+
if (!isPreviewPayload(body)) throw new Error("file_preview_failed");
|
|
171
255
|
return body;
|
|
172
256
|
},
|
|
173
|
-
mediaUrl(sessionId, path, modifiedAt) {
|
|
174
|
-
return urlFor(rawFileUrl(sessionId, path, modifiedAt));
|
|
257
|
+
mediaUrl(sessionId, path, modifiedAt, size) {
|
|
258
|
+
return urlFor(rawFileUrl(sessionId, path, modifiedAt, size));
|
|
175
259
|
},
|
|
176
260
|
downloadUrl(sessionId, path) {
|
|
177
261
|
return urlFor(downloadFileUrl(sessionId, path));
|
|
178
262
|
},
|
|
179
|
-
//
|
|
180
|
-
//
|
|
181
|
-
//
|
|
182
|
-
// cookie `fetch` these two used before.
|
|
263
|
+
// Binary bodies stay off the JSON path. `direct` still uses the host's
|
|
264
|
+
// auth port, or cookie fetch when none was given, and does not call out
|
|
265
|
+
// when LarePass has no entrance.
|
|
183
266
|
upload(sessionId, file, options) {
|
|
184
267
|
return uploadFile(file, sessionId, {
|
|
185
268
|
...options,
|
|
186
269
|
url: urlFor(FILES_UPLOAD_PATH),
|
|
187
|
-
request:
|
|
270
|
+
request: direct,
|
|
188
271
|
});
|
|
189
272
|
},
|
|
190
273
|
transcribe(blob, language, signal) {
|
|
191
274
|
return postTranscribe(blob, language, signal, {
|
|
192
275
|
baseUrl: urlFor(VOICE_API),
|
|
193
|
-
request:
|
|
276
|
+
request: direct,
|
|
277
|
+
});
|
|
278
|
+
},
|
|
279
|
+
reportLog(scope, event, fields) {
|
|
280
|
+
return postReport({ scope, event, ...(fields ?? {}) }, {
|
|
281
|
+
request,
|
|
282
|
+
url: urlFor(REPORT_PATH),
|
|
194
283
|
});
|
|
195
284
|
},
|
|
285
|
+
async speak(text) {
|
|
286
|
+
const res = await request(`${VOICE_API}/speak`, { method: "POST", body: { text } });
|
|
287
|
+
const body = res?.body;
|
|
288
|
+
if (res?.ok && body && typeof body.audio === "string") {
|
|
289
|
+
return {
|
|
290
|
+
bluetooth: body.bluetooth === "played" ? "played" : "skipped",
|
|
291
|
+
audio: body.audio,
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
const error = body && typeof body === "object" ? body.error : null;
|
|
295
|
+
const code = typeof error?.code === "string" && error.code ? error.code : "voice_speak_failed";
|
|
296
|
+
const message = typeof error?.message === "string" && error.message ? error.message : code;
|
|
297
|
+
throw Object.assign(new Error(message), { code });
|
|
298
|
+
},
|
|
299
|
+
async interruptSpeech() {
|
|
300
|
+
const res = await request(`${VOICE_API}/playback/stop`, { method: "POST" });
|
|
301
|
+
return Boolean(res?.ok);
|
|
302
|
+
},
|
|
196
303
|
async openMux(signal) {
|
|
304
|
+
if (ports.missing) {
|
|
305
|
+
return { ok: false, status: "missing", error: { code: "missing", message: "no entrance" } };
|
|
306
|
+
}
|
|
197
307
|
try {
|
|
198
308
|
return await openMuxSocket(urlFor(MUX_PATH), signal, socketProtocols(ports));
|
|
199
309
|
} catch (err) {
|
package/src/index.d.ts
CHANGED
|
@@ -30,6 +30,8 @@ export type HostPorts = {
|
|
|
30
30
|
* carry `X-Authorization`. Pass a function to have it read per connect.
|
|
31
31
|
*/
|
|
32
32
|
socketProtocol?: string | string[] | (() => string | string[] | undefined);
|
|
33
|
+
/** LarePass sets this when myApps has no Lares app. The entrance page leaves it unset. */
|
|
34
|
+
missing?: boolean;
|
|
33
35
|
};
|
|
34
36
|
|
|
35
37
|
export const PC_TEST_PROXY: string;
|
|
@@ -50,6 +52,7 @@ export function laresPortsFromAccount(input?: {
|
|
|
50
52
|
baseUrl?: string;
|
|
51
53
|
env?: HostEnv;
|
|
52
54
|
proxyPrefix?: string;
|
|
55
|
+
missing?: boolean;
|
|
53
56
|
};
|
|
54
57
|
|
|
55
58
|
export function defaultRequest(url: string, init?: HostRequestInit): Promise<HostResponse>;
|
|
@@ -72,6 +75,7 @@ export const LaresApp: DefineComponent<{
|
|
|
72
75
|
env?: HostEnv;
|
|
73
76
|
request?: (url: string, init?: HostRequestInit) => Promise<HostResponse>;
|
|
74
77
|
socketProtocol?: HostPorts["socketProtocol"];
|
|
78
|
+
missing?: boolean;
|
|
75
79
|
}>;
|
|
76
80
|
|
|
77
81
|
export const LaresAgentSettings: DefineComponent<{
|
|
@@ -81,4 +85,5 @@ export const LaresAgentSettings: DefineComponent<{
|
|
|
81
85
|
proxyPrefix?: string;
|
|
82
86
|
env?: HostEnv;
|
|
83
87
|
request?: (url: string, init?: HostRequestInit) => Promise<HostResponse>;
|
|
88
|
+
missing?: boolean;
|
|
84
89
|
}>;
|