@olares/lares-mobile 0.15.6 → 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.
@@ -1,18 +1,50 @@
1
1
  <template>
2
2
  <LaresPcDialog
3
3
  :open="state.open"
4
- :title="t('desktop.chooseFolder')"
4
+ :title="t('desktop.selectWorkspaceDirectory')"
5
5
  :close-label="t('desktop.close')"
6
6
  :busy="state.creating || state.creatingFolder"
7
7
  :width="620"
8
+ variant="directory"
8
9
  @close="$emit('close')"
9
10
  >
11
+ <template #header="{ titleId }">
12
+ <div class="directory-browser__header">
13
+ <h2 :id="titleId">{{ t("desktop.selectWorkspaceDirectory") }}</h2>
14
+ <div class="directory-browser__location">
15
+ <input
16
+ v-if="editingPath"
17
+ ref="pathInput"
18
+ v-model="pathDraft"
19
+ :aria-label="t('desktop.folderPath')"
20
+ @keydown.enter.prevent="submitPath"
21
+ @keydown.esc.prevent="editingPath = false"
22
+ @blur="editingPath = false"
23
+ />
24
+ <nav v-else-if="state.listing" :aria-label="t('desktop.folderPath')">
25
+ <template v-for="(crumb, index) in homeCrumbs" :key="crumb.path">
26
+ <span v-if="index" aria-hidden="true">/</span>
27
+ <button type="button" @click="$emit('browse', crumb.path)">
28
+ {{ crumb.path === state.listing.home ? t("desktop.home") : crumb.name }}
29
+ </button>
30
+ </template>
31
+ </nav>
32
+ <button
33
+ v-if="!editingPath"
34
+ type="button"
35
+ class="directory-browser__edit"
36
+ :aria-label="t('desktop.folderPath')"
37
+ @click="beginPathEdit"
38
+ >
39
+ <svg viewBox="0 0 24 24" aria-hidden="true">
40
+ <path d="m4 16-.75 4.25L7.5 19.5 18.4 8.6a2.1 2.1 0 0 0-3-3Z" />
41
+ <path d="m13.8 7.2 3 3M3 21h18" />
42
+ </svg>
43
+ </button>
44
+ </div>
45
+ </div>
46
+ </template>
10
47
  <div class="directory-browser">
11
- <nav v-if="state.listing" class="directory-browser__crumbs" :aria-label="t('desktop.folderPath')">
12
- <button v-for="crumb in state.listing.crumbs" :key="crumb.path" type="button" @click="$emit('browse', crumb.path)">
13
- {{ crumb.path === state.listing.home ? t("desktop.home") : crumb.name }}
14
- </button>
15
- </nav>
16
48
  <div class="directory-browser__body">
17
49
  <p v-if="state.loading" class="directory-browser__status">{{ t("desktop.loadingFolders") }}</p>
18
50
  <p v-else-if="state.error" class="directory-browser__error">{{ state.error }}</p>
@@ -40,9 +72,7 @@
40
72
  </div>
41
73
  <template #footer>
42
74
  <div class="directory-browser__footer">
43
- <p class="directory-browser__path">{{ selected || state.listing?.path || "" }}</p>
44
- <div class="directory-browser__footer-row">
45
- <div class="directory-browser__footer-left">
75
+ <div class="directory-browser__footer-left">
46
76
  <button
47
77
  type="button"
48
78
  class="directory-browser__new-folder"
@@ -60,8 +90,8 @@
60
90
  />
61
91
  <span>{{ t("desktop.showHiddenFiles") }}</span>
62
92
  </label>
63
- </div>
64
- <div class="directory-browser__footer-right">
93
+ </div>
94
+ <div class="directory-browser__footer-right">
65
95
  <button type="button" class="directory-browser__cancel" :disabled="state.creating" @click="$emit('close')">
66
96
  {{ t("desktop.cancel") }}
67
97
  </button>
@@ -71,9 +101,8 @@
71
101
  :disabled="state.loading || state.creating || !(selected || state.listing?.path)"
72
102
  @click="$emit('choose', selected || state.listing.path)"
73
103
  >
74
- {{ state.creating ? t("desktop.addingWorkspace") : t("desktop.addWorkspace") }}
104
+ {{ state.creating ? t("desktop.addingWorkspace") : t("desktop.open") }}
75
105
  </button>
76
- </div>
77
106
  </div>
78
107
  </div>
79
108
  </template>
@@ -100,17 +129,25 @@ export default {
100
129
  },
101
130
  emits: ["close", "browse", "choose", "create-folder", "toggle-hidden"],
102
131
  data() {
103
- return { selected: "", newFolderOpen: false };
132
+ return { selected: "", newFolderOpen: false, editingPath: false, pathDraft: "" };
104
133
  },
105
134
  computed: {
106
135
  visibleEntries() {
107
136
  const entries = this.state.listing?.entries ?? [];
108
137
  return this.state.showHidden ? entries : entries.filter((entry) => !entry.hidden);
109
138
  },
139
+ homeCrumbs() {
140
+ const crumbs = this.state.listing?.crumbs ?? [];
141
+ const home = this.state.listing?.home;
142
+ const homeIndex = crumbs.findIndex((crumb) => crumb.path === home);
143
+ return homeIndex >= 0 ? crumbs.slice(homeIndex) : crumbs;
144
+ },
110
145
  },
111
146
  watch: {
112
147
  "state.listing.path"() {
113
148
  this.selected = this.state.selectPath || "";
149
+ this.pathDraft = this.state.listing?.path || "";
150
+ this.editingPath = false;
114
151
  },
115
152
  "state.selectPath"(path) {
116
153
  if (path) this.selected = path;
@@ -129,38 +166,62 @@ export default {
129
166
  onCreateFolder(name) {
130
167
  this.$emit("create-folder", name);
131
168
  },
169
+ beginPathEdit() {
170
+ this.pathDraft = this.state.listing?.path || "";
171
+ this.editingPath = true;
172
+ this.$nextTick(() => {
173
+ this.$refs.pathInput?.focus();
174
+ this.$refs.pathInput?.select();
175
+ });
176
+ },
177
+ submitPath() {
178
+ const path = this.pathDraft.trim();
179
+ if (!path) return;
180
+ this.editingPath = false;
181
+ this.$emit("browse", path);
182
+ },
132
183
  },
133
184
  };
134
185
  </script>
135
186
 
136
187
  <style scoped>
137
- .directory-browser { display:flex; height:min(440px, calc(100vh - 180px)); flex-direction:column; margin:-16px; }
188
+ .directory-browser__header { height:76px; }
189
+ .directory-browser__header h2 { height:42px; margin:0; padding:19px 22px 0; font-size:16px; font-weight:500; line-height:22px; }
190
+ .directory-browser__location { display:flex; height:34px; align-items:center; gap:8px; padding:0 20px 7px 22px; }
191
+ .directory-browser__location nav { display:flex; min-width:0; flex:1; align-items:center; gap:7px; overflow:hidden; color:var(--q-ink-2); }
192
+ .directory-browser__location nav button { min-width:0; overflow:hidden; border:0; padding:0; background:transparent; color:inherit; font:inherit; font-size:14px; text-overflow:ellipsis; white-space:nowrap; cursor:pointer; }
193
+ .directory-browser__location nav button:hover { color:var(--q-ink-1); }
194
+ .directory-browser__location input { box-sizing:border-box; min-width:0; height:27px; flex:1; border:1px solid var(--q-input-stroke); border-radius:5px; padding:0 8px; outline:none; background:var(--q-background-2); color:var(--q-ink-1); font:inherit; font-size:13px; }
195
+ .directory-browser__edit { display:grid; width:30px; height:30px; flex-shrink:0; place-items:center; border:0; border-radius:6px; background:transparent; color:var(--q-ink-2); cursor:pointer; }
196
+ .directory-browser__edit:hover { background:var(--q-background-hover); color:var(--q-ink-1); }
197
+ .directory-browser__edit svg { width:17px; height:17px; fill:none; stroke:currentColor; stroke-width:1.6; stroke-linecap:round; stroke-linejoin:round; }
198
+ .directory-browser { display:flex; height:min(316px, calc(100vh - 186px)); flex-direction:column; }
138
199
  .directory-browser svg { width:18px; height:18px; fill:none; stroke:currentColor; stroke-width:1.7; stroke-linecap:round; stroke-linejoin:round; }
139
- .directory-browser__crumbs { display:flex; gap:3px; min-height:38px; align-items:center; overflow:auto; padding:0 14px; border-bottom:1px solid var(--q-separator); }
140
- .directory-browser__crumbs button { flex-shrink:0; border:0; border-radius:5px; padding:4px 6px; background:transparent; color:var(--q-ink-3); font-size:14px; }
141
- .directory-browser__crumbs button:hover { background:var(--q-background-hover); color:var(--q-ink-1); }
142
- .directory-browser__body { flex:1; min-height:0; overflow:auto; padding:8px; }
143
- .directory-browser__row { display:flex; width:100%; align-items:center; gap:9px; border:0; border-radius:7px; padding:8px 10px; background:transparent; color:var(--q-ink-1); text-align:left; }
144
- .directory-browser__row:hover,
145
- .directory-browser__row[data-selected="true"] { background:var(--q-background-hover); }
200
+ .directory-browser__body { flex:1; min-height:0; overflow:auto; padding:13px 20px; }
201
+ .directory-browser__row { display:flex; width:100%; height:28px; align-items:center; gap:7px; border:0; border-radius:6px; padding:0 6px; background:transparent; color:var(--q-ink-1); font-size:14px; text-align:left; }
202
+ .directory-browser__row:hover { background:var(--q-background-hover); }
203
+ .directory-browser__row[data-selected="true"] { background:var(--q-background-selected); }
146
204
  .directory-browser__row > span { min-width:0; flex:1; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
147
205
  .directory-browser__open { display:grid; width:26px; height:26px; place-items:center; border:0; background:transparent; color:var(--q-ink-3); }
148
206
  .directory-browser__status,
149
- .directory-browser__error { margin:20px; color:var(--q-ink-3); font-size:15px; text-align:center; }
207
+ .directory-browser__error { margin:20px; color:var(--q-ink-3); font-size:14px; text-align:center; }
150
208
  .directory-browser__error { color:var(--q-orange-default); }
151
- .directory-browser__footer { display:flex; width:100%; flex-direction:column; gap:10px; }
152
- .directory-browser__path { min-width:0; margin:0; overflow:hidden; color:var(--q-ink-3); font-size:13px; text-overflow:ellipsis; white-space:nowrap; text-align:left; }
153
- .directory-browser__footer-row { display:flex; align-items:center; justify-content:space-between; gap:12px; }
209
+ .directory-browser__footer { display:flex; width:100%; align-items:center; justify-content:space-between; gap:12px; }
154
210
  .directory-browser__footer-left { display:flex; min-width:0; flex:1; align-items:center; gap:12px; }
155
211
  .directory-browser__footer-right { display:flex; flex-shrink:0; align-items:center; gap:8px; }
156
- .directory-browser__new-folder { display:inline-flex; align-items:center; gap:6px; border:1px solid var(--q-separator); border-radius:999px; padding:0 10px; height:32px; background:transparent; color:var(--q-ink-2); font:inherit; font-size:13px; white-space:nowrap; }
157
- .directory-browser__new-folder svg { width:14px; height:14px; }
158
- .directory-browser__hidden { display:inline-flex; align-items:center; gap:6px; color:var(--q-ink-3); font-size:13px; white-space:nowrap; cursor:pointer; }
159
- .directory-browser__hidden input { width:15px; height:15px; margin:0; }
212
+ .directory-browser__new-folder { display:inline-flex; align-items:center; gap:6px; border:1px solid var(--q-btn-stroke); border-radius:999px; padding:0 14px; height:34px; background:transparent; color:var(--q-ink-1); font:inherit; font-size:14px; white-space:nowrap; }
213
+ /* The footer is a dialog slot, outside `.directory-browser`, so it misses the shared icon rule. */
214
+ .directory-browser__new-folder svg { width:14px; height:14px; fill:none; stroke:currentColor; stroke-width:1.7; stroke-linecap:round; stroke-linejoin:round; }
215
+ .directory-browser__hidden { display:inline-flex; align-items:center; color:var(--q-ink-2); font-size:13px; white-space:nowrap; cursor:pointer; }
216
+ .directory-browser__hidden input { position:absolute; width:1px; height:1px; overflow:hidden; opacity:0; pointer-events:none; }
217
+ .directory-browser__hidden:has(input:checked) { color:var(--q-ink-1); }
160
218
  .directory-browser__cancel,
161
- .directory-browser__choose { min-width:76px; height:32px; border-radius:999px; padding:0 12px; }
162
- .directory-browser__cancel { border:1px solid var(--q-separator); background:transparent; color:var(--q-ink-2); }
163
- .directory-browser__choose { border:0; background:var(--q-blue-default); color:var(--q-ink-on-brand); }
219
+ .directory-browser__choose { min-width:67px; height:34px; border-radius:999px; padding:0 14px; font-size:14px; }
220
+ .directory-browser__cancel { border:1px solid var(--q-btn-stroke); background:transparent; color:var(--q-ink-1); }
221
+ .directory-browser__new-folder:hover:not(:disabled),
222
+ .directory-browser__cancel:hover:not(:disabled) { background:var(--q-btn-bg-hover); border-color:var(--q-btn-stroke-hover); }
223
+ /* The confirm button is the inverse surface: white on dark, dark on light. */
224
+ .directory-browser__choose { border:0; background:var(--q-background-9); color:var(--q-background-1); }
164
225
  .directory-browser button,
165
226
  .directory-browser__cancel,
166
227
  .directory-browser__choose { cursor:pointer; font:inherit; }
@@ -1,17 +1,26 @@
1
1
  <template>
2
2
  <Teleport to="body">
3
3
  <Transition name="lares-dialog">
4
- <div v-if="open" class="lares-pc-dialog" role="dialog" aria-modal="true" :aria-labelledby="titleId">
4
+ <div
5
+ v-if="open"
6
+ class="lares-pc-dialog"
7
+ role="dialog"
8
+ aria-modal="true"
9
+ :aria-labelledby="titleId"
10
+ :data-variant="variant || undefined"
11
+ >
5
12
  <button class="lares-pc-dialog__backdrop" type="button" :aria-label="closeLabel" @click="close" />
6
13
  <section class="lares-pc-dialog__surface" :style="{ width: surfaceWidth }">
7
14
  <header class="lares-pc-dialog__header">
8
- <div class="lares-pc-dialog__title">
9
- <h2 :id="titleId">{{ title }}</h2>
10
- <button type="button" class="lares-pc-dialog__close" :aria-label="closeLabel" @click="close">
11
- <svg viewBox="0 0 24 24" aria-hidden="true"><path d="m7 7 10 10M17 7 7 17" /></svg>
12
- </button>
13
- </div>
14
- <p v-if="description">{{ description }}</p>
15
+ <slot name="header" :title-id="titleId">
16
+ <div class="lares-pc-dialog__title">
17
+ <h2 :id="titleId">{{ title }}</h2>
18
+ <button type="button" class="lares-pc-dialog__close" :aria-label="closeLabel" @click="close">
19
+ <svg viewBox="0 0 24 24" aria-hidden="true"><path d="m7 7 10 10M17 7 7 17" /></svg>
20
+ </button>
21
+ </div>
22
+ <p v-if="description">{{ description }}</p>
23
+ </slot>
15
24
  </header>
16
25
  <div class="lares-pc-dialog__body">
17
26
  <slot />
@@ -37,6 +46,7 @@ export default {
37
46
  closeLabel: { type: String, default: "Close" },
38
47
  width: { type: [Number, String], default: 440 },
39
48
  busy: { type: Boolean, default: false },
49
+ variant: { type: String, default: "" },
40
50
  },
41
51
  emits: ["close"],
42
52
  data() {
@@ -82,4 +92,9 @@ export default {
82
92
  .lares-pc-dialog__close svg { width:18px; height:18px; fill:none; stroke:currentColor; stroke-width:1.7; stroke-linecap:round; }
83
93
  .lares-pc-dialog__body { min-height:0; overflow:auto; padding:16px; }
84
94
  .lares-pc-dialog__footer { display:flex; min-height:58px; flex-shrink:0; align-items:center; justify-content:flex-end; gap:8px; padding:0 16px; border-top:1px solid var(--q-separator); }
95
+ /* The directory variant only changes geometry; colors stay on the shared tokens. */
96
+ .lares-pc-dialog[data-variant="directory"] .lares-pc-dialog__surface { border-radius:20px; }
97
+ .lares-pc-dialog[data-variant="directory"] .lares-pc-dialog__header { padding:0; }
98
+ .lares-pc-dialog[data-variant="directory"] .lares-pc-dialog__body { padding:0; }
99
+ .lares-pc-dialog[data-variant="directory"] .lares-pc-dialog__footer { min-height:62px; padding:0 22px; }
85
100
  </style>
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 { downloadFileUrl, previewMetaUrl, rawFileUrl } from "@olares/lares-core/files/preview-workspace";
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 === undefined
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 request = (path, init) => send(urlFor(path), init);
122
- const rpc = (method, payload) => callRpc(request, method, payload);
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 (!proxyPrefix && !baseUrl) {
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
- // Both carry a binary body, so neither can go through `request` as an RPC —
180
- // but both must still ride the host's auth. `ports.request` is passed
181
- // through rather than called here so a host that supplies none keeps the
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: ports.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: ports.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
  }>;
@@ -6,35 +6,6 @@
6
6
  </div>
7
7
 
8
8
  <div class="mobile-new-session__prompt">
9
- <div class="mobile-new-session__selectors">
10
- <button
11
- type="button"
12
- class="mobile-new-session__chip mobile-new-session__chip--workspace"
13
- :disabled="workspaceBusy"
14
- :aria-label="t('desktop.workspaces')"
15
- @click="$emit('workspace')"
16
- >
17
- <svg viewBox="0 0 24 24" aria-hidden="true">
18
- <path d="M3.5 7.5h6l1.6 2H20.5v9H3.5z" />
19
- </svg>
20
- <span>{{ workspaceLabel }}</span>
21
- <svg class="mobile-new-session__chevron" viewBox="0 0 24 24" aria-hidden="true">
22
- <path d="M7 10l5 5 5-5" />
23
- </svg>
24
- </button>
25
- <button
26
- type="button"
27
- class="mobile-new-session__chip mobile-new-session__chip--model"
28
- :disabled="modelBusy"
29
- :aria-label="modelAria"
30
- @click="$emit('model')"
31
- >
32
- <span>{{ modelLabel || t("model.select") }}</span>
33
- <svg class="mobile-new-session__chevron" viewBox="0 0 24 24" aria-hidden="true">
34
- <path d="M7 10l5 5 5-5" />
35
- </svg>
36
- </button>
37
- </div>
38
9
  <div class="mobile-new-session__composer">
39
10
  <slot />
40
11
  </div>
@@ -49,21 +20,11 @@ export default {
49
20
  name: "LaresMobileNewSession",
50
21
  props: {
51
22
  t: { type: Function, required: true },
52
- workspaceLabel: { type: String, default: "" },
53
- workspaceBusy: { type: Boolean, default: false },
54
- modelLabel: { type: String, default: "" },
55
- modelBusy: { type: Boolean, default: false },
56
23
  },
57
- emits: ["workspace", "model"],
58
24
  computed: {
59
25
  logoStyle() {
60
26
  return { backgroundImage: MARK_DATA_URI };
61
27
  },
62
- modelAria() {
63
- return this.modelLabel
64
- ? this.t("model.switchAria", { label: this.modelLabel })
65
- : this.t("model.select");
66
- },
67
28
  },
68
29
  };
69
30
  </script>
@@ -75,17 +36,17 @@ export default {
75
36
  min-height: 0;
76
37
  flex: 1;
77
38
  flex-direction: column;
78
- align-items: center;
79
- justify-content: center;
80
- padding: 20px 20px calc(12px + env(safe-area-inset-bottom, 0px));
39
+ align-items: stretch;
81
40
  }
82
41
 
83
42
  .mobile-new-session__brand {
84
43
  display: flex;
44
+ min-height: 0;
45
+ flex: 1;
85
46
  flex-direction: column;
86
47
  align-items: center;
48
+ justify-content: center;
87
49
  gap: 8px;
88
- margin-bottom: 32px;
89
50
  }
90
51
 
91
52
  .mobile-new-session__logo {
@@ -121,78 +82,10 @@ export default {
121
82
  }
122
83
 
123
84
  .mobile-new-session__prompt {
124
- display: flex;
125
- width: min(100%, 420px);
126
- flex-direction: column;
127
- gap: 6px;
128
- }
129
-
130
- .mobile-new-session__selectors {
131
85
  display: flex;
132
86
  width: 100%;
133
- align-items: center;
134
- justify-content: space-between;
135
- gap: 10px;
136
- }
137
-
138
- .mobile-new-session__chip {
139
- display: inline-flex;
140
- min-width: 0;
141
- align-items: center;
142
- gap: 6px;
143
- border: 0;
144
- border-radius: 999px;
145
- padding: 6px 10px;
146
- background: transparent;
147
- color: var(--q-ink-2);
148
- font-size: 14px;
149
- font-weight: 500;
150
- line-height: 18px;
151
- transition: background var(--lares-duration-fast, 120ms) var(--lares-ease-out, ease),
152
- color var(--lares-duration-fast, 120ms) var(--lares-ease-out, ease);
153
- }
154
-
155
- .mobile-new-session__chip--workspace {
156
- flex: 0 1 auto;
157
- max-width: 46%;
158
- }
159
-
160
- .mobile-new-session__chip--model {
161
- flex: 1 1 auto;
162
- justify-content: flex-end;
163
- max-width: 54%;
164
- }
165
-
166
- .mobile-new-session__chip span {
167
- min-width: 0;
168
- overflow: hidden;
169
- text-overflow: ellipsis;
170
- white-space: nowrap;
171
- }
172
-
173
- .mobile-new-session__chip svg:not(.mobile-new-session__chevron) {
174
- width: 18px;
175
- height: 18px;
176
- flex-shrink: 0;
177
- fill: none;
178
- stroke: currentColor;
179
- stroke-width: 1.75;
180
- stroke-linejoin: round;
181
- }
182
-
183
- .mobile-new-session__chevron {
184
- width: 16px;
185
- height: 16px;
186
- flex-shrink: 0;
187
- fill: none;
188
- stroke: currentColor;
189
- stroke-width: 1.75;
190
- stroke-linecap: round;
191
- stroke-linejoin: round;
192
- }
193
-
194
- .mobile-new-session__chip:active:not(:disabled) {
195
- background: var(--q-btn-bg-pressed);
87
+ flex-direction: column;
88
+ padding: 8px 16px calc(12px + env(safe-area-inset-bottom, 0px));
196
89
  }
197
90
 
198
91
  .mobile-new-session__composer {
@@ -200,10 +93,6 @@ export default {
200
93
  }
201
94
 
202
95
  @media (max-height: 640px) {
203
- .mobile-new-session__brand {
204
- margin-bottom: 24px;
205
- }
206
-
207
96
  .mobile-new-session__logo {
208
97
  width: 48px;
209
98
  height: 48px;