@olares/lares-mobile 0.15.1 → 0.15.4
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/README.md +21 -1
- package/package.json +2 -2
- package/src/App.vue +54 -47
- package/src/chat/Composer.vue +6 -3
- package/src/chat/Deliverables.vue +13 -1
- package/src/host.js +37 -5
- package/src/index.d.ts +8 -0
- package/src/layout.js +8 -0
- package/src/preview/Preview.vue +3 -0
- package/src/settings/AgentSettings.vue +2 -0
- package/src/styles.css +15 -0
package/README.md
CHANGED
|
@@ -4,19 +4,39 @@ LarePass 只依赖这一个包。对话、Host、设置都在内部走 `@olares/
|
|
|
4
4
|
|
|
5
5
|
```vue
|
|
6
6
|
<template>
|
|
7
|
-
<LaresApp :locale="locale" v-bind="ports" />
|
|
7
|
+
<LaresApp :locale="locale" :device="device" v-bind="ports" />
|
|
8
8
|
</template>
|
|
9
9
|
<script setup>
|
|
10
10
|
import { LaresApp, laresPortsFromAccount, findLaresEntrance } from "@olares/lares-mobile";
|
|
11
11
|
</script>
|
|
12
12
|
```
|
|
13
13
|
|
|
14
|
+
`device` 为 `desktop` 时对话列居中、宽 748px;`mobile`(默认)保持全宽。
|
|
15
|
+
|
|
14
16
|
设置页同样只挂 `LaresAgentSettings`。
|
|
15
17
|
|
|
16
18
|
正式 App:把当前登录账号的 `myApps` 交给 `laresPortsFromAccount`(或自己调 `findLaresEntrance` 得到 `baseUrl`)。入口前缀是安装时随机的,不要拼 `lares.<用户>.olares.com`,也不要把 webpack `.env` 里的调试子域带进生产。
|
|
17
19
|
|
|
18
20
|
PC 预览:`IS_PC_TEST` 时走 `/laresHost` 代理,`laresPortsFromAccount` 会继续用 `PROTOCOL` / `LARES_SUB_DOMAIN` / `ACCOUNT_DOMAIN`。自定义鉴权请求再传 `request`。
|
|
19
21
|
|
|
22
|
+
宿主页面与 Host 跨源时(打包桌面端是 `file://`)必须传 `request`,浏览器不会带上 Olares cookie,靠 cookie 的默认实现一律 401。`request` 会用在所有调用上,包括聊天附件上传和语音转写这两个二进制请求——它们的 `body` 是 `File` / `Blob`,`headers` 里自带 `content-type`,实现里不要再当成 JSON 序列化。
|
|
23
|
+
|
|
24
|
+
```js
|
|
25
|
+
async function request(url, { method = "GET", body, headers, signal } = {}) {
|
|
26
|
+
// body 可能是 File / Blob,原样发出去
|
|
27
|
+
const res = await send(url, { method, body, headers, signal, token });
|
|
28
|
+
return { ok: res.ok, status: res.status, body: res.json }; // 4xx 也要返回,不要 throw
|
|
29
|
+
}
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
事件流 `/api/events.mux` 是唯一带不了 header 的调用——浏览器 `WebSocket` 构造器不收 header。Olares 会从 `Sec-WebSocket-Protocol` 取 token 提升成 `X-Authorization` 再走入口鉴权,所以另外传 `socketProtocol`:
|
|
33
|
+
|
|
34
|
+
```js
|
|
35
|
+
ports.socketProtocol = () => currentUser.access_token; // 传函数则每次连接现取,换 token 无需重建 runtime
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
也接受直接给字符串或数组。**不传就不传**,不要传空串——服务端不回应的子协议会让握手直接失败,同源的 PC 端保持裸构造。
|
|
39
|
+
|
|
20
40
|
切账号时更新 `baseUrl` / `env` 即可,客户端会丢掉上一台的 runtime 和设置缓存,改连新 Host。
|
|
21
41
|
|
|
22
42
|
本地联调(包未上 registry 时):
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@olares/lares-mobile",
|
|
3
|
-
"version": "0.15.
|
|
3
|
+
"version": "0.15.4",
|
|
4
4
|
"description": "Lares chat component for LarePass",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"repository": {
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
"vue": "^3.2.0"
|
|
29
29
|
},
|
|
30
30
|
"dependencies": {
|
|
31
|
-
"@olares/lares-core": "0.15.
|
|
31
|
+
"@olares/lares-core": "0.15.4",
|
|
32
32
|
"markdown-it": "^14.3.0"
|
|
33
33
|
}
|
|
34
34
|
}
|
package/src/App.vue
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
<template>
|
|
2
|
-
<div class="lares-shell">
|
|
2
|
+
<div class="lares-shell" :data-device="chatDevice">
|
|
3
3
|
<LaresChatBar
|
|
4
4
|
:t="t"
|
|
5
5
|
:title="sessionTitle"
|
|
@@ -16,52 +16,54 @@
|
|
|
16
16
|
@close="panel = ''"
|
|
17
17
|
@pick="pickSession"
|
|
18
18
|
/>
|
|
19
|
-
<
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
19
|
+
<div class="lares-shell__conversation">
|
|
20
|
+
<p v-if="failed" class="lares-shell__status">{{ failText }}</p>
|
|
21
|
+
<button v-if="failed" type="button" class="lares-shell__retry" :disabled="starting" @click="retry">
|
|
22
|
+
{{ t("probe.retry") }}
|
|
23
|
+
</button>
|
|
24
|
+
<LaresTranscript
|
|
25
|
+
ref="transcript"
|
|
26
|
+
:items="viewItems"
|
|
27
|
+
:running="running"
|
|
28
|
+
:loading="historyLoading || starting"
|
|
29
|
+
:previews="previews"
|
|
30
|
+
:media-url="mediaUrl"
|
|
31
|
+
:scroll-top="scrollTop"
|
|
32
|
+
:remember-scroll="rememberScroll"
|
|
33
|
+
:sticking="sticking"
|
|
34
|
+
:session-id="sessionId"
|
|
35
|
+
:question="question"
|
|
36
|
+
:question-busy="questionBusy"
|
|
37
|
+
:t="t"
|
|
38
|
+
@open="openFile"
|
|
39
|
+
@media="pinLog"
|
|
40
|
+
@answer="answerQuestion"
|
|
41
|
+
/>
|
|
42
|
+
<LaresComposer
|
|
43
|
+
:draft="draft"
|
|
44
|
+
:sending="sending"
|
|
45
|
+
:can-send="canSend"
|
|
46
|
+
:model-label="modelLabel"
|
|
47
|
+
:model-busy="modelBusy"
|
|
48
|
+
:effort-label="effortLabel"
|
|
49
|
+
:effort-disabled="effortDisabled"
|
|
50
|
+
:attach-pending="upload.pending > 0"
|
|
51
|
+
:attach-disabled="!sessionId || historyLoading || starting"
|
|
52
|
+
:voice-phase="voicePhase"
|
|
53
|
+
:voice-elapsed="voiceElapsed"
|
|
54
|
+
:voice-error="voiceErrorText"
|
|
55
|
+
:failures="upload.failures"
|
|
56
|
+
:t="t"
|
|
57
|
+
@update:draft="draft = $event"
|
|
58
|
+
@send="send"
|
|
59
|
+
@model="openModelSheet"
|
|
60
|
+
@effort="openEffortSheet"
|
|
61
|
+
@files="attachFiles"
|
|
62
|
+
@voice="toggleVoice"
|
|
63
|
+
@hold-start="onHoldStart"
|
|
64
|
+
@hold-end="onHoldEnd"
|
|
65
|
+
/>
|
|
66
|
+
</div>
|
|
65
67
|
<LaresSheet :open="modelSheet" :title="t('model.menuAria')" @close="modelSheet = false">
|
|
66
68
|
<p v-if="modelError" class="lares-shell__status">{{ modelError }}</p>
|
|
67
69
|
<p v-else-if="!models" class="lares-shell__hint">{{ t("agent.loading") }}</p>
|
|
@@ -118,6 +120,7 @@ import { DEFAULT_MAX_UPLOAD_BYTES } from "@olares/lares-core/files/limits";
|
|
|
118
120
|
import { groupModelsByProvider, effortMenuRows, reasoningOfModel, currentEffortId, selectionKey } from "@olares/lares-core/router/session-model";
|
|
119
121
|
import { messageFromCode } from "@olares/lares-core/i18n/t";
|
|
120
122
|
import { parseAskUserQuestions, singleSelectAnswer } from "@olares/lares-core/larepass/questions";
|
|
123
|
+
import { chatDevice as resolveChatDevice } from "./layout.js";
|
|
121
124
|
import LaresChatBar from "./chat/ChatBar.vue";
|
|
122
125
|
import LaresHistoryPanel from "./chat/HistoryPanel.vue";
|
|
123
126
|
import LaresTranscript from "./chat/Transcript.vue";
|
|
@@ -132,6 +135,7 @@ export default {
|
|
|
132
135
|
components: { LaresChatBar, LaresHistoryPanel, LaresTranscript, LaresComposer, LaresPreview, LaresSheet, LaresSettingRow },
|
|
133
136
|
props: {
|
|
134
137
|
locale: { type: String, default: "en" },
|
|
138
|
+
device: { type: String, default: "mobile" },
|
|
135
139
|
baseUrl: { type: String, default: undefined },
|
|
136
140
|
proxyPrefix: { type: String, default: undefined },
|
|
137
141
|
request: { type: Function, default: undefined },
|
|
@@ -190,6 +194,9 @@ export default {
|
|
|
190
194
|
t() {
|
|
191
195
|
return createT(this.locale);
|
|
192
196
|
},
|
|
197
|
+
chatDevice() {
|
|
198
|
+
return resolveChatDevice(this.device);
|
|
199
|
+
},
|
|
193
200
|
canSend() {
|
|
194
201
|
return Boolean(this.draft.trim()) && Boolean(this.sessionId) && !this.running && !this.historyLoading && this.upload.pending === 0;
|
|
195
202
|
},
|
package/src/chat/Composer.vue
CHANGED
|
@@ -68,7 +68,6 @@
|
|
|
68
68
|
</svg>
|
|
69
69
|
</button>
|
|
70
70
|
<button
|
|
71
|
-
v-if="showSend"
|
|
72
71
|
type="submit"
|
|
73
72
|
class="lares-composer__icon"
|
|
74
73
|
data-kind="send"
|
|
@@ -79,8 +78,9 @@
|
|
|
79
78
|
<path d="M12 19V7M6.5 12.5 12 7l5.5 5.5" />
|
|
80
79
|
</svg>
|
|
81
80
|
</button>
|
|
81
|
+
<!-- Voice input parked.
|
|
82
82
|
<button
|
|
83
|
-
v-
|
|
83
|
+
v-if="!showSend"
|
|
84
84
|
type="button"
|
|
85
85
|
class="lares-composer__icon"
|
|
86
86
|
data-kind="voice"
|
|
@@ -99,10 +99,11 @@
|
|
|
99
99
|
<path d="M17.5 9v6" />
|
|
100
100
|
</svg>
|
|
101
101
|
</button>
|
|
102
|
+
-->
|
|
102
103
|
</div>
|
|
103
104
|
</div>
|
|
104
105
|
</div>
|
|
105
|
-
<p v-if="voiceError" class="lares-composer__hint" data-status="error">{{ voiceError }}</p>
|
|
106
|
+
<!-- <p v-if="voiceError" class="lares-composer__hint" data-status="error">{{ voiceError }}</p> -->
|
|
106
107
|
<p v-for="item in failures" :key="item.id" class="lares-composer__hint" data-status="error">
|
|
107
108
|
{{ t("upload.failed", { name: item.name, reason: t(`error.${item.code}`) }) }}
|
|
108
109
|
</p>
|
|
@@ -187,6 +188,8 @@ export default {
|
|
|
187
188
|
this.onSend();
|
|
188
189
|
},
|
|
189
190
|
canHold(event) {
|
|
191
|
+
// Voice input parked (hold-to-record).
|
|
192
|
+
return false;
|
|
190
193
|
if (this.draft.trim() || this.sending || this.voiceBlocked) return false;
|
|
191
194
|
if (event.pointerType === "mouse") return false;
|
|
192
195
|
return true;
|
|
@@ -23,11 +23,15 @@
|
|
|
23
23
|
playsinline
|
|
24
24
|
/>
|
|
25
25
|
<audio
|
|
26
|
-
v-else
|
|
26
|
+
v-else-if="item.kind === 'audio'"
|
|
27
27
|
class="lares-turn-media-audio"
|
|
28
28
|
:src="mediaUrl(item)"
|
|
29
29
|
controls
|
|
30
30
|
/>
|
|
31
|
+
<div
|
|
32
|
+
v-else-if="item.kind === 'model3d'"
|
|
33
|
+
class="lares-turn-media-model3d"
|
|
34
|
+
>{{ t("model3dHint") }}</div>
|
|
31
35
|
</figure>
|
|
32
36
|
</div>
|
|
33
37
|
<div v-if="group.files.length" class="lares-turn-files">
|
|
@@ -136,6 +140,14 @@ export default {
|
|
|
136
140
|
width: min(520px, 100%);
|
|
137
141
|
}
|
|
138
142
|
|
|
143
|
+
.lares-turn-media-model3d {
|
|
144
|
+
width: min(520px, 100%);
|
|
145
|
+
padding: 16px;
|
|
146
|
+
color: var(--q-ink-3);
|
|
147
|
+
font-size: 13px;
|
|
148
|
+
line-height: 20px;
|
|
149
|
+
}
|
|
150
|
+
|
|
139
151
|
.lares-turn-files {
|
|
140
152
|
display: grid;
|
|
141
153
|
grid-template-columns: max-content minmax(0, 1fr);
|
package/src/host.js
CHANGED
|
@@ -15,7 +15,25 @@ function pageOrigin() {
|
|
|
15
15
|
}
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
-
|
|
18
|
+
/**
|
|
19
|
+
* A browser `WebSocket` takes no headers, so the mux upgrade cannot carry the
|
|
20
|
+
* `X-Authorization` every other call uses and a cross-origin host page — a
|
|
21
|
+
* packaged LarePass build sends no Olares cookie — is answered 401. Olares
|
|
22
|
+
* reads the token off `Sec-WebSocket-Protocol` instead and promotes it to the
|
|
23
|
+
* header before the entrance auth runs, so the host hands one down here.
|
|
24
|
+
*
|
|
25
|
+
* Read at connect time rather than captured with the client: a refreshed token
|
|
26
|
+
* then lands on the next reconnect instead of needing a rebuilt runtime.
|
|
27
|
+
*/
|
|
28
|
+
function socketProtocols(ports) {
|
|
29
|
+
const value = typeof ports.socketProtocol === "function"
|
|
30
|
+
? ports.socketProtocol()
|
|
31
|
+
: ports.socketProtocol;
|
|
32
|
+
if (Array.isArray(value)) return value.length > 0 ? value : undefined;
|
|
33
|
+
return value ? String(value) : undefined;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function openMuxSocket(httpUrl, signal, protocols) {
|
|
19
37
|
const Socket = globalThis.WebSocket;
|
|
20
38
|
if (typeof Socket !== "function") {
|
|
21
39
|
return Promise.resolve({
|
|
@@ -26,7 +44,10 @@ function openMuxSocket(httpUrl, signal) {
|
|
|
26
44
|
}
|
|
27
45
|
return new Promise((resolve) => {
|
|
28
46
|
let settled = false;
|
|
29
|
-
const
|
|
47
|
+
const url = muxWsUrl(httpUrl, pageOrigin());
|
|
48
|
+
// Offering a subprotocol the server does not echo back aborts the
|
|
49
|
+
// handshake, so a host that hands down none keeps the bare constructor.
|
|
50
|
+
const socket = protocols === undefined ? new Socket(url) : new Socket(url, protocols);
|
|
30
51
|
const finish = (result) => {
|
|
31
52
|
if (settled) return;
|
|
32
53
|
settled = true;
|
|
@@ -137,15 +158,26 @@ export function createHostClient(ports = {}) {
|
|
|
137
158
|
downloadUrl(sessionId, path) {
|
|
138
159
|
return urlFor(downloadFileUrl(sessionId, path));
|
|
139
160
|
},
|
|
161
|
+
// Both carry a binary body, so neither can go through `request` as an RPC —
|
|
162
|
+
// but both must still ride the host's auth. `ports.request` is passed
|
|
163
|
+
// through rather than called here so a host that supplies none keeps the
|
|
164
|
+
// cookie `fetch` these two used before.
|
|
140
165
|
upload(sessionId, file, options) {
|
|
141
|
-
return uploadFile(file, sessionId, {
|
|
166
|
+
return uploadFile(file, sessionId, {
|
|
167
|
+
...options,
|
|
168
|
+
url: urlFor(FILES_UPLOAD_PATH),
|
|
169
|
+
request: ports.request,
|
|
170
|
+
});
|
|
142
171
|
},
|
|
143
172
|
transcribe(blob, language, signal) {
|
|
144
|
-
return postTranscribe(blob, language, signal, {
|
|
173
|
+
return postTranscribe(blob, language, signal, {
|
|
174
|
+
baseUrl: urlFor(VOICE_API),
|
|
175
|
+
request: ports.request,
|
|
176
|
+
});
|
|
145
177
|
},
|
|
146
178
|
async openMux(signal) {
|
|
147
179
|
try {
|
|
148
|
-
return await openMuxSocket(urlFor(MUX_PATH), signal);
|
|
180
|
+
return await openMuxSocket(urlFor(MUX_PATH), signal, socketProtocols(ports));
|
|
149
181
|
} catch (err) {
|
|
150
182
|
return {
|
|
151
183
|
ok: false,
|
package/src/index.d.ts
CHANGED
|
@@ -25,6 +25,11 @@ export type HostPorts = {
|
|
|
25
25
|
proxyPrefix?: string;
|
|
26
26
|
env?: HostEnv;
|
|
27
27
|
request?: (url: string, init?: HostRequestInit) => Promise<HostResponse>;
|
|
28
|
+
/**
|
|
29
|
+
* Token offered as `Sec-WebSocket-Protocol` on the mux upgrade, which cannot
|
|
30
|
+
* carry `X-Authorization`. Pass a function to have it read per connect.
|
|
31
|
+
*/
|
|
32
|
+
socketProtocol?: string | string[] | (() => string | string[] | undefined);
|
|
28
33
|
};
|
|
29
34
|
|
|
30
35
|
export const PC_TEST_PROXY: string;
|
|
@@ -57,8 +62,11 @@ export function createHostClient(ports?: HostPorts): {
|
|
|
57
62
|
prompt: (sessionId: string, text: string) => Promise<{ ok: boolean }>;
|
|
58
63
|
};
|
|
59
64
|
|
|
65
|
+
export type ChatDevice = "desktop" | "mobile";
|
|
66
|
+
|
|
60
67
|
export const LaresApp: DefineComponent<{
|
|
61
68
|
locale?: string;
|
|
69
|
+
device?: ChatDevice;
|
|
62
70
|
baseUrl?: string;
|
|
63
71
|
proxyPrefix?: string;
|
|
64
72
|
env?: HostEnv;
|
package/src/layout.js
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export const DEVICE_MOBILE = "mobile";
|
|
2
|
+
export const DEVICE_DESKTOP = "desktop";
|
|
3
|
+
export const DESKTOP_CHAT_WIDTH_PX = 748;
|
|
4
|
+
|
|
5
|
+
/** Host-facing layout. Unknown values stay on the phone column. */
|
|
6
|
+
export function chatDevice(value) {
|
|
7
|
+
return value === DEVICE_DESKTOP ? DEVICE_DESKTOP : DEVICE_MOBILE;
|
|
8
|
+
}
|
package/src/preview/Preview.vue
CHANGED
|
@@ -38,6 +38,9 @@
|
|
|
38
38
|
@click="onMarkdownClick"
|
|
39
39
|
/>
|
|
40
40
|
<pre v-else-if="data?.kind === 'text'" class="lares-preview__text">{{ data.text }}</pre>
|
|
41
|
+
<div v-else-if="data?.kind === 'model3d'" class="lares-preview__hint">
|
|
42
|
+
<p>{{ t("model3dHint") }}</p>
|
|
43
|
+
</div>
|
|
41
44
|
<div v-else class="lares-preview__hint">
|
|
42
45
|
<p>{{ t("unsupportedTitle") }}</p>
|
|
43
46
|
<p>{{ t("unsupported") }}</p>
|
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
</p>
|
|
23
23
|
<p v-if="modelError" class="lares-agent__hint" data-status="error">{{ modelError }}</p>
|
|
24
24
|
</section>
|
|
25
|
+
<!-- Voice input parked.
|
|
25
26
|
<section class="lares-agent__group">
|
|
26
27
|
<h2>{{ tVoice("settings.title") }}</h2>
|
|
27
28
|
<div class="lares-agent__list">
|
|
@@ -44,6 +45,7 @@
|
|
|
44
45
|
</div>
|
|
45
46
|
<p v-if="voiceError" class="lares-agent__hint" data-status="error">{{ voiceError }}</p>
|
|
46
47
|
</section>
|
|
48
|
+
-->
|
|
47
49
|
<section class="lares-agent__group">
|
|
48
50
|
<h2>{{ tSearch("settings.title") }}</h2>
|
|
49
51
|
<div class="lares-agent__list">
|
package/src/styles.css
CHANGED
|
@@ -7,6 +7,21 @@
|
|
|
7
7
|
color: var(--q-ink-1);
|
|
8
8
|
}
|
|
9
9
|
|
|
10
|
+
.lares-shell__conversation {
|
|
11
|
+
display: flex;
|
|
12
|
+
flex-direction: column;
|
|
13
|
+
min-width: 0;
|
|
14
|
+
min-height: 0;
|
|
15
|
+
flex: 1;
|
|
16
|
+
width: 100%;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
.lares-shell[data-device="desktop"] .lares-shell__conversation {
|
|
20
|
+
width: 748px;
|
|
21
|
+
max-width: 100%;
|
|
22
|
+
margin-inline: auto;
|
|
23
|
+
}
|
|
24
|
+
|
|
10
25
|
.lares-shell button,
|
|
11
26
|
.lares-shell textarea,
|
|
12
27
|
.lares-shell select {
|