dsh-mobile-plugin 0.1.92 → 0.1.94
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 +3 -1
- package/config/agent-presets/standard/agent.cordis.yml +7 -4
- package/lib/desktop-view.cjs +20 -0
- package/lib/impl.js +7 -2
- package/lib/mirror/pusher.cjs +40 -0
- package/lib/mobile/archive-clean.js +17 -18
- package/lib/mobile/attachments.js +23 -6
- package/lib/mobile/events.js +28 -3
- package/lib/mobile/history-derive.js +53 -7
- package/lib/mobile/host-bridge/endpoints.js +0 -1
- package/lib/mobile/host-bridge/event-synthesis.js +33 -3
- package/lib/mobile/host-bridge/shims.js +28 -4
- package/lib/mobile/monitor.js +135 -0
- package/lib/mobile/session-flags.js +63 -0
- package/lib/mobile/session-models.js +6 -0
- package/lib/mobile/session-preview.js +70 -0
- package/lib/mobile/session-send.js +8 -2
- package/lib/mobile/session-snapshot.js +13 -19
- package/lib/mobile/sessions.js +48 -91
- package/lib/mobile/stream.js +113 -16
- package/lib/mobile/uploads.js +14 -7
- package/lib/mobile/workspaces.js +17 -3
- package/lib/panel/index.html +7 -7
- package/lib/reconnect-scheduler.cjs +31 -4
- package/lib/relay/lan-network.cjs +156 -12
- package/lib/relay/lan-proxy.js +85 -7
- package/lib/relay/protocol.js +3 -3
- package/lib/relay/tunnel.js +17 -4
- package/lib/routes/control-plane.js +11 -5
- package/lib/routes/mobile-api.js +22 -1
- package/lib/session-cleanup.cjs +160 -27
- package/lib/shared/desktop-view-signal.js +66 -0
- package/lib/shared/http-util.js +50 -4
- package/lib/shared/list-revision.js +20 -1
- package/lib/shared/sse-write.js +52 -2
- package/lib/sidecar-facade-worker.cjs +16 -2
- package/lib/update-install.cjs +71 -13
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -7,6 +7,7 @@ AI-HUB 手机端插件,为 DSH 提供移动 API、远程连接和局域网代
|
|
|
7
7
|
## 下载 AI-HUB 手机 App
|
|
8
8
|
|
|
9
9
|
- [Android APK](https://app.wnluo.com/public/apps/14)
|
|
10
|
+
- [Android APK(GitHub 渠道)](https://github.com/WNLUO/dsh-mobile-plugin/releases/latest/download/ai-hub-android.apk);[历史版本](https://github.com/WNLUO/dsh-mobile-plugin/releases)
|
|
10
11
|
- [App Store](https://apps.apple.com/us/app/ai-hub/id6821681438)(若暂时无法打开,可能是 App 仍在等待苹果审核通过。)
|
|
11
12
|
- [TestFlight 测试版](https://testflight.apple.com/join/4fn5PCMD)
|
|
12
13
|
|
|
@@ -55,7 +56,7 @@ curl http://127.0.0.1:3080/api/relay/pairing
|
|
|
55
56
|
|
|
56
57
|
移动 API 需要 Bearer token。设备事件流 `GET /api/mobile/v1/events/stream` 使用 SSE 转发宿主事件。创建会话时可传 `workspaceId` 或 `cwd`;前者优先,并将会话归入指定工作区。移动端排序也会同步到宿主工作区。
|
|
57
58
|
|
|
58
|
-
配对策略默认为 `code`(凭配对码放行);设为 `deny` 时拒绝所有新配对。自动归档只隐藏符合条件的闲置会话,不删除日志。`session_cleanup`
|
|
59
|
+
配对策略默认为 `code`(凭配对码放行);设为 `deny` 时拒绝所有新配对。自动归档只隐藏符合条件的闲置会话,不删除日志。`session_cleanup` 工具可归档会话,也可永久删除已归档会话;删除不可恢复,需要显式确认。删除是「排进待删队列」:会话立刻从列表里消失,日志与投影缓存等宿主下次启动时才从磁盘上删掉 —— 宿主没有「删除会话」的接口,进程活着的时候从它脚底下删文件会让它读到不存在的日志,那是未捕获异常,会带走整个宿主进程。
|
|
59
60
|
|
|
60
61
|
## 无头部署
|
|
61
62
|
|
|
@@ -104,6 +105,7 @@ npm test
|
|
|
104
105
|
- npm 包的 `repository` 字段用于关联插件列表条目;修改后需再次发布,注册表信息才会更新。
|
|
105
106
|
- 插件列表收录通过向 awesome-dsh-plugin 提交 `data/plugins/<owner>__<repo>.yml` 完成。描述中的数字和 API 名称应能由代码核实;列表要求 `description.en`、`category: remote`,包需声明 `dsh.bundle`,仓库需有 `dsh-plugin` topic。
|
|
106
107
|
- 仓库创建满一天的检查会自动重跑;截图可通过 `screenshots.json` 提供,也可由市场从 README 查找。
|
|
108
|
+
- GitHub 的 release 页放手机 App 的 APK(下载入口见开头那一节):资产名固定用 `ai-hub-android.apk`,并且发成正式版 —— 草稿与预发布都不会成为「最新」,README 里那条直连会随之断掉。插件自身走 npm 发布,不在本仓库发 release。
|
|
107
109
|
- 隐私政策:[PRIVACY.md](PRIVACY.md)。技术支持与常见问题:[SUPPORT.md](SUPPORT.md)。
|
|
108
110
|
|
|
109
111
|
### 发布新版本
|
|
@@ -162,9 +162,12 @@
|
|
|
162
162
|
searchTimeoutMs: 60000
|
|
163
163
|
# Without this row the mobile host's tool catalog has no `ask_user_question`,
|
|
164
164
|
# so the App's question dock could never appear: the model simply reports that
|
|
165
|
-
# the tool is unavailable.
|
|
166
|
-
# the `
|
|
167
|
-
#
|
|
168
|
-
#
|
|
165
|
+
# the tool is unavailable. The plugin's `lib/mobile/questions.js` listens on
|
|
166
|
+
# the `user-questions/request` hook (0.2.x shape) and answers it — only the
|
|
167
|
+
# tool row was missing.
|
|
168
|
+
#
|
|
169
|
+
# The package must be pinned to the runtime's own generation (0.2.0-rc.2):
|
|
170
|
+
# its peerDependencies track the runtime release exactly, and the loader
|
|
171
|
+
# disables the whole row — rather than degrade — when one does not match.
|
|
169
172
|
- id: tool-ask-user
|
|
170
173
|
name: '@deepseek-ai/dsh-tool-ask-user'
|
package/lib/desktop-view.cjs
CHANGED
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
const fs = require('node:fs');
|
|
20
20
|
const path = require('node:path');
|
|
21
21
|
const paths = require('./shared/paths');
|
|
22
|
+
const { desktopViewSignal } = require('./shared/desktop-view-signal.js');
|
|
22
23
|
|
|
23
24
|
/** 单个键(workspaceId)长度上限;超长的一律丢弃(真实 id 只有十几字节)。 */
|
|
24
25
|
const MAX_KEY_LENGTH = 200;
|
|
@@ -36,6 +37,17 @@ function stateFile() {
|
|
|
36
37
|
let cached = null;
|
|
37
38
|
let loaded = false;
|
|
38
39
|
|
|
40
|
+
/**
|
|
41
|
+
* 折叠态内容指纹(键排序后序列化)。
|
|
42
|
+
*
|
|
43
|
+
* 只用来回答「这次上报真的改了折叠态吗」:两次 JSON.stringify 的键序不保证一致,
|
|
44
|
+
* 直接比会让同一份状态被认成两次变更。内容没变就既不重写文件、也不给手机推帧。
|
|
45
|
+
*/
|
|
46
|
+
function expansionFingerprint(groupExpansion) {
|
|
47
|
+
const keys = Object.keys(groupExpansion).sort();
|
|
48
|
+
return JSON.stringify(keys.map((key) => [key, groupExpansion[key]]));
|
|
49
|
+
}
|
|
50
|
+
|
|
39
51
|
/** 归一化上报的 groupExpansion:只保留布尔值、限长限量;形状不对返回 null。 */
|
|
40
52
|
function normalizeGroupExpansion(input) {
|
|
41
53
|
if (!input || typeof input !== 'object' || Array.isArray(input)) return null;
|
|
@@ -91,11 +103,16 @@ function update(input, now = Date.now()) {
|
|
|
91
103
|
const groupExpansion = normalizeGroupExpansion(input && input.groupExpansion);
|
|
92
104
|
if (!groupExpansion) throw new Error('groupExpansion 必须是对象');
|
|
93
105
|
const rawSource = input && (typeof input.source === 'string' ? input.source : input.storageKey);
|
|
106
|
+
const previous = load();
|
|
94
107
|
const next = {
|
|
95
108
|
groupExpansion,
|
|
96
109
|
receivedAt: now,
|
|
97
110
|
source: typeof rawSource === 'string' ? rawSource.slice(0, MAX_SOURCE_LENGTH) : '',
|
|
98
111
|
};
|
|
112
|
+
// 折叠态没变就不落盘、不广播:客户端半边在页面重载 / 切窗口时会重发同一份。
|
|
113
|
+
// 什么都没变却写一次文件、给每台手机推一帧,等于把白问的轮询换成白推的帧。
|
|
114
|
+
const changed = !previous || expansionFingerprint(previous.groupExpansion) !== expansionFingerprint(groupExpansion);
|
|
115
|
+
if (!changed) return snapshot();
|
|
99
116
|
const file = stateFile();
|
|
100
117
|
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
101
118
|
const tmp = `${file}.tmp`;
|
|
@@ -103,6 +120,9 @@ function update(input, now = Date.now()) {
|
|
|
103
120
|
fs.renameSync(tmp, file);
|
|
104
121
|
cached = next;
|
|
105
122
|
loaded = true;
|
|
123
|
+
// 落盘之后再广播:订阅方收到帧会立刻回来读这条路(GET /desktop-view 读的是这份
|
|
124
|
+
// cached),先通知后落盘会把它引到上一份内容上。
|
|
125
|
+
desktopViewSignal.bump(next.receivedAt);
|
|
106
126
|
return snapshot();
|
|
107
127
|
}
|
|
108
128
|
|
package/lib/impl.js
CHANGED
|
@@ -24,7 +24,7 @@ const { authorized } = require('./mobile/auth.js')
|
|
|
24
24
|
const { createSidecarLifecycle } = require('./sidecar-lifecycle.cjs')
|
|
25
25
|
const { createMirrorPusher } = require('./mirror/pusher.cjs')
|
|
26
26
|
const { randomBytes } = require('node:crypto')
|
|
27
|
-
const { lanUrls, advertisedCandidates, orderCandidates } = require('./relay/lan-network.cjs')
|
|
27
|
+
const { lanUrls, advertisedCandidates, orderCandidates, ipv6Candidates } = require('./relay/lan-network.cjs')
|
|
28
28
|
const { resolvePort } = require('./relay/port-allocator.cjs')
|
|
29
29
|
const { createPreviewService } = require('./relay/preview.js')
|
|
30
30
|
// 本机设备凭据库:/api/relay/revoke-all 必须同时吊销局域网直连的 dshc_ 凭据,
|
|
@@ -177,6 +177,9 @@ function pairingPayload(state, lanPort) {
|
|
|
177
177
|
// 候选列表随二维码一起带走:App 配对后即使换了网段,也能按这份列表
|
|
178
178
|
// 依次重试而不用先回到公网中转去问。
|
|
179
179
|
lanCandidates: candidates.map(candidate => candidate.url).filter(Boolean),
|
|
180
|
+
// IPv6 直连候选(理想情况下是手机**不在同一个 Wi-Fi 里**时唯一能拨到的地址):不参与上面
|
|
181
|
+
// 那份的排序,顺序由 App 侧拿它和公网中转实测比出来;直连监听没开就没有这条通道。
|
|
182
|
+
ipv6Candidates: state.lanEnabled ? ipv6Candidates(port).map(candidate => candidate.url).filter(Boolean) : [],
|
|
180
183
|
}
|
|
181
184
|
return {
|
|
182
185
|
payload,
|
|
@@ -292,7 +295,7 @@ function apply(ctx) {
|
|
|
292
295
|
const { sidecarToken, syncSidecar, restartSidecar, tunnelConnected } = sidecarLifecycle
|
|
293
296
|
|
|
294
297
|
// 镜像推送调度器:会话事件立即推,后台循环每个 tick 补推一个冷会话。
|
|
295
|
-
const mirror = createMirrorPusher({ ctx, tunnelConnected, getSidecar: sidecarLifecycle.getSidecar })
|
|
298
|
+
const mirror = createMirrorPusher({ ctx, tunnelConnected, getSidecar: sidecarLifecycle.getSidecar, lastMobileAt: activity.snapshot })
|
|
296
299
|
// 隧道刚连上时补推一次全量:未连通期间每轮推送都被 worker 以 503 拒绝。
|
|
297
300
|
sidecarLifecycle.onTunnelUp(() => { void mirror.pushMirror(); })
|
|
298
301
|
|
|
@@ -413,6 +416,8 @@ function apply(ctx) {
|
|
|
413
416
|
getSidecarSnapshot: sidecarLifecycle.getSnapshot,
|
|
414
417
|
// 移动端路由表那边叫它「局域网候选」,取的是同一份私网优先的清单。
|
|
415
418
|
safeLanCandidates: advertisedCandidates,
|
|
419
|
+
// IPv6 直连候选单独一份:App 拿它单开一条通道、和公网中转比速度,不掺进上面那份的排序。
|
|
420
|
+
safeIpv6Candidates: ipv6Candidates,
|
|
416
421
|
lanPortInUse: advertisedLanPort, // LAN 候选用实绑端口
|
|
417
422
|
// 配对那条路由自己要签凭据:core 是签 token 的实现,pairingApproval 决定这台机器
|
|
418
423
|
// 收不收新设备('deny' 时一律拒)。两者本文件都已有,透传给移动端路由。
|
package/lib/mirror/pusher.cjs
CHANGED
|
@@ -39,6 +39,25 @@ const { observeCompletionEvent } = require('../mobile/completion.js');
|
|
|
39
39
|
/** 后台循环每个 tick 补推一个冷会话,保证没人动的会话镜像也不长期不更新。 */
|
|
40
40
|
const HIGH_PRIORITY_WINDOW_MS = 5 * 60 * 1000;
|
|
41
41
|
const BG_TICK_INTERVAL_MS = 500;
|
|
42
|
+
/**
|
|
43
|
+
* 没人看的时候,后台 tick 降到这个节拍。
|
|
44
|
+
*
|
|
45
|
+
* 500ms 那条节奏是给「手机正在用」准备的:清单或工作区一变,手机半秒内就该看到。
|
|
46
|
+
* 但宿主一天里大部分时间没人看 —— 那时每 500ms 整轮重建一次清单/工作区,走中转的
|
|
47
|
+
* 还要把快照推过一条隧道,纯属空转。
|
|
48
|
+
*
|
|
49
|
+
* 上限由 sidecar 的快照 TTL 卡着:镜像快照 15s 过期,降频必须明显小于它,
|
|
50
|
+
* 取 5s(= TTL/3,`test/mirror-tick-budget.test.cjs` 钉着这条比例)。
|
|
51
|
+
*/
|
|
52
|
+
const IDLE_TICK_INTERVAL_MS = 5_000;
|
|
53
|
+
/**
|
|
54
|
+
* 多久没有手机请求就算「没人看」。
|
|
55
|
+
*
|
|
56
|
+
* 走中转的手机每个请求都会记一次 `mobile/activity.cjs` 的 `note()`;局域网直连的手机
|
|
57
|
+
* 不走那道守卫所以记不到,那种情况另有 `listSignal.watching()` 兜着(有人订阅清单变化时
|
|
58
|
+
* 它为真)。两个判据都不成立时才降频。
|
|
59
|
+
*/
|
|
60
|
+
const AUDIENCE_IDLE_MS = 30_000;
|
|
42
61
|
/**
|
|
43
62
|
* 冷会话轮转的最小间隔(清单/工作区都没变时才生效)。
|
|
44
63
|
*
|
|
@@ -64,6 +83,19 @@ const MAX_CONSECUTIVE_EVENT_BUILDS = 3;
|
|
|
64
83
|
|
|
65
84
|
function createMirrorPusher(deps) {
|
|
66
85
|
const { ctx, tunnelConnected, getSidecar, listSignal = sharedListSignal, sessionSignal = sharedSessionSignal } = deps;
|
|
86
|
+
/**
|
|
87
|
+
* 「手机最近来过没有」的取值器(`mobile/activity.cjs` 的 `snapshot` 形态)。
|
|
88
|
+
*
|
|
89
|
+
* 不传就按「有人在看」处理、维持原来 500ms 的节拍:降频是个优化,不是契约,
|
|
90
|
+
* 别让一处漏接线的装配把镜像悄悄降成 5s 一份。
|
|
91
|
+
*/
|
|
92
|
+
const lastMobileAt = typeof deps.lastMobileAt === 'function' ? deps.lastMobileAt : null;
|
|
93
|
+
const hasAudience = () => {
|
|
94
|
+
if (listSignal.watching()) return true;
|
|
95
|
+
if (!lastMobileAt) return true;
|
|
96
|
+
const at = Number(lastMobileAt()?.lastMobileAt) || 0;
|
|
97
|
+
return at > 0 && Date.now() - at <= AUDIENCE_IDLE_MS;
|
|
98
|
+
};
|
|
67
99
|
|
|
68
100
|
// 移动镜像推送状态(供诊断/状态面板观测:最近推送时间 + 结果)
|
|
69
101
|
let mirrorPushAt = 0
|
|
@@ -565,9 +597,17 @@ function createMirrorPusher(deps) {
|
|
|
565
597
|
let bgQueueBusy = false;
|
|
566
598
|
// 上一次真的跑过冷会话轮转的时刻(见 BG_COLD_ROTATE_MIN_INTERVAL_MS)。
|
|
567
599
|
let lastColdRotateAt = 0;
|
|
600
|
+
// 上一轮后台扫描真正跑起来的时刻(见 IDLE_TICK_INTERVAL_MS 的降频)。
|
|
601
|
+
let lastBgScanAt = 0;
|
|
568
602
|
|
|
569
603
|
const processBgQueue = async () => {
|
|
570
604
|
if (bgQueueBusy) return;
|
|
605
|
+
// 降频:没人看的时候不必每 500ms 重建一轮清单。判据用「手机最近有没有请求」,
|
|
606
|
+
// 而不是「隧道连没连上」—— 隧道连着但人没在用,一样是空转。
|
|
607
|
+
// 有人在看时不加额外下限,节奏仍由 500ms 的定时器自己决定。
|
|
608
|
+
// 事件驱动那条路径(pushSessionImmediately)不受这里影响,会话真的变了仍是立即推。
|
|
609
|
+
if (!hasAudience() && lastBgScanAt && Date.now() - lastBgScanAt < IDLE_TICK_INTERVAL_MS) return;
|
|
610
|
+
lastBgScanAt = Date.now();
|
|
571
611
|
const online = tunnelConnected();
|
|
572
612
|
// 隧道未连接时这个 500ms 的 tick 只在**有人听**的时候才有意义:构建出来的快照没有
|
|
573
613
|
// 任何去处(pushMirrorPayload 会短路),但「清单变了」这件事仍然要说给手机听 ——
|
|
@@ -1,30 +1,33 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
*
|
|
4
|
+
* 移动端「清理所有已归档的对话」:按宿主归档集合确定清理范围,把它们排进待删队列。
|
|
5
5
|
*
|
|
6
6
|
* 以 workspace.list 的 archivedSessionIds 为清理范围,不用可能过时的列表状态缩小范围;
|
|
7
7
|
* 请求必须显式带 confirm: true —— 磁盘删除不可恢复,手机端「少点一次」不值得把它变成滑一下就没了的动作。
|
|
8
8
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
9
|
+
* **这里不删磁盘**:只把 id 排进 session-cleanup.cjs 的待删队列,日志留到宿主下次启动时删
|
|
10
|
+
* (见那个文件的 deleteQueuedArchivedSessions)。宿主没有「删除会话」接口,进程活着的时候
|
|
11
|
+
* 它随时可能去打开那些文件 —— 文件不在就是一次未捕获的 ENOENT,宿主整个退出、桌面端闪退
|
|
12
|
+
* (2026-10-20 真机现场)。本进程只把 id 压进删除集,让手机列表立刻看不到它们。
|
|
11
13
|
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
* 正常列表——桌面端用户看到的就是「清理完它们又回来了,重启 DSH 才真的消失」。归档集合
|
|
15
|
-
* 的收尾放在宿主启动时做,见 session-cleanup.cjs 的 pruneOrphanedArchivedSessionsOnce。
|
|
14
|
+
* 也**不擦宿主的归档集合**:进程里擦会把桌面端快照里那些其实已经没有日志的行放回正常列表。
|
|
15
|
+
* 归档集合的收尾同样在宿主启动时做,见 pruneOrphanedArchivedSessionsOnce。
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
18
|
const { sendJson, readJsonBody } = require('../shared/http-util');
|
|
19
19
|
const { withIdempotency, requestIdOf } = require('./idempotency.js');
|
|
20
|
-
const { canonicalDeviceKey, rememberDeletedSessionIds
|
|
20
|
+
const { canonicalDeviceKey, rememberDeletedSessionIds } = require('./session-cache.js');
|
|
21
21
|
const { invalidateSessionList } = require('./sessions.js');
|
|
22
22
|
const { unwrap } = require('./envelope.js');
|
|
23
|
-
const {
|
|
23
|
+
const { queueArchivedClearIds } = require('../session-cleanup.cjs');
|
|
24
24
|
|
|
25
25
|
/**
|
|
26
26
|
* POST /api/mobile/v1/workspace/clear-archived(token)
|
|
27
27
|
* body: { confirm: true } → { ok: true, deleted: N }。confirm 不为 true 一律 400。
|
|
28
|
+
*
|
|
29
|
+
* deleted = 这次被排进队列、手机上立刻消失的条数(含盘上早就没有数据的那些);日志要到
|
|
30
|
+
* 宿主下次启动才真正从磁盘上消失。
|
|
28
31
|
*/
|
|
29
32
|
async function handleClearArchived(ctx, req, res) {
|
|
30
33
|
const body = await readJsonBody(req, 64 * 1024);
|
|
@@ -41,17 +44,13 @@ async function handleClearArchived(ctx, req, res) {
|
|
|
41
44
|
const archivedIds = Array.isArray(workspaceResponse.value?.archivedSessionIds) ? workspaceResponse.value.archivedSessionIds : [];
|
|
42
45
|
const selected = archivedIds.filter((id) => typeof id === 'string' && id);
|
|
43
46
|
if (selected.length === 0) return { status: 200, body: { ok: true, deleted: 0 } };
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
//
|
|
47
|
-
|
|
48
|
-
const already = deletedSessionIdsOf(ctx);
|
|
49
|
-
const cleared = new Set(removed);
|
|
50
|
-
for (const id of sessionIdsWithoutAnyData(selected)) if (!already.has(id)) cleared.add(id);
|
|
51
|
-
rememberDeletedSessionIds(ctx, cleared);
|
|
47
|
+
// 排不进队列就整次失败(路由回 500):回「已清理」却没排上,重启之后它们原样回来。
|
|
48
|
+
queueArchivedClearIds(selected);
|
|
49
|
+
// 宿主那份快照可能还带着它们,先在本进程压住:手机列表与归档列表立刻看不到。
|
|
50
|
+
rememberDeletedSessionIds(ctx, selected);
|
|
52
51
|
// 不立刻失效的话,最长 10 秒还能点到已删的行。
|
|
53
52
|
invalidateSessionList(ctx);
|
|
54
|
-
return { status: 200, body: { ok: true, deleted:
|
|
53
|
+
return { status: 200, body: { ok: true, deleted: selected.length } };
|
|
55
54
|
});
|
|
56
55
|
sendJson(res, outcome.status, outcome.body);
|
|
57
56
|
}
|
|
@@ -1,16 +1,24 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* 图片归一:会话历史存的是 `{type:'image', attachment}`
|
|
5
|
-
* 手机端拿到后要经 session attachment RPC 把图片取回来。
|
|
4
|
+
* 图片归一:会话历史存的是 `{type:'image', attachment}` 而不是内联字节。
|
|
6
5
|
*
|
|
7
6
|
* 宿主事件里图片可能出现在四处:message/content、inserted 上下文项、
|
|
8
7
|
* assistant chunk 的 block,以及更深的嵌套 content。全部在这里归一,
|
|
9
|
-
* 这样历史与 SSE
|
|
8
|
+
* 这样历史与 SSE 暴露给手机端的图片形状是同一个。
|
|
9
|
+
*
|
|
10
|
+
* **默认(引用模式 `opts.referencesOnly`)不取字节**:图片块原样留着 `attachment`
|
|
11
|
+
* 引用,由 history-derive 投影成 `{mediaType, name?, attachmentId}`,手机端要画的时候
|
|
12
|
+
* 按 attachmentId 单张去取(`/session/attachment`)。此前这里把每张图都换成内联 base64,
|
|
13
|
+
* 一页 200 条带十几张截图的响应接近 10MB,每次进会话都要重传一遍。
|
|
14
|
+
*
|
|
15
|
+
* 只有显式传 `referencesOnly: false` 时才回到「经 session attachment RPC 取回字节」的
|
|
16
|
+
* 老行为;取不回字节的块打 `dataUnavailable` 标(语义不变,见 dataUnavailableBlock)。
|
|
10
17
|
*
|
|
11
18
|
* 另外一类图片在正文里:`` 这种指向**宿主本机文件**的写法
|
|
12
19
|
* (本机绝对路径、file://、~)在手机上取不到字节,也会被 App 当成非 http 图片降级成字面
|
|
13
20
|
* markdown。这里顺手按同一形状内联成 data URI(实现与预算见 local-images.js)。
|
|
21
|
+
* 这条与附件引用是两件事:它走的是正文里的路径、没有 attachmentId,预算与行为保持原样。
|
|
14
22
|
*/
|
|
15
23
|
|
|
16
24
|
const { unwrap } = require('./envelope.js');
|
|
@@ -19,12 +27,16 @@ const { inlineTextImages, createTextImageBudget } = require('./local-images.js')
|
|
|
19
27
|
|
|
20
28
|
/**
|
|
21
29
|
* Resolve durable image references before exposing messages to mobile clients.
|
|
22
|
-
*
|
|
23
|
-
*
|
|
30
|
+
*
|
|
31
|
+
* Session history stores `{type:'image', attachment}` rather than inline bytes.
|
|
32
|
+
* By default (`referencesOnly`) the block is left as a reference and the mobile
|
|
33
|
+
* client fetches the bytes per attachment (`/session/attachment`); no host RPC
|
|
34
|
+
* happens on this path at all. Pass `referencesOnly: false` for the legacy
|
|
35
|
+
* inline-bytes shape.
|
|
24
36
|
*
|
|
25
37
|
* Host events can carry content in several places: message/content, inserted
|
|
26
38
|
* context items, or an assistant/chunk block. Keep this normalization here so
|
|
27
|
-
* history and SSE expose the same
|
|
39
|
+
* history and SSE expose the same image shape to mobile clients.
|
|
28
40
|
*
|
|
29
41
|
* Text blocks additionally get local-file image syntax inlined as data URIs
|
|
30
42
|
* (see local-images.js); `opts.budget` is a shared per-response budget so one
|
|
@@ -47,6 +59,9 @@ async function resolveEventAttachments(ctx, sessionId, event, opts = {}) {
|
|
|
47
59
|
if (!event || !event.data || typeof event.data !== 'object') return event;
|
|
48
60
|
const data = event.data;
|
|
49
61
|
const budget = opts.budget || createTextImageBudget();
|
|
62
|
+
// 引用模式(默认):图片块只留 attachment 引用,**一次宿主 RPC 都不发**。
|
|
63
|
+
// 只有显式 referencesOnly:false 才走下面那条把字节取回来的老路。
|
|
64
|
+
const referencesOnly = opts.referencesOnly !== false;
|
|
50
65
|
let nextData = data;
|
|
51
66
|
let eventChanged = false;
|
|
52
67
|
|
|
@@ -59,6 +74,8 @@ async function resolveEventAttachments(ctx, sessionId, event, opts = {}) {
|
|
|
59
74
|
if (block.type === 'image' && !block.data && block.attachment) {
|
|
60
75
|
const attachmentId = block.attachment.attachmentId;
|
|
61
76
|
if (typeof attachmentId !== 'string' || !attachmentId) return block;
|
|
77
|
+
// 引用模式到此为止:块原样返回,字节由手机端按 attachmentId 单张去取。
|
|
78
|
+
if (referencesOnly) return block;
|
|
62
79
|
let result;
|
|
63
80
|
try {
|
|
64
81
|
result = unwrap(await callSessions(ctx, 'attachment', { sessionId, attachmentId }));
|
package/lib/mobile/events.js
CHANGED
|
@@ -6,9 +6,10 @@
|
|
|
6
6
|
* 但宿主在 DSH 0.2.0 兼容桥下只会订阅 workspace/follow + session/control,会话状态变化
|
|
7
7
|
* 走的是 session/projection(被白名单丢掉),于是这条流连着却可能长时间一帧都不发;
|
|
8
8
|
* 插件自己补了 device/list-changed(来自 lib/shared/list-signal.js,清单变了)、
|
|
9
|
-
* device/session-activity(来自 lib/shared/session-signal.js
|
|
10
|
-
*
|
|
11
|
-
*
|
|
9
|
+
* device/session-activity(来自 lib/shared/session-signal.js,某个会话的正文水位涨了)、
|
|
10
|
+
* device/desktop-view-changed(来自 lib/shared/desktop-view-signal.js,桌面侧栏折叠态
|
|
11
|
+
* 变了)与 device/heartbeat(心跳)四类自产帧,App 侧靠它们判断「这条流还活着」
|
|
12
|
+
* 「该重拉列表」「该给某个会话补正文」「该重取折叠态」并据此决定要不要把兜底轮询降频。
|
|
12
13
|
*
|
|
13
14
|
* 断流补偿:这条流不做 Last-Event-ID 续传(App 只把它当信号,不当数据源),所以**连上时**
|
|
14
15
|
* 会立刻补发「当前状态」——一帧最近已知 revision 的 device/list-changed + 每个已跟踪会话
|
|
@@ -19,6 +20,7 @@
|
|
|
19
20
|
|
|
20
21
|
const { listSignal: sharedListSignal } = require('../shared/list-signal.js');
|
|
21
22
|
const { sessionSignal: sharedSessionSignal } = require('../shared/session-signal.js');
|
|
23
|
+
const { desktopViewSignal: sharedDesktopViewSignal } = require('../shared/desktop-view-signal.js');
|
|
22
24
|
const { writeSse: sharedWriteSse, stableEventId: sharedEventId } = require('../shared/sse-write.js');
|
|
23
25
|
|
|
24
26
|
const DEDUP_WINDOW = 2048;
|
|
@@ -194,6 +196,22 @@ async function handleDeviceStream(ctx, req, res) {
|
|
|
194
196
|
if (ac.signal.aborted) return;
|
|
195
197
|
if (!writeFrame({ type: 'device/session-activity', sessionId: event.sessionId, seq: event.seq, at: event.at })) ac.abort();
|
|
196
198
|
});
|
|
199
|
+
/**
|
|
200
|
+
* 自产的「桌面侧栏折叠态变了」帧。
|
|
201
|
+
*
|
|
202
|
+
* 桌面端那份 groupExpansion 只活在浏览器 localStorage,插件靠客户端半边轮询上报,
|
|
203
|
+
* 落在 lib/desktop-view.cjs;手机要拿到它只能问 GET /desktop-view。过去 App 用
|
|
204
|
+
* 「跟列表刷新走 + 5 分钟最短间隔」去猜它有没有变,稳态下一天白问几百次 —— 而插件
|
|
205
|
+
* 早就知道它哪一刻变了(desktop-view.cjs 的 update())。这里补上那个出口。
|
|
206
|
+
*
|
|
207
|
+
* 只发 updatedAt、不发内容:手机据此重拉那一发,拿到的是与列表同一口径的完整镜像,
|
|
208
|
+
* 也不必让这条「信号流」承担数据同步(它的帧一律可丢、可重放)。
|
|
209
|
+
* 同样必须直订 + 收尾注销,理由与上面两条一致。
|
|
210
|
+
*/
|
|
211
|
+
const unsubscribeDesktopView = sharedDesktopViewSignal.onBump((event) => {
|
|
212
|
+
if (ac.signal.aborted) return;
|
|
213
|
+
if (!writeFrame({ type: 'device/desktop-view-changed', updatedAt: event.updatedAt, at: event.at })) ac.abort();
|
|
214
|
+
});
|
|
197
215
|
/**
|
|
198
216
|
* 连上就先补一帧「当前状态」。
|
|
199
217
|
*
|
|
@@ -213,6 +231,12 @@ async function handleDeviceStream(ctx, req, res) {
|
|
|
213
231
|
if (list && list.revision) {
|
|
214
232
|
if (!writeFrame({ type: 'device/list-changed', revision: list.revision, at: list.at || Date.now() })) return false;
|
|
215
233
|
}
|
|
234
|
+
// 桌面侧栏折叠态:手机从此只在变化时拉一次。老 App 不认识这一帧(走默认分支忽略),
|
|
235
|
+
// 所以补发它不会改变老客户端的任何行为。
|
|
236
|
+
const view = sharedDesktopViewSignal.snapshot();
|
|
237
|
+
if (view && view.updatedAt > 0) {
|
|
238
|
+
if (!writeFrame({ type: 'device/desktop-view-changed', updatedAt: view.updatedAt, at: view.at || Date.now() })) return false;
|
|
239
|
+
}
|
|
216
240
|
for (const [sessionId, seq] of Object.entries(sharedSessionSignal.snapshot())) {
|
|
217
241
|
if (!(seq > 0)) continue;
|
|
218
242
|
if (!writeFrame({ type: 'device/session-activity', sessionId, seq, at: Date.now() })) return false;
|
|
@@ -279,6 +303,7 @@ async function handleDeviceStream(ctx, req, res) {
|
|
|
279
303
|
clearInterval(keepAlive);
|
|
280
304
|
unsubscribeSignal();
|
|
281
305
|
unsubscribeActivity();
|
|
306
|
+
unsubscribeDesktopView();
|
|
282
307
|
releaseSignal();
|
|
283
308
|
try {
|
|
284
309
|
if (!res.writableEnded) {
|
|
@@ -99,6 +99,46 @@ function toolResultParts(msg) {
|
|
|
99
99
|
};
|
|
100
100
|
}
|
|
101
101
|
|
|
102
|
+
/**
|
|
103
|
+
* 图片块 → 移动端图片条目(`ChatMessage.images[]` / 工具块的 `images[]` 同一个形状)。
|
|
104
|
+
*
|
|
105
|
+
* 两条互斥的出口:
|
|
106
|
+
* - 块上有 `attachmentId`:下发**引用** `{ mediaType, name?, attachmentId }`,**不带 data**。
|
|
107
|
+
* 字节由手机端要画的时候按 attachmentId 单张去取(`/session/attachment`),
|
|
108
|
+
* 历史响应因此不再背着几十张截图的 base64。
|
|
109
|
+
* - 块里本来就只有内联 `data`、没有 attachmentId:照旧下发 `{ mediaType, data, name? }`。
|
|
110
|
+
* 这条兜底不能删——老日志、工具结果里存在这种直接带字节的图片块。
|
|
111
|
+
*
|
|
112
|
+
* 两条都要求 mediaType 可读(块上没有就退到 attachment 上的),否则 App 认不出来、
|
|
113
|
+
* 也没有可显示的动作,返回 null 让调用方丢掉它。
|
|
114
|
+
*/
|
|
115
|
+
function imageEntryOf(block) {
|
|
116
|
+
if (!block || typeof block !== 'object') return null;
|
|
117
|
+
const attachment = block.attachment && typeof block.attachment === 'object' ? block.attachment : {};
|
|
118
|
+
const attachmentId = typeof attachment.attachmentId === 'string' ? attachment.attachmentId : '';
|
|
119
|
+
const mediaType = typeof block.mediaType === 'string' && block.mediaType
|
|
120
|
+
? block.mediaType
|
|
121
|
+
: (typeof attachment.mediaType === 'string' ? attachment.mediaType : '');
|
|
122
|
+
if (!mediaType) return null;
|
|
123
|
+
const name = typeof block.name === 'string'
|
|
124
|
+
? block.name
|
|
125
|
+
: (typeof attachment.name === 'string' ? attachment.name : undefined);
|
|
126
|
+
if (attachmentId) return { mediaType, ...(name !== undefined ? { name } : {}), attachmentId };
|
|
127
|
+
if (typeof block.data === 'string' && block.data) return { mediaType, data: block.data, ...(name !== undefined ? { name } : {}) };
|
|
128
|
+
return null;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** 把 tool/result 的图片块收成移动端图片条目(两种出口见 imageEntryOf)。 */
|
|
132
|
+
function toolResultImages(parts) {
|
|
133
|
+
const out = [];
|
|
134
|
+
for (const part of parts) {
|
|
135
|
+
if (!part || typeof part !== 'object' || part.type !== 'image') continue;
|
|
136
|
+
const entry = imageEntryOf(part);
|
|
137
|
+
if (entry) out.push(entry);
|
|
138
|
+
}
|
|
139
|
+
return out;
|
|
140
|
+
}
|
|
141
|
+
|
|
102
142
|
/** 预扫描 tool/result 事件,构建 callId → {isError, code} 映射(对接桌面 toolRowModel 状态派生)。 */
|
|
103
143
|
function buildToolResultMap(events) {
|
|
104
144
|
const map = new Map();
|
|
@@ -115,10 +155,7 @@ function buildToolResultMap(events) {
|
|
|
115
155
|
if (part && typeof part === 'object' && part.type === 'image') return '';
|
|
116
156
|
try { return JSON.stringify(part); } catch { return ''; }
|
|
117
157
|
}).filter(Boolean).join('');
|
|
118
|
-
const images = parts
|
|
119
|
-
.filter((part) => part && typeof part === 'object' && part.type === 'image'
|
|
120
|
-
&& typeof part.data === 'string' && typeof part.mediaType === 'string')
|
|
121
|
-
.map((part) => ({ mediaType: part.mediaType, data: part.data, ...(typeof part.name === 'string' ? { name: part.name } : {}) }));
|
|
158
|
+
const images = toolResultImages(parts);
|
|
122
159
|
const diffs = normalizeResultDiffs(e.data.meta);
|
|
123
160
|
map.set(src.callId, { isError, code, ...(output ? { output } : {}), ...(images.length ? { images } : {}), ...(diffs ? { diffs } : {}) });
|
|
124
161
|
}
|
|
@@ -142,8 +179,11 @@ function parseBlocks(blocks, toolResults) {
|
|
|
142
179
|
// The image attachment resolver marked this block as unavailable. Keep it out of
|
|
143
180
|
// visible text; pushMessage projects its retry metadata onto unavailableImages.
|
|
144
181
|
continue;
|
|
145
|
-
} else if (b.type === 'image'
|
|
146
|
-
|
|
182
|
+
} else if (b.type === 'image') {
|
|
183
|
+
// 有 attachmentId 走引用、只有内联 data 的走兜底(见 imageEntryOf)。两者都认不出
|
|
184
|
+
// 就整块丢掉——与旧行为一致,绝不能把它兜底成一段 JSON 文本。
|
|
185
|
+
const entry = imageEntryOf(b);
|
|
186
|
+
if (entry) out.push({ type: 'image', ...entry });
|
|
147
187
|
} else if (b.type === 'tool-call') {
|
|
148
188
|
// tool-call 块字段为 id/name/arguments(与 harness toAssistantBlock 同口径)。
|
|
149
189
|
const callId = b.id != null ? String(b.id) : undefined;
|
|
@@ -336,10 +376,16 @@ function deriveMessages(events) {
|
|
|
336
376
|
return rest;
|
|
337
377
|
});
|
|
338
378
|
const text = blocksToText(blocks);
|
|
379
|
+
// 图片条目统一投影:引用模式带 attachmentId 不带 data,内联兜底带 data 不带 attachmentId。
|
|
339
380
|
const images = [
|
|
340
381
|
...blocks.filter((b) => b.type === 'image'),
|
|
341
382
|
...toolImages,
|
|
342
|
-
].map(({ mediaType, data, name }) => ({
|
|
383
|
+
].map(({ mediaType, data, name, attachmentId }) => ({
|
|
384
|
+
mediaType,
|
|
385
|
+
...(typeof name === 'string' ? { name } : {}),
|
|
386
|
+
...(typeof attachmentId === 'string' && attachmentId ? { attachmentId } : {}),
|
|
387
|
+
...(typeof attachmentId === 'string' && attachmentId ? {} : { data }),
|
|
388
|
+
}));
|
|
343
389
|
const unavailableImages = (Array.isArray(msg.content) ? msg.content : [])
|
|
344
390
|
.filter((block) => block && block.type === 'image' && block.dataUnavailable === true)
|
|
345
391
|
.map((block) => {
|
|
@@ -110,7 +110,6 @@ const ENDPOINTS = {
|
|
|
110
110
|
|
|
111
111
|
// skills / subagents
|
|
112
112
|
'skills.list': inv('skills', 'list'),
|
|
113
|
-
'subagents.history': inv('subagents', 'history'),
|
|
114
113
|
'subagents.prompt': inv('subagents', 'prompt', (p) => ({
|
|
115
114
|
request: {
|
|
116
115
|
...pick(p, ['parentSessionId', 'childSessionId', 'mode', 'content', 'clientTimeZone']),
|
|
@@ -21,6 +21,28 @@ function sessionHint(request) {
|
|
|
21
21
|
return typeof sessionId === 'string' && sessionId ? sessionId : null;
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
+
/**
|
|
25
|
+
* 这条单会话流该按哪种地址订阅。
|
|
26
|
+
*
|
|
27
|
+
* 子会话必须换成 `{kind:'subagent'}` 的 durable 地址:宿主 `validateAddress` 对
|
|
28
|
+
* `origin === 'subagent'` 的会话拿普通地址一律回 agent-busy,subscribe 直接失败 ——
|
|
29
|
+
* 表现就是「子代理页面打得开,但内容永远不自己更新」。请求体里带上 `parentSessionId`
|
|
30
|
+
* 就是「这是个子会话」的信号(与历史那条同源,见 stream.js 的 handleStream)。
|
|
31
|
+
*/
|
|
32
|
+
function sessionAddress(payload) {
|
|
33
|
+
const sessionId = payload && payload.sessionId;
|
|
34
|
+
const parentSessionId = payload && payload.parentSessionId;
|
|
35
|
+
if (typeof parentSessionId === 'string' && parentSessionId) {
|
|
36
|
+
return {
|
|
37
|
+
kind: 'subagent',
|
|
38
|
+
parentSessionId,
|
|
39
|
+
childSessionId: sessionId,
|
|
40
|
+
mode: (payload && payload.mode) || 'continuable',
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
return { kind: 'session', sessionId };
|
|
44
|
+
}
|
|
45
|
+
|
|
24
46
|
/** 一条流跑不通不该拖垮其余流:这里把异常吃掉,只丢这一条源的帧。 */
|
|
25
47
|
async function* tolerate(frames, what, ctx) {
|
|
26
48
|
try {
|
|
@@ -207,20 +229,28 @@ async function* deviceFrames(ctx, signal) {
|
|
|
207
229
|
/** 旧 `events.mux`:设备级帧流,带 sessionId 时收窄成单会话视角。 */
|
|
208
230
|
function muxFrames(ctx, request, signal) {
|
|
209
231
|
const sessionId = sessionHint(request);
|
|
232
|
+
const payload = request && request.payload;
|
|
210
233
|
return (async function* run() {
|
|
211
234
|
if (!sessionId) {
|
|
212
235
|
yield* deviceFrames(ctx, signal);
|
|
213
236
|
return;
|
|
214
237
|
}
|
|
238
|
+
const address = sessionAddress(payload);
|
|
239
|
+
// 子会话没有 job 面板(任务归属父会话),也就不订阅那一条:拿子会话 id 去问 job.list
|
|
240
|
+
// 只会白打一发,而且它的失败会被 tolerate 吞成一条警告。
|
|
215
241
|
const control = openStream(ctx, 'session', 'control', {}, signal).then((frames) => tolerate(frames, 'session/control', ctx));
|
|
216
|
-
const jobs =
|
|
242
|
+
const jobs = address.kind === 'session'
|
|
243
|
+
? openStream(ctx, 'job', 'list', { request: { sessionId } }, signal).then((frames) => tolerate(frames, `job/${sessionId}`, ctx))
|
|
244
|
+
: null;
|
|
217
245
|
// assistantStream: 逐块增量只走这条流的 assistant-stream 通知(见 followFramePayloads)。
|
|
218
246
|
const follow = openStream(ctx, 'session', 'follow', {
|
|
219
|
-
request: { address
|
|
247
|
+
request: { address, maxMessages: 1, assistantStream: true },
|
|
220
248
|
}, signal).then((frames) => tolerate(frames, `session/${sessionId}`, ctx));
|
|
221
249
|
const projections = projectionState();
|
|
222
250
|
const followState = { turn: null, step: null, streaming: false };
|
|
223
|
-
const
|
|
251
|
+
const opening = [['control', control], ['follow', follow]];
|
|
252
|
+
if (jobs !== null) opening.push(['jobs', jobs]);
|
|
253
|
+
const sources = await Promise.all(opening.map(([source, promise]) => promise.then(async function* wrap(iterable) {
|
|
224
254
|
for await (const frame of iterable) {
|
|
225
255
|
if (source === 'follow') {
|
|
226
256
|
// follow 一帧可能产出 0..n 个 mux 帧(事件原样 + 增量通知合成 chunk 事件)。
|
|
@@ -13,13 +13,36 @@ const { invoke, invokeEnvelope, ok, fail, pick, warn, deadline, openStream, SYNT
|
|
|
13
13
|
* follow 拿游标(读完首帧立刻 break,网关会取消这条流),再翻一页历史。
|
|
14
14
|
* 返回值把 records 拆回旧形状的 events(history-derive.js 只认 events/hasMore)。
|
|
15
15
|
*/
|
|
16
|
+
/**
|
|
17
|
+
* 这次 history 要读的是「谁」的地址。
|
|
18
|
+
*
|
|
19
|
+
* 普通会话是 `{kind:'session'}`;子会话必须换成**带父会话的 durable 地址**
|
|
20
|
+
* `{kind:'subagent'}` —— 宿主对 `origin === 'subagent'` 的会话拿普通地址一律回
|
|
21
|
+
* agent-busy(见 dsh-api-session-controller 的 validateAddress),这跟续话那条
|
|
22
|
+
* 是同一个门。`mode` 也要带:宿主会拿它跟子会话自己的 descriptor 对一遍。
|
|
23
|
+
*/
|
|
24
|
+
function historyAddress(payload) {
|
|
25
|
+
const sessionId = payload && payload.sessionId;
|
|
26
|
+
const parentSessionId = payload && payload.parentSessionId;
|
|
27
|
+
if (typeof parentSessionId === 'string' && parentSessionId) {
|
|
28
|
+
return {
|
|
29
|
+
kind: 'subagent',
|
|
30
|
+
parentSessionId,
|
|
31
|
+
childSessionId: sessionId,
|
|
32
|
+
mode: (payload && payload.mode) || 'continuable',
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
return { kind: 'session', sessionId };
|
|
36
|
+
}
|
|
37
|
+
|
|
16
38
|
async function historyViaPage(ctx, payload, signal) {
|
|
17
39
|
const sessionId = payload && payload.sessionId;
|
|
18
40
|
if (typeof sessionId !== 'string' || !sessionId) return fail({ code: 'bad-request', message: '缺少 sessionId' }, 'bad-request');
|
|
41
|
+
const address = historyAddress(payload);
|
|
19
42
|
let throughSeq = -1;
|
|
20
43
|
try {
|
|
21
44
|
const frames = await openStream(ctx, 'session', 'follow', {
|
|
22
|
-
request: { address
|
|
45
|
+
request: { address, maxMessages: 1 },
|
|
23
46
|
}, deadline(signal, SYNTHETIC_DEADLINE_MS));
|
|
24
47
|
for await (const frame of frames) {
|
|
25
48
|
if (frame && frame.type === 'snapshot' && Number.isSafeInteger(frame.cursor)) {
|
|
@@ -31,7 +54,7 @@ async function historyViaPage(ctx, payload, signal) {
|
|
|
31
54
|
return fail(error);
|
|
32
55
|
}
|
|
33
56
|
const request = {
|
|
34
|
-
address
|
|
57
|
+
address,
|
|
35
58
|
throughSeq,
|
|
36
59
|
...pick(payload, ['maxMessages', 'beforeSeq', 'turnWindow']),
|
|
37
60
|
};
|
|
@@ -86,10 +109,11 @@ async function modelCatalog(ctx, payload, signal) {
|
|
|
86
109
|
: null)
|
|
87
110
|
: null;
|
|
88
111
|
const effective = sessionSelection && sessionSelection.provider && sessionSelection.model ? sessionSelection : current;
|
|
89
|
-
const
|
|
112
|
+
const routableProviders = Array.isArray(catalog.routableProviders) ? catalog.routableProviders : [];
|
|
90
113
|
return ok({
|
|
91
114
|
...(effective ? { current: effective } : {}),
|
|
92
|
-
routable: Boolean(effective &&
|
|
115
|
+
routable: Boolean(effective && routableProviders.includes(effective.provider)),
|
|
116
|
+
routableProviders,
|
|
93
117
|
groups: Array.isArray(catalog.groups) ? catalog.groups : [],
|
|
94
118
|
});
|
|
95
119
|
}
|