@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.
- package/package.json +2 -2
- package/src/App.vue +255 -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/DesktopComposer.vue +74 -3
- package/src/desktop/DesktopPreview.vue +10 -2
- package/src/desktop/DesktopShell.vue +31 -3
- package/src/desktop/DirectoryBrowser.vue +94 -33
- package/src/desktop/ui/PcDialog.vue +23 -8
- 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();
|
|
@@ -1,7 +1,50 @@
|
|
|
1
1
|
<template>
|
|
2
|
-
<form class="desktop-composer" @submit.prevent>
|
|
2
|
+
<form class="desktop-composer" :data-hero="hero ? 'true' : undefined" @submit.prevent>
|
|
3
3
|
<div class="desktop-composer__model-row">
|
|
4
|
-
<LaresPcPopover
|
|
4
|
+
<LaresPcPopover
|
|
5
|
+
v-if="hero"
|
|
6
|
+
v-model="workspaceOpen"
|
|
7
|
+
class="desktop-composer__workspace-slot"
|
|
8
|
+
:width="240"
|
|
9
|
+
placement="top-start"
|
|
10
|
+
>
|
|
11
|
+
<template #trigger="{ toggle }">
|
|
12
|
+
<button
|
|
13
|
+
type="button"
|
|
14
|
+
class="desktop-composer__model desktop-composer__workspace"
|
|
15
|
+
:disabled="!state.workspacesReady"
|
|
16
|
+
aria-haspopup="menu"
|
|
17
|
+
:aria-expanded="workspaceOpen"
|
|
18
|
+
@mousedown.prevent
|
|
19
|
+
@click="toggle"
|
|
20
|
+
>
|
|
21
|
+
<svg viewBox="0 0 24 24" aria-hidden="true">
|
|
22
|
+
<path d="M3.5 7.5h6l1.6 2H20.5v9H3.5z" />
|
|
23
|
+
</svg>
|
|
24
|
+
<span>{{ workspaceLabel }}</span>
|
|
25
|
+
<ChevronIcon />
|
|
26
|
+
</button>
|
|
27
|
+
</template>
|
|
28
|
+
<div class="desktop-composer__menu">
|
|
29
|
+
<button
|
|
30
|
+
v-for="workspace in state.workspaces"
|
|
31
|
+
:key="workspace.workspaceId"
|
|
32
|
+
type="button"
|
|
33
|
+
class="desktop-composer__option"
|
|
34
|
+
:data-selected="workspace.workspaceId === state.currentWorkspaceId"
|
|
35
|
+
@click="pickWorkspace(workspace.workspaceId)"
|
|
36
|
+
>
|
|
37
|
+
<span>{{ workspace.title || defaultWorkspaceTitle }}</span>
|
|
38
|
+
<CheckIcon v-if="workspace.workspaceId === state.currentWorkspaceId" />
|
|
39
|
+
</button>
|
|
40
|
+
</div>
|
|
41
|
+
</LaresPcPopover>
|
|
42
|
+
<LaresPcPopover
|
|
43
|
+
v-model="modelOpen"
|
|
44
|
+
class="desktop-composer__model-slot"
|
|
45
|
+
:width="260"
|
|
46
|
+
placement="top-end"
|
|
47
|
+
>
|
|
5
48
|
<template #trigger="{ toggle }">
|
|
6
49
|
<button
|
|
7
50
|
type="button"
|
|
@@ -397,6 +440,7 @@
|
|
|
397
440
|
<script>
|
|
398
441
|
import { h } from "vue";
|
|
399
442
|
import { resolveSubmitMode } from "@olares/lares-core/larepass/submission-settings";
|
|
443
|
+
import { DEFAULT_WORKSPACE_TITLE } from "@olares/lares-core/workspace/constants";
|
|
400
444
|
import { activeReferenceToken, insertReference } from "@olares/lares-core/larepass/references";
|
|
401
445
|
import { draftImageStatus } from "@olares/lares-core/files/draft-images";
|
|
402
446
|
import LaresDesktopQueue from "./DesktopQueue.vue";
|
|
@@ -456,16 +500,18 @@ export default {
|
|
|
456
500
|
props: {
|
|
457
501
|
state: { type: Object, required: true },
|
|
458
502
|
t: { type: Function, required: true },
|
|
503
|
+
hero: { type: Boolean, default: false },
|
|
459
504
|
modelKey: { type: Function, required: true },
|
|
460
505
|
effortName: { type: Function, required: true },
|
|
461
506
|
},
|
|
462
507
|
emits: [
|
|
463
508
|
"update-draft", "send", "stop", "steer-queue", "files", "choose-model", "choose-effort", "answer-approval", "answer-questions",
|
|
464
|
-
"choose-permission", "disable-plan", "goal-action", "open-commands", "pick-command", "queue-action", "remove-image",
|
|
509
|
+
"choose-permission", "disable-plan", "goal-action", "open-commands", "pick-command", "queue-action", "remove-image", "pick-workspace",
|
|
465
510
|
],
|
|
466
511
|
data() {
|
|
467
512
|
return {
|
|
468
513
|
modelOpen: false,
|
|
514
|
+
workspaceOpen: false,
|
|
469
515
|
effortOpen: false,
|
|
470
516
|
permissionOpen: false,
|
|
471
517
|
permissionConfirm: false,
|
|
@@ -484,6 +530,16 @@ export default {
|
|
|
484
530
|
};
|
|
485
531
|
},
|
|
486
532
|
computed: {
|
|
533
|
+
defaultWorkspaceTitle() {
|
|
534
|
+
return DEFAULT_WORKSPACE_TITLE;
|
|
535
|
+
},
|
|
536
|
+
workspaceLabel() {
|
|
537
|
+
if (!this.state.workspacesReady) return this.t("agent.loading");
|
|
538
|
+
const current = this.state.workspaces.find(
|
|
539
|
+
(row) => row.workspaceId === this.state.currentWorkspaceId,
|
|
540
|
+
);
|
|
541
|
+
return current?.title || this.state.workspaces[0]?.title || DEFAULT_WORKSPACE_TITLE;
|
|
542
|
+
},
|
|
487
543
|
/** Held by id, so removing or sending the draft closes the dialog. */
|
|
488
544
|
previewImage() {
|
|
489
545
|
return this.state.draftImages.find((row) => row.id === this.previewImageId) ?? null;
|
|
@@ -524,6 +580,7 @@ export default {
|
|
|
524
580
|
return this.t("chat.placeholderSteerQueue", { shortcut });
|
|
525
581
|
}
|
|
526
582
|
if (this.state.planActive) return this.t("chat.placeholderPlan");
|
|
583
|
+
if (this.hero) return this.t("chat.placeholderNewSession");
|
|
527
584
|
return this.t("chat.placeholderDesktop");
|
|
528
585
|
},
|
|
529
586
|
},
|
|
@@ -544,6 +601,11 @@ export default {
|
|
|
544
601
|
window.removeEventListener("mousedown", this.onComposerOverlayPointer, true);
|
|
545
602
|
},
|
|
546
603
|
methods: {
|
|
604
|
+
pickWorkspace(workspaceId) {
|
|
605
|
+
this.workspaceOpen = false;
|
|
606
|
+
if (!workspaceId || workspaceId === this.state.currentWorkspaceId) return;
|
|
607
|
+
this.$emit("pick-workspace", workspaceId);
|
|
608
|
+
},
|
|
547
609
|
chooseModel(model) {
|
|
548
610
|
this.modelOpen = false;
|
|
549
611
|
this.$emit("choose-model", model);
|
|
@@ -768,12 +830,21 @@ export default {
|
|
|
768
830
|
padding: 8px 0 20px;
|
|
769
831
|
}
|
|
770
832
|
.desktop-composer__model-row { display:flex; justify-content:flex-end; gap:2px; min-height:28px; margin-bottom:6px; }
|
|
833
|
+
.desktop-composer[data-hero="true"] .desktop-composer__model-row { justify-content:space-between; }
|
|
771
834
|
.desktop-composer__model,
|
|
772
835
|
.desktop-composer__circle,
|
|
773
836
|
.desktop-composer__send {
|
|
774
837
|
border:0; color:var(--q-ink-2); cursor:pointer;
|
|
775
838
|
}
|
|
776
839
|
.desktop-composer__model { display:flex; align-items:center; gap:4px; border-radius:999px; padding:4px 10px; background:transparent; font-size:14px; }
|
|
840
|
+
/* The model name is the long label of the pair, so it is the one that gives way;
|
|
841
|
+
without a floor of its own the workspace chip absorbs every pixel of shrink. */
|
|
842
|
+
.desktop-composer__workspace-slot { flex:0 0 auto; max-width:45%; }
|
|
843
|
+
.desktop-composer__model-slot { min-width:0; }
|
|
844
|
+
.desktop-composer__workspace,
|
|
845
|
+
.desktop-composer__model-slot .desktop-composer__model { min-width:0; max-width:100%; }
|
|
846
|
+
.desktop-composer__workspace span,
|
|
847
|
+
.desktop-composer__model-slot .desktop-composer__model span { min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
|
777
848
|
.desktop-composer__model svg,
|
|
778
849
|
.desktop-composer__permission svg,
|
|
779
850
|
.desktop-composer__circle svg,
|
|
@@ -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>
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
@archive-session="$emit('archive-session', $event)"
|
|
17
17
|
/>
|
|
18
18
|
<main class="desktop-shell__main">
|
|
19
|
-
<header class="desktop-shell__header">
|
|
19
|
+
<header v-if="!newSessionHero" class="desktop-shell__header">
|
|
20
20
|
<h1>{{ state.sessionTitle }}</h1>
|
|
21
21
|
<Transition name="lares-tabs">
|
|
22
22
|
<LaresDesktopPreviewTabs
|
|
@@ -30,15 +30,21 @@
|
|
|
30
30
|
/>
|
|
31
31
|
</Transition>
|
|
32
32
|
</header>
|
|
33
|
-
<section class="desktop-shell__conversation">
|
|
33
|
+
<section class="desktop-shell__conversation" :data-hero="newSessionHero ? 'true' : 'false'">
|
|
34
34
|
<div v-if="state.failed" class="desktop-shell__failure">
|
|
35
35
|
<span>{{ state.failText }}</span>
|
|
36
36
|
<button type="button" :disabled="state.starting" @click="$emit('retry')">{{ t("probe.retry") }}</button>
|
|
37
37
|
</div>
|
|
38
|
+
<div v-if="newSessionHero" class="desktop-shell__brand">
|
|
39
|
+
<div class="desktop-shell__logo" :style="logoStyle" aria-hidden="true" />
|
|
40
|
+
<h1>{{ t("shell.title") }}</h1>
|
|
41
|
+
</div>
|
|
38
42
|
<LaresTranscript
|
|
43
|
+
v-if="!newSessionHero"
|
|
39
44
|
ref="transcript"
|
|
40
45
|
:items="state.viewItems"
|
|
41
46
|
:running="state.running"
|
|
47
|
+
:running-since="state.runningSince"
|
|
42
48
|
:loading="state.historyLoading || state.starting"
|
|
43
49
|
:previews="state.previews"
|
|
44
50
|
:media-url="mediaUrl"
|
|
@@ -58,6 +64,7 @@
|
|
|
58
64
|
ref="composer"
|
|
59
65
|
:state="state"
|
|
60
66
|
:t="t"
|
|
67
|
+
:hero="newSessionHero"
|
|
61
68
|
:model-key="modelKey"
|
|
62
69
|
:effort-name="effortName"
|
|
63
70
|
@update-draft="$emit('update-draft', $event)"
|
|
@@ -67,6 +74,7 @@
|
|
|
67
74
|
@files="$emit('files', $event)"
|
|
68
75
|
@remove-image="$emit('remove-image', $event)"
|
|
69
76
|
@choose-model="$emit('choose-model', $event)"
|
|
77
|
+
@pick-workspace="$emit('pick-workspace', $event)"
|
|
70
78
|
@choose-effort="$emit('choose-effort', $event)"
|
|
71
79
|
@choose-permission="$emit('choose-permission', $event)"
|
|
72
80
|
@disable-plan="$emit('disable-plan')"
|
|
@@ -88,6 +96,7 @@
|
|
|
88
96
|
:error="state.preview.error"
|
|
89
97
|
:media-src="state.previewMediaSrc"
|
|
90
98
|
:download-href="state.previewDownloadHref"
|
|
99
|
+
:files-href="state.previewFilesHref"
|
|
91
100
|
:href-for="previewHref"
|
|
92
101
|
:t="t"
|
|
93
102
|
@retry="$emit('open-file', state.preview.path)"
|
|
@@ -115,6 +124,7 @@ import LaresDesktopPreviewTabs from "./DesktopPreviewTabs.vue";
|
|
|
115
124
|
import LaresDesktopSidebar from "./DesktopSidebar.vue";
|
|
116
125
|
import LaresDesktopComposer from "./DesktopComposer.vue";
|
|
117
126
|
import LaresDirectoryBrowser from "./DirectoryBrowser.vue";
|
|
127
|
+
import { MARK_DATA_URI } from "@olares/lares-core/icons/mark";
|
|
118
128
|
|
|
119
129
|
export default {
|
|
120
130
|
name: "LaresDesktopShell",
|
|
@@ -138,7 +148,7 @@ export default {
|
|
|
138
148
|
effortName: { type: Function, required: true },
|
|
139
149
|
},
|
|
140
150
|
emits: [
|
|
141
|
-
"create", "pick-session", "retry", "open-file", "answer", "update-draft",
|
|
151
|
+
"create", "pick-session", "pick-workspace", "retry", "open-file", "answer", "update-draft",
|
|
142
152
|
"send", "stop", "steer-queue", "files", "remove-image", "choose-model", "choose-effort", "choose-permission", "disable-plan", "goal-action", "answer-approval", "answer-questions",
|
|
143
153
|
"open-commands", "open-references", "pick-command", "queue-action", "show-chat", "close-preview-tab",
|
|
144
154
|
"refresh-workspaces", "browse-workspaces", "browse-directory",
|
|
@@ -146,6 +156,20 @@ export default {
|
|
|
146
156
|
"delete-workspace", "move-workspace", "move-session", "rename-session",
|
|
147
157
|
"fork-session", "archive-session",
|
|
148
158
|
],
|
|
159
|
+
computed: {
|
|
160
|
+
newSessionHero() {
|
|
161
|
+
if (this.state.previewMode === "preview" && this.state.preview.path) return false;
|
|
162
|
+
return (
|
|
163
|
+
this.state.viewItems.length === 0
|
|
164
|
+
&& !this.state.running
|
|
165
|
+
&& !this.state.historyLoading
|
|
166
|
+
&& !this.state.starting
|
|
167
|
+
);
|
|
168
|
+
},
|
|
169
|
+
logoStyle() {
|
|
170
|
+
return { backgroundImage: MARK_DATA_URI };
|
|
171
|
+
},
|
|
172
|
+
},
|
|
149
173
|
methods: {
|
|
150
174
|
focusComposer() {
|
|
151
175
|
this.$refs.composer?.focus?.();
|
|
@@ -188,6 +212,10 @@ export default {
|
|
|
188
212
|
.desktop-shell__header { display:flex; height:44px; flex-shrink:0; align-items:center; gap:16px; padding:0 18px; border-bottom:1px solid var(--q-separator); }
|
|
189
213
|
.desktop-shell__header h1 { max-width:min(280px, 40%); overflow:hidden; margin:0; font-size:15px; font-weight:500; line-height:22px; text-overflow:ellipsis; white-space:nowrap; }
|
|
190
214
|
.desktop-shell__conversation { position:relative; display:flex; min-width:0; min-height:0; flex:1; flex-direction:column; }
|
|
215
|
+
.desktop-shell__conversation[data-hero="true"] { justify-content:center; }
|
|
216
|
+
.desktop-shell__brand { display:flex; align-items:center; justify-content:center; gap:12px; margin-bottom:36px; }
|
|
217
|
+
.desktop-shell__brand h1 { margin:0; color:var(--q-ink-1); font-size:26px; font-weight:600; line-height:34px; letter-spacing:-.01em; }
|
|
218
|
+
.desktop-shell__logo { width:44px; height:44px; background-position:center; background-repeat:no-repeat; background-size:cover; box-shadow:inset 0 1.5px 0 rgb(255 255 255 / 50%); }
|
|
191
219
|
.desktop-shell__failure { display:flex; width:calc(var(--lares-column) - 2 * var(--lares-gutter)); align-items:center; gap:10px; margin:12px auto 0; color:var(--q-orange-default); font-size:14px; }
|
|
192
220
|
.desktop-shell__failure button { border:0; border-radius:6px; padding:5px 8px; background:var(--q-blue-alpha); color:var(--q-blue-default); }
|
|
193
221
|
/* The scroller stays full width so the wheel works over the gutters; the rows carry the reading column. */
|