@olares/lares-mobile 0.15.0

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.
@@ -0,0 +1,84 @@
1
+ <template>
2
+ <header class="lares-bar">
3
+ <button type="button" class="lares-bar__icon-btn" :aria-label="t('bar.menu')" @click="$emit('history')">
4
+ <svg class="lares-bar__glyph" viewBox="0 0 24 24" aria-hidden="true">
5
+ <path d="M5 9h14M5 15h14" />
6
+ </svg>
7
+ </button>
8
+ <h1 class="lares-bar__title">{{ title }}</h1>
9
+ <button
10
+ type="button"
11
+ class="lares-bar__icon-btn"
12
+ :disabled="starting"
13
+ :aria-label="t('bar.newAria')"
14
+ @click="$emit('create')"
15
+ >
16
+ <svg class="lares-bar__glyph" viewBox="0 0 24 24" aria-hidden="true">
17
+ <circle cx="12" cy="12" r="8.25" />
18
+ <path d="M12 8.5v7M8.5 12h7" />
19
+ </svg>
20
+ </button>
21
+ </header>
22
+ </template>
23
+
24
+ <script>
25
+ export default {
26
+ name: "LaresChatBar",
27
+ props: {
28
+ t: { type: Function, required: true },
29
+ title: { type: String, default: "" },
30
+ starting: { type: Boolean, default: false },
31
+ },
32
+ emits: ["history", "create"],
33
+ };
34
+ </script>
35
+
36
+ <style scoped>
37
+ .lares-bar {
38
+ display: grid;
39
+ grid-template-columns: 44px 1fr 44px;
40
+ align-items: center;
41
+ flex-shrink: 0;
42
+ min-height: 56px;
43
+ padding: 6px 8px;
44
+ }
45
+
46
+ .lares-bar__title {
47
+ max-width: 100%;
48
+ margin: 0;
49
+ overflow: hidden;
50
+ font-size: 16px;
51
+ font-weight: 600;
52
+ line-height: 22px;
53
+ text-align: center;
54
+ text-overflow: ellipsis;
55
+ white-space: nowrap;
56
+ }
57
+
58
+ .lares-bar__icon-btn {
59
+ display: inline-flex;
60
+ align-items: center;
61
+ justify-content: center;
62
+ width: 40px;
63
+ height: 40px;
64
+ border: 0;
65
+ border-radius: 999px;
66
+ padding: 0;
67
+ background: transparent;
68
+ color: var(--q-ink-1);
69
+ }
70
+
71
+ .lares-bar__glyph {
72
+ width: 22px;
73
+ height: 22px;
74
+ fill: none;
75
+ stroke: currentColor;
76
+ stroke-width: 1.75;
77
+ stroke-linecap: round;
78
+ stroke-linejoin: round;
79
+ }
80
+
81
+ .lares-bar__icon-btn:active {
82
+ background: var(--q-btn-bg-pressed);
83
+ }
84
+ </style>
@@ -0,0 +1,431 @@
1
+ <template>
2
+ <form class="lares-composer" @submit.prevent="onSend">
3
+ <div class="lares-composer__card" data-composer-card :data-holding="holding ? 'true' : 'false'">
4
+ <textarea
5
+ ref="input"
6
+ :value="draft"
7
+ class="lares-composer__input"
8
+ rows="1"
9
+ :placeholder="t('chat.placeholder')"
10
+ :disabled="sending"
11
+ @input="onInput"
12
+ @keydown="onKeydown"
13
+ @pointerdown="onHoldDown"
14
+ @pointerup="onHoldUp"
15
+ @pointercancel="onHoldUp"
16
+ @contextmenu="onContextMenu"
17
+ />
18
+ <p v-if="holding" class="lares-composer__hold">{{ t("composer.hold") }} · {{ elapsed }}</p>
19
+ <div class="lares-composer__bar">
20
+ <div class="lares-composer__pills">
21
+ <button
22
+ type="button"
23
+ class="lares-composer__pill"
24
+ :disabled="modelBusy"
25
+ :aria-label="modelAria"
26
+ @click="$emit('model')"
27
+ >
28
+ <svg viewBox="0 0 24 24" aria-hidden="true">
29
+ <rect x="4" y="5" width="16" height="5" rx="1.2" />
30
+ <rect x="4" y="14" width="16" height="5" rx="1.2" />
31
+ </svg>
32
+ <span>{{ modelLabel }}</span>
33
+ </button>
34
+ <button
35
+ v-if="!effortDisabled"
36
+ type="button"
37
+ class="lares-composer__pill"
38
+ :disabled="modelBusy"
39
+ :aria-label="effortAria"
40
+ @click="$emit('effort')"
41
+ >
42
+ <svg viewBox="0 0 24 24" aria-hidden="true">
43
+ <circle class="lares-composer__nucleus" cx="12" cy="12" r="2.5" />
44
+ <ellipse cx="12" cy="12" rx="9.2" ry="3.8" />
45
+ <ellipse cx="12" cy="12" rx="9.2" ry="3.8" transform="rotate(60 12 12)" />
46
+ <ellipse cx="12" cy="12" rx="9.2" ry="3.8" transform="rotate(-60 12 12)" />
47
+ </svg>
48
+ <span>{{ effortLabel }}</span>
49
+ </button>
50
+ </div>
51
+ <div class="lares-composer__tools">
52
+ <input
53
+ ref="picker"
54
+ type="file"
55
+ multiple
56
+ class="lares-composer__picker"
57
+ @change="onFiles"
58
+ />
59
+ <button
60
+ type="button"
61
+ class="lares-composer__icon"
62
+ :disabled="attachDisabled"
63
+ :aria-label="attachLabel"
64
+ @click="pickFiles"
65
+ >
66
+ <svg viewBox="0 0 24 24" aria-hidden="true">
67
+ <path d="M12 5v14M5 12h14" />
68
+ </svg>
69
+ </button>
70
+ <button
71
+ v-if="showSend"
72
+ type="submit"
73
+ class="lares-composer__icon"
74
+ data-kind="send"
75
+ :disabled="sending || !canSend"
76
+ :aria-label="t('chat.send')"
77
+ >
78
+ <svg viewBox="0 0 24 24" aria-hidden="true">
79
+ <path d="M12 19V7M6.5 12.5 12 7l5.5 5.5" />
80
+ </svg>
81
+ </button>
82
+ <button
83
+ v-else
84
+ type="button"
85
+ class="lares-composer__icon"
86
+ data-kind="voice"
87
+ :data-phase="voicePhase"
88
+ :disabled="voiceBlocked"
89
+ :aria-label="voiceLabel"
90
+ @click="$emit('voice')"
91
+ >
92
+ <span v-if="voicePhase === 'recording'" class="lares-composer__live">{{ elapsed }}</span>
93
+ <svg v-else-if="voicePhase === 'transcribing'" class="lares-composer__spin" viewBox="0 0 16 16" aria-hidden="true">
94
+ <circle cx="8" cy="8" r="6" />
95
+ </svg>
96
+ <svg v-else class="lares-composer__wave" viewBox="0 0 24 24" aria-hidden="true">
97
+ <path d="M6.5 9v6" />
98
+ <path d="M12 4.5v15" />
99
+ <path d="M17.5 9v6" />
100
+ </svg>
101
+ </button>
102
+ </div>
103
+ </div>
104
+ </div>
105
+ <p v-if="voiceError" class="lares-composer__hint" data-status="error">{{ voiceError }}</p>
106
+ <p v-for="item in failures" :key="item.id" class="lares-composer__hint" data-status="error">
107
+ {{ t("upload.failed", { name: item.name, reason: t(`error.${item.code}`) }) }}
108
+ </p>
109
+ </form>
110
+ </template>
111
+
112
+ <script>
113
+ import { formatElapsed } from "./voice.js";
114
+
115
+ const HOLD_MS = 240;
116
+
117
+ export default {
118
+ name: "LaresComposer",
119
+ props: {
120
+ draft: { type: String, default: "" },
121
+ sending: { type: Boolean, default: false },
122
+ canSend: { type: Boolean, default: false },
123
+ modelLabel: { type: String, default: "" },
124
+ modelBusy: { type: Boolean, default: false },
125
+ effortLabel: { type: String, default: "" },
126
+ effortDisabled: { type: Boolean, default: false },
127
+ attachPending: { type: Boolean, default: false },
128
+ attachDisabled: { type: Boolean, default: false },
129
+ voicePhase: { type: String, default: "idle" },
130
+ voiceElapsed: { type: Number, default: 0 },
131
+ voiceError: { type: String, default: "" },
132
+ failures: { type: Array, default: () => [] },
133
+ t: { type: Function, required: true },
134
+ },
135
+ emits: ["update:draft", "send", "model", "effort", "files", "voice", "hold-start", "hold-end"],
136
+ data() {
137
+ return { holding: false, holdTimer: 0 };
138
+ },
139
+ computed: {
140
+ showSend() {
141
+ return Boolean(this.draft.trim()) && this.voicePhase !== "recording" && !this.holding;
142
+ },
143
+ elapsed() {
144
+ return formatElapsed(this.voiceElapsed);
145
+ },
146
+ modelAria() {
147
+ return this.modelLabel ? this.t("model.switchAria", { label: this.modelLabel }) : this.t("model.select");
148
+ },
149
+ effortAria() {
150
+ return this.t("reasoning.switchAria", { label: this.effortLabel || this.t("reasoning.default") });
151
+ },
152
+ attachLabel() {
153
+ return this.attachPending ? this.t("button.uploading", { count: 1 }) : this.t("composer.attach");
154
+ },
155
+ voiceLabel() {
156
+ if (this.voiceError) return this.voiceError;
157
+ if (this.voicePhase === "recording") return this.t("mic.stop");
158
+ if (this.voicePhase === "transcribing") return this.t("mic.transcribing");
159
+ return this.t("composer.voice");
160
+ },
161
+ voiceBlocked() {
162
+ return this.voicePhase === "transcribing" || this.sending;
163
+ },
164
+ },
165
+ watch: {
166
+ draft() {
167
+ this.autosize();
168
+ },
169
+ },
170
+ mounted() {
171
+ this.autosize();
172
+ },
173
+ beforeUnmount() {
174
+ this.clearHold();
175
+ },
176
+ methods: {
177
+ onInput(event) {
178
+ this.$emit("update:draft", event.target.value);
179
+ },
180
+ onSend() {
181
+ if (this.canSend) this.$emit("send");
182
+ },
183
+ onKeydown(event) {
184
+ if (event.key !== "Enter" || event.shiftKey || event.isComposing) return;
185
+ if (typeof window !== "undefined" && window.matchMedia?.("(pointer: coarse)").matches) return;
186
+ event.preventDefault();
187
+ this.onSend();
188
+ },
189
+ canHold(event) {
190
+ if (this.draft.trim() || this.sending || this.voiceBlocked) return false;
191
+ if (event.pointerType === "mouse") return false;
192
+ return true;
193
+ },
194
+ onContextMenu(event) {
195
+ if (typeof window !== "undefined" && window.matchMedia?.("(pointer: coarse)").matches) {
196
+ event.preventDefault();
197
+ }
198
+ },
199
+ onHoldDown(event) {
200
+ if (!this.canHold(event)) return;
201
+ event.currentTarget.setPointerCapture?.(event.pointerId);
202
+ this.holdTimer = window.setTimeout(() => {
203
+ this.holding = true;
204
+ this.$refs.input?.blur();
205
+ this.$emit("hold-start");
206
+ }, HOLD_MS);
207
+ },
208
+ onHoldUp(event) {
209
+ event.currentTarget.releasePointerCapture?.(event.pointerId);
210
+ this.clearHold(true);
211
+ },
212
+ clearHold(emitEnd = false) {
213
+ if (this.holdTimer) {
214
+ clearTimeout(this.holdTimer);
215
+ this.holdTimer = 0;
216
+ }
217
+ if (this.holding) {
218
+ this.holding = false;
219
+ if (emitEnd) this.$emit("hold-end");
220
+ }
221
+ },
222
+ pickFiles() {
223
+ this.$refs.picker?.click();
224
+ },
225
+ onFiles(event) {
226
+ const files = Array.from(event.currentTarget.files ?? []);
227
+ event.currentTarget.value = "";
228
+ if (files.length) this.$emit("files", files);
229
+ },
230
+ autosize() {
231
+ this.$nextTick(() => {
232
+ const el = this.$refs.input;
233
+ if (!el) return;
234
+ el.style.height = "auto";
235
+ el.style.height = `${Math.min(el.scrollHeight, 160)}px`;
236
+ });
237
+ },
238
+ },
239
+ };
240
+ </script>
241
+
242
+ <style scoped>
243
+ .lares-composer {
244
+ display: flex;
245
+ flex-direction: column;
246
+ gap: 6px;
247
+ flex-shrink: 0;
248
+ padding: 8px 16px calc(12px + env(safe-area-inset-bottom, 0px));
249
+ }
250
+
251
+ .lares-composer__card {
252
+ display: flex;
253
+ flex-direction: column;
254
+ gap: 6px;
255
+ border: 1px solid var(--q-input-stroke);
256
+ border-radius: 24px;
257
+ padding: 12px 14px 10px;
258
+ background: var(--q-background-1);
259
+ }
260
+
261
+ .lares-composer__input {
262
+ width: 100%;
263
+ min-height: 24px;
264
+ max-height: 160px;
265
+ resize: none;
266
+ overflow-y: auto;
267
+ border: 0;
268
+ padding: 2px 2px 4px;
269
+ background: transparent;
270
+ line-height: 1.45;
271
+ }
272
+
273
+ .lares-composer__input:focus {
274
+ outline: none;
275
+ }
276
+
277
+ .lares-composer__hold {
278
+ margin: 0;
279
+ color: var(--q-blue-default);
280
+ font-size: 13px;
281
+ line-height: 18px;
282
+ }
283
+
284
+ .lares-composer__bar {
285
+ display: flex;
286
+ align-items: center;
287
+ gap: 8px;
288
+ min-height: 32px;
289
+ }
290
+
291
+ .lares-composer__pills {
292
+ display: flex;
293
+ align-items: center;
294
+ flex-wrap: wrap;
295
+ gap: 6px;
296
+ min-width: 0;
297
+ }
298
+
299
+ .lares-composer__pill {
300
+ display: inline-flex;
301
+ align-items: center;
302
+ gap: 6px;
303
+ box-sizing: border-box;
304
+ width: 98px;
305
+ flex: 0 0 98px;
306
+ height: 32px;
307
+ border: 0;
308
+ border-radius: 999px;
309
+ padding: 0 10px 0 8px;
310
+ background: var(--q-background-3);
311
+ color: var(--q-ink-2);
312
+ font-size: 13px;
313
+ font-weight: 500;
314
+ line-height: 16px;
315
+ white-space: nowrap;
316
+ }
317
+
318
+ .lares-composer__pill span {
319
+ min-width: 0;
320
+ overflow: hidden;
321
+ text-overflow: ellipsis;
322
+ }
323
+
324
+ .lares-composer__pill svg {
325
+ width: 20px;
326
+ height: 20px;
327
+ flex-shrink: 0;
328
+ fill: none;
329
+ stroke: currentColor;
330
+ stroke-width: 1.8;
331
+ stroke-linecap: round;
332
+ stroke-linejoin: round;
333
+ }
334
+
335
+ .lares-composer__pill svg .lares-composer__nucleus {
336
+ fill: currentColor;
337
+ }
338
+
339
+ .lares-composer__tools {
340
+ display: flex;
341
+ align-items: center;
342
+ gap: 16px;
343
+ margin-left: auto;
344
+ flex-shrink: 0;
345
+ }
346
+
347
+ .lares-composer__picker {
348
+ display: none;
349
+ }
350
+
351
+ .lares-composer__icon {
352
+ display: inline-flex;
353
+ align-items: center;
354
+ justify-content: center;
355
+ width: 32px;
356
+ height: 32px;
357
+ border: 1.5px solid currentColor;
358
+ border-radius: 999px;
359
+ padding: 0;
360
+ background: transparent;
361
+ color: var(--q-ink-1);
362
+ }
363
+
364
+ .lares-composer__icon:active {
365
+ background: var(--q-btn-bg-pressed);
366
+ }
367
+
368
+ .lares-composer__icon[data-kind="send"],
369
+ .lares-composer__icon[data-kind="voice"] {
370
+ border-color: transparent;
371
+ color: var(--q-ink-on-brand);
372
+ }
373
+
374
+ .lares-composer__icon[data-kind="send"] {
375
+ background: var(--q-blue-default);
376
+ }
377
+
378
+ .lares-composer__icon[data-kind="voice"] {
379
+ background: var(--q-ink-1);
380
+ }
381
+
382
+ .lares-composer__icon svg {
383
+ width: 20px;
384
+ height: 20px;
385
+ fill: none;
386
+ stroke: currentColor;
387
+ stroke-width: 2;
388
+ stroke-linecap: round;
389
+ stroke-linejoin: round;
390
+ }
391
+
392
+ .lares-composer__icon svg.lares-composer__wave {
393
+ width: 18px;
394
+ height: 18px;
395
+ stroke-width: 2.75;
396
+ }
397
+
398
+ .lares-composer__live {
399
+ font-size: 10px;
400
+ font-variant-numeric: tabular-nums;
401
+ }
402
+
403
+ .lares-composer__spin {
404
+ animation: lares-composer-spin 0.8s linear infinite;
405
+ }
406
+
407
+ .lares-composer__spin circle {
408
+ fill: none;
409
+ stroke-dasharray: 24 12;
410
+ }
411
+
412
+ .lares-composer__hint {
413
+ margin: 0 4px;
414
+ font-size: 12px;
415
+ color: var(--q-ink-3);
416
+ }
417
+
418
+ .lares-composer__hint[data-status="error"] {
419
+ color: var(--q-orange-default);
420
+ }
421
+
422
+ @keyframes lares-composer-spin {
423
+ to { transform: rotate(360deg); }
424
+ }
425
+
426
+ @media (prefers-reduced-motion: reduce) {
427
+ .lares-composer__spin {
428
+ animation: none;
429
+ }
430
+ }
431
+ </style>
@@ -0,0 +1,167 @@
1
+ <template>
2
+ <section class="lares-turn-deliverables" :aria-label="t('produced')">
3
+ <p v-if="group.loading" class="lares-turn-media-loading">{{ t("mediaLoading") }}</p>
4
+ <div v-if="group.media.length" class="lares-turn-media-list">
5
+ <figure v-for="item in group.media" :key="item.path" class="lares-turn-media">
6
+ <figcaption class="lares-turn-media-caption">
7
+ <span :title="item.path">{{ item.name }}</span>
8
+ <button type="button" class="lares-turn-open" @click="$emit('open', item.path)">{{ t("openInTab") }}</button>
9
+ </figcaption>
10
+ <img
11
+ v-if="item.kind === 'image'"
12
+ class="lares-turn-media-image"
13
+ :src="mediaUrl(item)"
14
+ :alt="item.name"
15
+ @load="$emit('media')"
16
+ @click="$emit('open', item.path)"
17
+ />
18
+ <video
19
+ v-else-if="item.kind === 'video'"
20
+ class="lares-turn-media-video"
21
+ :src="mediaUrl(item)"
22
+ controls
23
+ playsinline
24
+ />
25
+ <audio
26
+ v-else
27
+ class="lares-turn-media-audio"
28
+ :src="mediaUrl(item)"
29
+ controls
30
+ />
31
+ </figure>
32
+ </div>
33
+ <div v-if="group.files.length" class="lares-turn-files">
34
+ <span class="lares-turn-files-label">{{ t("produced") }}</span>
35
+ <div class="lares-turn-files-row">
36
+ <button
37
+ v-for="path in group.files"
38
+ :key="path"
39
+ type="button"
40
+ class="lares-turn-file"
41
+ :title="path"
42
+ @click="$emit('open', path)"
43
+ >
44
+ {{ fileName(path) }}
45
+ </button>
46
+ </div>
47
+ </div>
48
+ </section>
49
+ </template>
50
+
51
+ <script>
52
+ import { fileName } from "@olares/lares-core/files/preview-workspace";
53
+
54
+ export default {
55
+ name: "LaresDeliverables",
56
+ props: {
57
+ group: { type: Object, required: true },
58
+ mediaUrl: { type: Function, required: true },
59
+ t: { type: Function, required: true },
60
+ },
61
+ emits: ["open", "media"],
62
+ methods: { fileName },
63
+ };
64
+ </script>
65
+
66
+ <style scoped>
67
+ .lares-turn-deliverables {
68
+ display: flex;
69
+ min-width: 0;
70
+ flex-direction: column;
71
+ gap: 12px;
72
+ margin-top: 8px;
73
+ }
74
+
75
+ .lares-turn-media-loading {
76
+ margin: 0;
77
+ color: var(--q-ink-3);
78
+ font-size: 13px;
79
+ }
80
+
81
+ .lares-turn-media-list {
82
+ display: flex;
83
+ flex-direction: column;
84
+ gap: 12px;
85
+ }
86
+
87
+ .lares-turn-media {
88
+ display: flex;
89
+ width: fit-content;
90
+ max-width: 100%;
91
+ flex-direction: column;
92
+ gap: 8px;
93
+ margin: 0;
94
+ padding: 10px;
95
+ overflow: hidden;
96
+ border: 1px solid var(--q-separator);
97
+ border-radius: 14px;
98
+ background: var(--q-background-1);
99
+ }
100
+
101
+ .lares-turn-media-caption {
102
+ display: flex;
103
+ min-width: 0;
104
+ align-items: center;
105
+ justify-content: space-between;
106
+ gap: 12px;
107
+ color: var(--q-ink-2);
108
+ font-size: 12px;
109
+ }
110
+
111
+ .lares-turn-media-caption > span {
112
+ min-width: 0;
113
+ overflow: hidden;
114
+ text-overflow: ellipsis;
115
+ white-space: nowrap;
116
+ }
117
+
118
+ .lares-turn-open,
119
+ .lares-turn-file {
120
+ border: 0;
121
+ background: transparent;
122
+ color: var(--q-blue-default);
123
+ }
124
+
125
+ .lares-turn-media-image,
126
+ .lares-turn-media-video {
127
+ display: block;
128
+ max-width: min(100%, 640px);
129
+ max-height: 420px;
130
+ border-radius: 10px;
131
+ object-fit: contain;
132
+ }
133
+
134
+ .lares-turn-media-audio {
135
+ display: block;
136
+ width: min(520px, 100%);
137
+ }
138
+
139
+ .lares-turn-files {
140
+ display: grid;
141
+ grid-template-columns: max-content minmax(0, 1fr);
142
+ align-items: center;
143
+ gap: 8px;
144
+ font-size: 13px;
145
+ }
146
+
147
+ .lares-turn-files-label {
148
+ color: var(--q-ink-3);
149
+ }
150
+
151
+ .lares-turn-files-row {
152
+ display: flex;
153
+ flex-wrap: wrap;
154
+ gap: 8px;
155
+ }
156
+
157
+ .lares-turn-file {
158
+ max-width: 320px;
159
+ padding: 4px 8px;
160
+ overflow: hidden;
161
+ border-radius: 6px;
162
+ background: var(--q-background-hover);
163
+ color: var(--q-ink-2);
164
+ text-overflow: ellipsis;
165
+ white-space: nowrap;
166
+ }
167
+ </style>