dsh-cindy-host 0.1.20
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/CHANGELOG.md +462 -0
- package/LICENSE +21 -0
- package/README.md +178 -0
- package/cordis.patch.yml +6 -0
- package/doc/architecture.md +34 -0
- package/doc/cindy-android-verify.md +362 -0
- package/doc/cindy-mobile-freeze-handoff.md +124 -0
- package/doc/cindy-phone-link.md +2902 -0
- package/doc/desktop-runtime-fix-20260930.md +69 -0
- package/doc/development-plan.md +19 -0
- package/doc/file-peer-apply-recovery-20260921.md +35 -0
- package/doc/file-peer-installed-hotfix-20260921.md +53 -0
- package/doc/file-peer-minimal-apply-20260921.md +78 -0
- package/doc/file-peer-original-contract-alignment-20260921.md +119 -0
- package/doc/file-peer-phone-export-observation-20260921.md +75 -0
- package/doc/file-peer-product-contract-audit-20260921.md +106 -0
- package/doc/file-peer-root-cause-20260921.md +53 -0
- package/doc/file-transfer.md +50 -0
- package/doc/host-performance-20260930.md +91 -0
- package/doc/neonabyss-apk-file-peer-diagnosis-20260921.md +87 -0
- package/doc/new-session-sync-fix-20260930.md +59 -0
- package/doc/patches/cindy-peer-silence-probe.patch +1112 -0
- package/doc/projection-read-probe.md +77 -0
- package/doc/publishing.md +259 -0
- package/doc/releasing.md +149 -0
- package/doc/research.md +24 -0
- package/doc/session-discovery-2026-09-20.md +67 -0
- package/lib/client.js +763 -0
- package/package.json +84 -0
- package/src/auth-session.js +189 -0
- package/src/authorization-policy.js +59 -0
- package/src/cindy-channels.js +1707 -0
- package/src/cindy-login-flow.js +159 -0
- package/src/cindy-login.js +80 -0
- package/src/cindy-message-row.js +432 -0
- package/src/cindy-session-row.js +171 -0
- package/src/console-sink.js +6 -0
- package/src/contracts.js +26 -0
- package/src/credential-store.js +128 -0
- package/src/demo.js +9 -0
- package/src/dsh-active-sessions.js +59 -0
- package/src/dsh-host-source.js +62 -0
- package/src/dsh-message-fold.js +608 -0
- package/src/dsh-plugin.js +1932 -0
- package/src/dsh-session-source.js +529 -0
- package/src/file-peer-protocol.js +7 -0
- package/src/file-peer-rtc.js +41 -0
- package/src/fixture-source.js +13 -0
- package/src/host-approvals.js +329 -0
- package/src/host-attachments.js +249 -0
- package/src/host-authorization.js +51 -0
- package/src/host-demo.js +3 -0
- package/src/host-export-jobs.js +356 -0
- package/src/host-export-owner.js +62 -0
- package/src/host-file-browser.js +400 -0
- package/src/host-file-ice.js +63 -0
- package/src/host-file-peer.js +289 -0
- package/src/host-file-reference.js +16 -0
- package/src/host-files.js +148 -0
- package/src/host-frame-budget.js +310 -0
- package/src/host-goals.js +284 -0
- package/src/host-history-view-limits.js +22 -0
- package/src/host-history-view.js +417 -0
- package/src/host-input-queue.js +505 -0
- package/src/host-media-fetch.js +511 -0
- package/src/host-media.js +678 -0
- package/src/host-models.js +231 -0
- package/src/host-palette.js +111 -0
- package/src/host-push-channels.js +37 -0
- package/src/host-reconnect.js +54 -0
- package/src/host-routes.js +266 -0
- package/src/host-settings.js +41 -0
- package/src/host-status.js +216 -0
- package/src/host-transfer-diagnostics.js +50 -0
- package/src/host.js +2497 -0
- package/src/login.js +31 -0
- package/src/logout.js +3 -0
- package/src/projection-read-model-sink.js +6 -0
- package/src/projection.js +43 -0
- package/src/session-flags.js +160 -0
- package/src/session-publisher.js +123 -0
- package/src/session-read-model.js +6 -0
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Non-interactive Cindy login steps, driven by the Settings card.
|
|
3
|
+
*
|
|
4
|
+
* `cindy-login.js` owns the interactive CLI flow (readline prompts). This module
|
|
5
|
+
* exposes the same three wire calls as separate steps so a browser surface can
|
|
6
|
+
* stage them: request a verification code, verify it, and refresh an existing
|
|
7
|
+
* session. Nothing here prompts, and nothing here prints a token.
|
|
8
|
+
*
|
|
9
|
+
* Outcomes are values rather than exceptions: `binding_required` and
|
|
10
|
+
* `sso_verification_required` are answers the card must render, not transport
|
|
11
|
+
* failures, so callers can distinguish "Cindy said no" from "the request broke".
|
|
12
|
+
*/
|
|
13
|
+
import { MAINLAND_CINDY_AUTH_BASE_URL, cindyRequest } from './cindy-login.js';
|
|
14
|
+
|
|
15
|
+
export { MAINLAND_CINDY_AUTH_BASE_URL };
|
|
16
|
+
|
|
17
|
+
/** Login identifiers Cindy's code endpoints accept. */
|
|
18
|
+
export const LOGIN_KINDS = Object.freeze(['phone', 'email']);
|
|
19
|
+
|
|
20
|
+
/** Normalize a base URL the same way the CLI flow does. */
|
|
21
|
+
export function normalizeAuthBaseUrl(value) {
|
|
22
|
+
const base = typeof value === 'string' && value.trim() !== '' ? value.trim() : MAINLAND_CINDY_AUTH_BASE_URL;
|
|
23
|
+
return base.replace(/\/$/, '');
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Reject an identifier the card could not have meant, before spending a code. */
|
|
27
|
+
export function validateIdentifier(kind, identifier) {
|
|
28
|
+
const value = typeof identifier === 'string' ? identifier.trim() : '';
|
|
29
|
+
if (!LOGIN_KINDS.includes(kind)) return { ok: false, message: 'Cindy 登录方式无效' };
|
|
30
|
+
if (value === '') return { ok: false, message: kind === 'phone' ? '请填写手机号' : '请填写邮箱' };
|
|
31
|
+
if (kind === 'phone' && !/^\+?[0-9][0-9\s-]{4,19}$/.test(value)) return { ok: false, message: '手机号格式不正确' };
|
|
32
|
+
if (kind === 'email' && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) return { ok: false, message: '邮箱格式不正确' };
|
|
33
|
+
return { ok: true, value };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Ask Cindy to send a verification code.
|
|
38
|
+
* @param options - auth base URL, identifier kind, and the raw identifier.
|
|
39
|
+
* @returns `{ ok: true }` once Cindy accepted the request, else an ok:false message.
|
|
40
|
+
*/
|
|
41
|
+
export async function requestLoginCode({ authBaseUrl, kind, identifier, locale = 'zh-CN' }) {
|
|
42
|
+
const checked = validateIdentifier(kind, identifier);
|
|
43
|
+
if (!checked.ok) return checked;
|
|
44
|
+
try {
|
|
45
|
+
await cindyRequest(normalizeAuthBaseUrl(authBaseUrl), `/api/auth/${kind}/request-code`, { [kind]: checked.value, locale });
|
|
46
|
+
return { ok: true, identifier: checked.value };
|
|
47
|
+
} catch {
|
|
48
|
+
return { ok: false, message: '验证码发送失败,请检查账号或网络' };
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Select one token pair from a Cindy auth answer, or explain why there is none. */
|
|
53
|
+
function tokenPairOf(outcome) {
|
|
54
|
+
const tokens = outcome?.tokens || outcome;
|
|
55
|
+
if (typeof tokens?.accessToken !== 'string' || typeof tokens?.refreshToken !== 'string') {
|
|
56
|
+
return { ok: false, message: 'Cindy 未返回登录凭据' };
|
|
57
|
+
}
|
|
58
|
+
return {
|
|
59
|
+
ok: true,
|
|
60
|
+
session: {
|
|
61
|
+
accessToken: tokens.accessToken,
|
|
62
|
+
refreshToken: tokens.refreshToken,
|
|
63
|
+
...(tokens.expiresAt === undefined ? {} : { expiresAt: tokens.expiresAt }),
|
|
64
|
+
},
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Exchange a verification code for a Cindy session.
|
|
70
|
+
*
|
|
71
|
+
* The device handle is minted once and reused, so a second login on the same
|
|
72
|
+
* Host does not register as a new device (`cindy-login.js` mints one per
|
|
73
|
+
* process; a card can log in repeatedly, so the caller supplies and persists it).
|
|
74
|
+
* @param options - auth base URL, kind, identifier, code, and the stable device handle.
|
|
75
|
+
* @returns an ok session, or a status naming what Cindy still requires.
|
|
76
|
+
*/
|
|
77
|
+
export async function verifyLoginCode({ authBaseUrl, kind, identifier, code, deviceId, clientType = 'desktop', locale = 'zh-CN' }) {
|
|
78
|
+
const checked = validateIdentifier(kind, identifier);
|
|
79
|
+
if (!checked.ok) return checked;
|
|
80
|
+
const trimmedCode = typeof code === 'string' ? code.trim() : '';
|
|
81
|
+
if (trimmedCode === '') return { ok: false, message: '请填写验证码' };
|
|
82
|
+
if (typeof deviceId !== 'string' || deviceId === '') return { ok: false, message: '缺少设备标识' };
|
|
83
|
+
|
|
84
|
+
const base = normalizeAuthBaseUrl(authBaseUrl);
|
|
85
|
+
let outcome;
|
|
86
|
+
try {
|
|
87
|
+
outcome = await cindyRequest(base, `/api/auth/${kind}/verify-code`, { [kind]: checked.value, code: trimmedCode, deviceId, clientType, locale });
|
|
88
|
+
} catch {
|
|
89
|
+
return { ok: false, message: '验证失败,请检查验证码或网络' };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (outcome?.status === 'select_account') {
|
|
93
|
+
const accounts = Array.isArray(outcome.accounts) ? outcome.accounts.map((account) => ({ id: account?.id, displayName: account?.displayName })) : [];
|
|
94
|
+
return { ok: false, status: 'select_account', message: '该账号需要选择要登录的 Cindy 账户', accounts, loginTicket: outcome.loginTicket };
|
|
95
|
+
}
|
|
96
|
+
if (outcome?.status === 'binding_required') return { ok: false, status: 'binding_required', message: '该 Cindy 账号需要先完成绑定' };
|
|
97
|
+
if (outcome?.status === 'sso_verification_required') return { ok: false, status: 'sso_verification_required', message: '该 Cindy 账号需要 SSO 验证' };
|
|
98
|
+
if (outcome?.status !== undefined && outcome.status !== 'ok') return { ok: false, message: `Cindy 返回了未支持的登录结果:${String(outcome.status)}` };
|
|
99
|
+
|
|
100
|
+
const pair = tokenPairOf(outcome);
|
|
101
|
+
if (!pair.ok) return pair;
|
|
102
|
+
return { ok: true, session: { authBaseUrl: base, deviceId, kind, identifier: checked.value, clientType, ...pair.session } };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Finish a multi-account login once the user picked one. */
|
|
106
|
+
export async function selectLoginAccount({ authBaseUrl, loginTicket, accountId, deviceId, clientType = 'desktop', locale = 'zh-CN' }) {
|
|
107
|
+
if (typeof loginTicket !== 'string' || loginTicket === '') return { ok: false, message: '缺少登录票据' };
|
|
108
|
+
if (typeof accountId !== 'string' || accountId === '') return { ok: false, message: '缺少账户选择' };
|
|
109
|
+
const base = normalizeAuthBaseUrl(authBaseUrl);
|
|
110
|
+
let outcome;
|
|
111
|
+
try {
|
|
112
|
+
outcome = await cindyRequest(base, '/api/auth/select-account', { loginTicket, accountId, deviceId });
|
|
113
|
+
} catch {
|
|
114
|
+
return { ok: false, message: '账户选择失败,请重试' };
|
|
115
|
+
}
|
|
116
|
+
const pair = tokenPairOf(outcome);
|
|
117
|
+
if (!pair.ok) return pair;
|
|
118
|
+
return { ok: true, session: { authBaseUrl: base, deviceId, clientType, ...pair.session } };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Refresh a stored session. Rejects nothing: an expired session is a value, not a
|
|
123
|
+
* crash.
|
|
124
|
+
*
|
|
125
|
+
* It distinguishes **rejected** from **unreachable**, because the caller deletes
|
|
126
|
+
* the stored credential on the first and must never do so on the second:
|
|
127
|
+
*
|
|
128
|
+
* - the service answered and refused the token (400/401/403) → `rejected: true`;
|
|
129
|
+
* - the request timed out, DNS failed, or the service answered 5xx/429 →
|
|
130
|
+
* `transient: true`.
|
|
131
|
+
*
|
|
132
|
+
* Collapsing both into one outcome is what signed a working session out on a
|
|
133
|
+
* flaky connection: every Host start refreshes, so a single hiccup deleted the
|
|
134
|
+
* credential permanently and the user had to log in again.
|
|
135
|
+
* @param session - the stored session.
|
|
136
|
+
* @returns `{ ok: true, session }`, or a failure carrying `rejected` or `transient`.
|
|
137
|
+
*/
|
|
138
|
+
export async function refreshStoredSession(session, { signal } = {}) {
|
|
139
|
+
const base = normalizeAuthBaseUrl(session?.authBaseUrl);
|
|
140
|
+
if (typeof session?.refreshToken !== 'string' || typeof session?.deviceId !== 'string') {
|
|
141
|
+
return { ok: false, rejected: true, message: '本机没有可续期的 Cindy 登录态' };
|
|
142
|
+
}
|
|
143
|
+
let next;
|
|
144
|
+
try {
|
|
145
|
+
next = await cindyRequest(base, '/api/auth/refresh', { deviceId: session.deviceId, refreshToken: session.refreshToken }, { signal });
|
|
146
|
+
} catch (error) {
|
|
147
|
+
const status = typeof error?.status === 'number' ? error.status : null;
|
|
148
|
+
const refused = status === 400 || status === 401 || status === 403;
|
|
149
|
+
if (refused) return { ok: false, rejected: true, message: 'Cindy 登录态已过期' };
|
|
150
|
+
return {
|
|
151
|
+
ok: false,
|
|
152
|
+
transient: true,
|
|
153
|
+
message: `暂时无法续期 Cindy 登录态:${status === null ? '网络不通' : `HTTP ${status}`}`,
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
const pair = tokenPairOf(next);
|
|
157
|
+
if (!pair.ok) return pair;
|
|
158
|
+
return { ok: true, session: { ...session, authBaseUrl: base, ...pair.session } };
|
|
159
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { createInterface } from "node:readline/promises";
|
|
3
|
+
import { stdin, stdout } from "node:process";
|
|
4
|
+
|
|
5
|
+
/** Explicit email-code login. Tokens remain in memory and are never printed or persisted. */
|
|
6
|
+
export const MAINLAND_CINDY_AUTH_BASE_URL = "https://auth.cindy.com.cn";
|
|
7
|
+
|
|
8
|
+
export function loginWithEmail(options = {}) { return loginWithVerification({ ...options, kind: "email", identifierLabel: "Cindy email" }); }
|
|
9
|
+
export function loginWithPhone(options = {}) { return loginWithVerification({ ...options, kind: "phone", identifierLabel: "Cindy phone number" }); }
|
|
10
|
+
|
|
11
|
+
async function loginWithVerification({ authBaseUrl = MAINLAND_CINDY_AUTH_BASE_URL, locale = "zh-CN", clientType = "desktop", prompt = defaultPrompt, kind, identifierLabel }) {
|
|
12
|
+
const base = authBaseUrl.replace(/\/$/, "");
|
|
13
|
+
const deviceId = randomUUID();
|
|
14
|
+
const identifier = await prompt(`${identifierLabel}: `);
|
|
15
|
+
await request(base, `/api/auth/${kind}/request-code`, { [kind]: identifier, locale });
|
|
16
|
+
const code = await prompt("Verification code: " );
|
|
17
|
+
const outcome = await request(base, `/api/auth/${kind}/verify-code`, { [kind]: identifier, code, deviceId, clientType, locale });
|
|
18
|
+
const resolved = await resolveLoginOutcome({ base, outcome, deviceId, prompt });
|
|
19
|
+
const tokens = selectTokenPair(resolved);
|
|
20
|
+
return { authBaseUrl: base, deviceId, accessToken: tokens.accessToken, refreshToken: tokens.refreshToken, expiresAt: tokens.expiresAt };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export async function refreshLogin({ authBaseUrl, deviceId, refreshToken }) {
|
|
24
|
+
const base = authBaseUrl.replace(/\/$/, "");
|
|
25
|
+
return request(base, "/api/auth/refresh", { deviceId, refreshToken });
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Send one Cindy auth request. Shared by the interactive CLI flow below and the
|
|
30
|
+
* settings-card flow in `cindy-login-flow.js`, so both speak the same wire.
|
|
31
|
+
*
|
|
32
|
+
* A failure carries `status` when the service answered at all. That distinction is
|
|
33
|
+
* load-bearing for refresh: "the service said this refresh token is invalid" and
|
|
34
|
+
* "the request never arrived" look identical otherwise, and treating the second as
|
|
35
|
+
* the first signs the user out (see `refreshStoredSession`). A transport failure
|
|
36
|
+
* carries `transport: true` and no status.
|
|
37
|
+
*/
|
|
38
|
+
export async function cindyRequest(base, path, body, { signal } = {}) {
|
|
39
|
+
let response;
|
|
40
|
+
try {
|
|
41
|
+
response = await fetch(base + path, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body), ...(signal ? { signal } : {}) });
|
|
42
|
+
} catch (error) {
|
|
43
|
+
const failure = new Error(`Cindy request failed to reach the service: ${String(error?.message ?? error)}`);
|
|
44
|
+
failure.transport = true;
|
|
45
|
+
throw failure;
|
|
46
|
+
}
|
|
47
|
+
if (!response.ok) {
|
|
48
|
+
const failure = new Error(`Cindy authentication failed (HTTP ${response.status})`);
|
|
49
|
+
failure.status = response.status;
|
|
50
|
+
throw failure;
|
|
51
|
+
}
|
|
52
|
+
return response.json();
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const request = cindyRequest;
|
|
56
|
+
|
|
57
|
+
async function resolveLoginOutcome({ base, outcome, deviceId, prompt }) {
|
|
58
|
+
if (outcome.status === "ok") return outcome;
|
|
59
|
+
if (outcome.status === "select_account") {
|
|
60
|
+
const choices = outcome.accounts.map((account, index) => `${index + 1}. ${account.displayName}`).join("\n");
|
|
61
|
+
const answer = await prompt(`Select Cindy account:\n${choices}\n> `);
|
|
62
|
+
const selected = outcome.accounts[Number(answer) - 1];
|
|
63
|
+
if (!selected) throw new Error("Invalid Cindy account selection");
|
|
64
|
+
return request(base, "/api/auth/select-account", { loginTicket: outcome.loginTicket, accountId: selected.id, deviceId });
|
|
65
|
+
}
|
|
66
|
+
if (outcome.status === "binding_required") throw new Error("Cindy account binding is required; complete binding in Cindy first");
|
|
67
|
+
if (outcome.status === "sso_verification_required") throw new Error("Cindy SSO verification is required; this MVP supports email-code login only");
|
|
68
|
+
throw new Error("Unsupported Cindy login outcome");
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function selectTokenPair(outcome) {
|
|
72
|
+
const tokens = outcome.tokens || outcome;
|
|
73
|
+
if (typeof tokens.accessToken !== "string" || typeof tokens.refreshToken !== "string") throw new Error("Cindy authentication returned no token pair");
|
|
74
|
+
return tokens;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async function defaultPrompt(label) {
|
|
78
|
+
const rl = createInterface({ input: stdin, output: stdout });
|
|
79
|
+
try { return (await rl.question(label)).trim(); } finally { rl.close(); }
|
|
80
|
+
}
|
|
@@ -0,0 +1,432 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fold DSH model messages into the rows the Cindy phone renders.
|
|
3
|
+
*
|
|
4
|
+
* DSH stores one message with a `content: ContentBlock[]`; the phone stores one
|
|
5
|
+
* row per block. The split is the whole job: an assistant turn that thought,
|
|
6
|
+
* called a tool, and then answered becomes a `thinking` row, a `tool_use` row,
|
|
7
|
+
* and an `assistant` row, in that order.
|
|
8
|
+
*
|
|
9
|
+
* The target is `RemoteMessage` (`apps/mobile/src/session/types.ts`), and the
|
|
10
|
+
* fields each role reads are defined by the phone's own normalizer
|
|
11
|
+
* (`apps/mobile/src/session/messageNormalize.ts`), not by this file:
|
|
12
|
+
*
|
|
13
|
+
* assistant / user content: string (or `{ text, files }` for attachments)
|
|
14
|
+
* thinking content: string
|
|
15
|
+
* tool_use content: { toolName, input } read by `parseMessageToolUse`
|
|
16
|
+
* tool_result content: anything previewable read by `contentToPreview`
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* DSH message roles this Host renders, and the phone row role each becomes.
|
|
21
|
+
*
|
|
22
|
+
* A role that is NOT in this map produces no rows at all. The earlier test was
|
|
23
|
+
* `role === 'user' ? 'user' : 'assistant'`, which made **every** unknown role an
|
|
24
|
+
* assistant turn — so DSH's `system/message` events arrived on the phone as
|
|
25
|
+
* ordinary replies. Those events are the prompt injection itself: DSH requires
|
|
26
|
+
* their source to be `{kind: 'plugin'}` and the plugin name to be present
|
|
27
|
+
* (`dsh-session`'s own `assertMessageEventShape`), and the plugin that emits
|
|
28
|
+
* them is `@deepseek-ai/dsh-system-prompt`. Nothing about the model's
|
|
29
|
+
* instructions belongs in a conversation transcript, so the default is silence
|
|
30
|
+
* rather than a guess.
|
|
31
|
+
*/
|
|
32
|
+
const ROW_ROLE_BY_MESSAGE_ROLE = new Map([
|
|
33
|
+
['user', 'user'],
|
|
34
|
+
['assistant', 'assistant'],
|
|
35
|
+
]);
|
|
36
|
+
|
|
37
|
+
/** Roles the phone knows; anything else would be dropped by its normalizer. */
|
|
38
|
+
const ROLE_BY_BLOCK = {
|
|
39
|
+
text: 'text',
|
|
40
|
+
reasoning: 'thinking',
|
|
41
|
+
'tool-call': 'tool_use',
|
|
42
|
+
'tool-result': 'tool_result',
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
/** Parse the raw JSON argument string a tool call carries. */
|
|
46
|
+
function parseToolArguments(raw) {
|
|
47
|
+
if (typeof raw !== 'string') return raw ?? null;
|
|
48
|
+
const trimmed = raw.trim();
|
|
49
|
+
if (trimmed === '') return null;
|
|
50
|
+
try {
|
|
51
|
+
return JSON.parse(trimmed);
|
|
52
|
+
} catch {
|
|
53
|
+
// A malformed argument string is still the only thing the model produced;
|
|
54
|
+
// the phone's summary formatter handles a non-object input.
|
|
55
|
+
return trimmed;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* DSH tool names → the names the phone's renderer switches on.
|
|
61
|
+
*
|
|
62
|
+
* Most of the phone's cards are chosen by input shape, but the plan/todo card is
|
|
63
|
+
* chosen by **name**: `extractPlanTodos` reads `input.todos` only for
|
|
64
|
+
* `TodoWrite`, and `agentPlanSource` returns `'todo'` for that name alone
|
|
65
|
+
* (`@cindy/maker-shared/message-render`). DSH's equivalent tool is `todo_write`
|
|
66
|
+
* and carries the identical `{ todos: [{ content, status, activeForm? }] }`
|
|
67
|
+
* input, so the card was never selected and the agent's plan stayed invisible on
|
|
68
|
+
* the phone. This translates the name only — never the data.
|
|
69
|
+
*/
|
|
70
|
+
const TOOL_NAME_BY_DSH_NAME = { todo_write: 'TodoWrite' };
|
|
71
|
+
|
|
72
|
+
/** Map one DSH tool name to the phone's vocabulary. */
|
|
73
|
+
function cindyToolName(name) {
|
|
74
|
+
return TOOL_NAME_BY_DSH_NAME[name] ?? name;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* The phone's entry for one durable DSH attachment block.
|
|
79
|
+
*
|
|
80
|
+
* The phone reads an attachment from the message's **content JSON**, and it reads
|
|
81
|
+
* the two kinds from two different fields (`messageNormalize.ts`):
|
|
82
|
+
*
|
|
83
|
+
* - `files[]` → `readFileAttachments`: `{ name, path?, url?, mimeType? }`. A file
|
|
84
|
+
* with neither `path` nor `url` renders as 没有可展示的远程路径 — which is what an
|
|
85
|
+
* image reported here used to look like.
|
|
86
|
+
* - `images[]` → `readImageAttachments`: needs a `url` **or** `base64`, and an entry
|
|
87
|
+
* with neither is dropped outright.
|
|
88
|
+
*
|
|
89
|
+
* DSH stores an image content-addressed and states that its reference is "never a
|
|
90
|
+
* filesystem path or bearer URL", so there is nothing to put in `path` and nothing to
|
|
91
|
+
* serve as a URL. What it does give is a `attachmentId` that `ctx.attachments.readImage`
|
|
92
|
+
* can turn back into bytes — so an image entry carries that handle **internally**
|
|
93
|
+
* (`imageRef`), the page is hydrated into `images[]` by
|
|
94
|
+
* {@link hydrateImageAttachments}, and the handle is stripped before the row leaves
|
|
95
|
+
* this Host. An image whose bytes cannot be read stays an honest file chip rather than
|
|
96
|
+
* a fabricated path.
|
|
97
|
+
*
|
|
98
|
+
* @param block - one `image` or `file` content block.
|
|
99
|
+
* @returns the entry, or null when the block names nothing.
|
|
100
|
+
*/
|
|
101
|
+
function attachmentEntryOf(block) {
|
|
102
|
+
const ref = block?.attachment;
|
|
103
|
+
const name = typeof ref?.name === 'string' && ref.name !== '' ? ref.name : '';
|
|
104
|
+
const bytes = Number.isFinite(ref?.bytes) ? ref.bytes : undefined;
|
|
105
|
+
if (name === '' && bytes === undefined) return null;
|
|
106
|
+
const mimeType = typeof ref?.mediaType === 'string' && ref.mediaType !== '' ? ref.mediaType : undefined;
|
|
107
|
+
// The **whole** durable descriptor travels as the handle, not a subset: the store
|
|
108
|
+
// validates the reference it is asked to read (it re-derives the object from it), and
|
|
109
|
+
// a handle missing `width`/`height`/`name` is a different value than the one DSH
|
|
110
|
+
// stored. Measured: a subset silently failed every image read.
|
|
111
|
+
const imageRef = block?.type === 'image' && ref !== null && typeof ref === 'object' && typeof ref.attachmentId === 'string' && ref.attachmentId !== ''
|
|
112
|
+
? { ...ref }
|
|
113
|
+
: null;
|
|
114
|
+
return {
|
|
115
|
+
...(name === '' ? {} : { name }),
|
|
116
|
+
...(bytes === undefined ? {} : { size: bytes }),
|
|
117
|
+
...(mimeType === undefined ? {} : { mimeType }),
|
|
118
|
+
...(imageRef === null ? {} : { imageRef }),
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Longest image this Host will inline into a transcript row.
|
|
124
|
+
*
|
|
125
|
+
* An inlined image is base64 (≈4/3 of the bytes) inside a JSON row the phone keeps in
|
|
126
|
+
* memory and in its on-disk message cache, so this is a memory budget rather than a
|
|
127
|
+
* protocol limit — and DSH's own normalization has usually shrunk the stored image
|
|
128
|
+
* well below it. Past the bound the entry stays a file chip: honest, if not pretty.
|
|
129
|
+
*/
|
|
130
|
+
export const MAX_INLINE_IMAGE_BYTES = 1024 * 1024;
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* How much inlined image data one page may carry in total.
|
|
134
|
+
*
|
|
135
|
+
* This is the bound that actually matters, and it comes from the wire, not from
|
|
136
|
+
* memory: the relay **rejects** any device-link frame over `MAX_FRAME_BYTES`
|
|
137
|
+
* (2 MiB) outright, so a page whose images add up past the ceiling does not arrive
|
|
138
|
+
* late — it does not arrive at all, and the controller is left with a window it
|
|
139
|
+
* cannot fill (reported as 整个对话变成只有一行我的输出、重新加载更早消息也没有响应).
|
|
140
|
+
*
|
|
141
|
+
* 1 MiB of base64 is ≈ 768 KiB of image data, and after JSON and the page's own text
|
|
142
|
+
* rows the frame still lands around 1.1–1.4 MiB — comfortably inside the 2 MiB
|
|
143
|
+
* ceiling with room for the rest of the transcript. Images beyond the budget stay
|
|
144
|
+
* file chips, which is a per-attachment degradation rather than a per-page failure.
|
|
145
|
+
*/
|
|
146
|
+
export const MAX_INLINE_IMAGE_TOTAL_BYTES = 1024 * 1024;
|
|
147
|
+
|
|
148
|
+
/** Estimated JSON overhead one inlined image adds on top of its base64. */
|
|
149
|
+
const INLINE_IMAGE_ENTRY_BYTES = 96;
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Turn the page's image handles into bytes the phone can render.
|
|
153
|
+
*
|
|
154
|
+
* Only the rows being returned are hydrated (not the whole session log), so a
|
|
155
|
+
* transcript with a hundred photos costs one page's worth of reads. Each image is
|
|
156
|
+
* read once even when several rows share it, and **one unreadable image costs that
|
|
157
|
+
* image, never the page** — the row keeps its file entry instead.
|
|
158
|
+
*
|
|
159
|
+
* The page's rows arrive newest first, so the budget is spent from the newest
|
|
160
|
+
* picture backwards: the images the user is looking at are the ones that render.
|
|
161
|
+
*
|
|
162
|
+
* @param rows - the page of rows about to be served.
|
|
163
|
+
* @param options - `readImage` (the attachment store's reader), `maxBytes` (one
|
|
164
|
+
* image), and `maxTotalBytes` (the whole page).
|
|
165
|
+
* @returns the same rows, with readable images moved into `content.images[]`.
|
|
166
|
+
*/
|
|
167
|
+
export async function hydrateImageAttachments(rows, {
|
|
168
|
+
readImage,
|
|
169
|
+
maxBytes = MAX_INLINE_IMAGE_BYTES,
|
|
170
|
+
maxTotalBytes = MAX_INLINE_IMAGE_TOTAL_BYTES,
|
|
171
|
+
} = {}) {
|
|
172
|
+
const list = Array.isArray(rows) ? rows : [];
|
|
173
|
+
// Plan first, read second.
|
|
174
|
+
//
|
|
175
|
+
// The page's rows arrive newest first, so the budget is spent from the newest
|
|
176
|
+
// picture backwards — the images the user is looking at are the ones that render.
|
|
177
|
+
// A descriptor that declares its size is judged on that number, so an image too
|
|
178
|
+
// large for the page is never read at all: reading 2.4 MB to throw it away is work
|
|
179
|
+
// the page does not owe anyone (and it made the inlining counters lie about what
|
|
180
|
+
// the page tried to serve).
|
|
181
|
+
const planned = new Set();
|
|
182
|
+
const wanted = new Map();
|
|
183
|
+
let spent = 0;
|
|
184
|
+
for (const row of list) {
|
|
185
|
+
for (const entry of Array.isArray(row?.content?.files) ? row.content.files : []) {
|
|
186
|
+
const handle = entry?.imageRef;
|
|
187
|
+
if (handle === null || typeof handle !== 'object' || typeof handle.attachmentId !== 'string') continue;
|
|
188
|
+
if (wanted.has(handle.attachmentId)) continue;
|
|
189
|
+
const declared = Number.isFinite(entry?.size) ? entry.size : (Number.isFinite(handle?.bytes) ? handle.bytes : null);
|
|
190
|
+
if (declared !== null && declared > maxBytes) continue;
|
|
191
|
+
if (declared !== null) {
|
|
192
|
+
// base64 is ≈4/3 of the bytes, plus the entry's own JSON.
|
|
193
|
+
const cost = Math.ceil((declared * 4) / 3) + INLINE_IMAGE_ENTRY_BYTES;
|
|
194
|
+
if (spent + cost > maxTotalBytes) continue;
|
|
195
|
+
spent += cost;
|
|
196
|
+
}
|
|
197
|
+
wanted.set(handle.attachmentId, handle);
|
|
198
|
+
planned.add(handle.attachmentId);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
const inlined = new Map();
|
|
202
|
+
if (typeof readImage === 'function' && wanted.size > 0) {
|
|
203
|
+
await Promise.all([...wanted].map(async ([attachmentId, handle]) => {
|
|
204
|
+
try {
|
|
205
|
+
const stored = await readImage(handle);
|
|
206
|
+
if (stored === null || typeof stored !== 'object') return;
|
|
207
|
+
if (typeof stored.base64 !== 'string' || stored.base64 === '') return;
|
|
208
|
+
// An undeclared size can only be judged now, and an oversize one costs this
|
|
209
|
+
// image rather than the page.
|
|
210
|
+
if (Number.isFinite(stored.bytes) && stored.bytes > maxBytes) return;
|
|
211
|
+
inlined.set(attachmentId, stored);
|
|
212
|
+
} catch {
|
|
213
|
+
// Per attachment, exactly like every other unservable attachment here.
|
|
214
|
+
}
|
|
215
|
+
}));
|
|
216
|
+
}
|
|
217
|
+
for (const row of list) {
|
|
218
|
+
const files = Array.isArray(row?.content?.files) ? row.content.files : null;
|
|
219
|
+
if (files === null) continue;
|
|
220
|
+
const images = [];
|
|
221
|
+
const kept = [];
|
|
222
|
+
for (const entry of files) {
|
|
223
|
+
const handle = entry?.imageRef;
|
|
224
|
+
const stored = handle === undefined ? undefined : inlined.get(handle.attachmentId);
|
|
225
|
+
if (stored !== undefined) {
|
|
226
|
+
// `base64` (not `url`) is what the phone's normalizer turns into
|
|
227
|
+
// `data:<mimeType>;base64,…`, which `isPreviewableUri` accepts — so the bubble
|
|
228
|
+
// renders the picture itself, with no media channel and no fetch.
|
|
229
|
+
const mimeType = stored.mediaType ?? entry.mimeType ?? 'image/png';
|
|
230
|
+
images.push({
|
|
231
|
+
base64: stored.base64,
|
|
232
|
+
mimeType,
|
|
233
|
+
...(entry.name === undefined ? {} : { originalName: entry.name }),
|
|
234
|
+
});
|
|
235
|
+
continue;
|
|
236
|
+
}
|
|
237
|
+
const { imageRef: _handle, ...rest } = entry;
|
|
238
|
+
kept.push(rest);
|
|
239
|
+
}
|
|
240
|
+
const content = { ...row.content };
|
|
241
|
+
if (images.length > 0) content.images = images;
|
|
242
|
+
if (kept.length > 0) content.files = kept;
|
|
243
|
+
else delete content.files;
|
|
244
|
+
row.content = content;
|
|
245
|
+
}
|
|
246
|
+
return list;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** Flatten nested tool-result content into the text the phone previews. */
|
|
250
|
+
function flattenToolResult(blocks) {
|
|
251
|
+
const list = Array.isArray(blocks) ? blocks : [];
|
|
252
|
+
const parts = [];
|
|
253
|
+
for (const block of list) {
|
|
254
|
+
if (block === null || typeof block !== 'object') continue;
|
|
255
|
+
if (block.type === 'text' && typeof block.text === 'string') parts.push(block.text);
|
|
256
|
+
else if (typeof block.text === 'string') parts.push(block.text);
|
|
257
|
+
}
|
|
258
|
+
return parts.join('\n');
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* The message sources this Host renders, on purpose and by name.
|
|
263
|
+
*
|
|
264
|
+
* DSH states the rule itself: a `user/message` event carries **three** producers
|
|
265
|
+
* — a human's prompt, a plugin notification, and an injected goal continuation
|
|
266
|
+
* round — and "all three project their `content` verbatim; `source` tells them
|
|
267
|
+
* apart" (`dsh-session` known event types). Role alone is therefore never enough,
|
|
268
|
+
* and a *denylist* is never enough either: this started as "drop `plugin`", which
|
|
269
|
+
* cleaned up the system prompt and the runtime snapshot and still left the skills
|
|
270
|
+
* catalog in the transcript, because `@deepseek-ai/dsh-tool-skill` builds that one
|
|
271
|
+
* with `source: { kind: 'skill-catalog', form: 'catalog' }` on a `user` message.
|
|
272
|
+
*
|
|
273
|
+
* So the test is inverted: render exactly what a participant produced — the
|
|
274
|
+
* human (`user`), the model (`model`), and a tool's own result (`tool`) — and
|
|
275
|
+
* every other named kind is producer-supplied context that belongs in the model's
|
|
276
|
+
* request, not in a conversation transcript. A source that is absent entirely is
|
|
277
|
+
* still rendered: DSH requires one on every message, so its absence means a shape
|
|
278
|
+
* this Host cannot classify, and hiding a user's own words is the worse failure.
|
|
279
|
+
*/
|
|
280
|
+
const RENDERABLE_SOURCE_KINDS = new Set(['user', 'model', 'tool']);
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Build the Cindy rows for one DSH message.
|
|
284
|
+
* @param message - `{ id, role, content }` in DSH's model shape.
|
|
285
|
+
* @param context - the session id, the row's creation time (ISO), and — for a
|
|
286
|
+
* user turn — the `clientId` the controller minted for the prompt.
|
|
287
|
+
* @returns one row per renderable content block, in order.
|
|
288
|
+
*/
|
|
289
|
+
export function toCindyMessageRows(message, { sessionId, createdAt, now = () => new Date(), promptClientId = null }) {
|
|
290
|
+
const rows = [];
|
|
291
|
+
const role = ROW_ROLE_BY_MESSAGE_ROLE.get(message?.role);
|
|
292
|
+
// `system` (the prompt injection), a hook's injected turn, or any role this
|
|
293
|
+
// Host has never seen: none of them is something a participant said.
|
|
294
|
+
if (role === undefined) return rows;
|
|
295
|
+
const blocks = Array.isArray(message?.content) ? message.content : [];
|
|
296
|
+
// DSH records a tool result as a `user`-role message whose source is
|
|
297
|
+
// `{kind: 'tool'}`. Its content block is what makes it a result row; a *text*
|
|
298
|
+
// block on such a message is not the user speaking, so it is not rendered as
|
|
299
|
+
// one.
|
|
300
|
+
const sourceKind = typeof message?.source?.kind === 'string' ? message.source.kind : null;
|
|
301
|
+
// Everything a producer injected — the system prompt and runtime-context
|
|
302
|
+
// snapshot (`plugin`), the skills catalog (`skill-catalog`), and whatever kind
|
|
303
|
+
// the next plugin adds — is the model's request, not the conversation. The
|
|
304
|
+
// runtime snapshot is re-injected on every turn, so rendering it meant the
|
|
305
|
+
// phone showed the whole system prompt as an assistant reply and a
|
|
306
|
+
// "Current runtime context. This snapshot supersedes…" bubble as a user message.
|
|
307
|
+
if (sourceKind !== null && !RENDERABLE_SOURCE_KINDS.has(sourceKind)) return rows;
|
|
308
|
+
const toolResultMessage = sourceKind === 'tool';
|
|
309
|
+
const time = createdAt ?? now().toISOString();
|
|
310
|
+
// A user turn's attachments belong to the message, not to a block: the phone
|
|
311
|
+
// reads them from the same `content` object as the text, so they are collected
|
|
312
|
+
// here and carried by the message's text row (or by a row of their own when the
|
|
313
|
+
// message was nothing but attachments).
|
|
314
|
+
const attachments = role === 'user' && !toolResultMessage
|
|
315
|
+
? blocks.filter((block) => block?.type === 'image' || block?.type === 'file').map(attachmentEntryOf).filter(Boolean)
|
|
316
|
+
: [];
|
|
317
|
+
let attachmentsCarried = false;
|
|
318
|
+
|
|
319
|
+
blocks.forEach((block, index) => {
|
|
320
|
+
if (block === null || typeof block !== 'object') return;
|
|
321
|
+
const kind = ROLE_BY_BLOCK[block.type];
|
|
322
|
+
// Images and files are not rows of their own here: an image needs a media
|
|
323
|
+
// channel this Host does not serve, and the file's chip travels on the text
|
|
324
|
+
// row above. Emitting them as empty rows would add noise, not information.
|
|
325
|
+
if (kind === undefined) return;
|
|
326
|
+
if (toolResultMessage && block.type !== 'tool-result') return;
|
|
327
|
+
|
|
328
|
+
// A stable id: the phone keys, dedupes, and patches rows by it.
|
|
329
|
+
const id = `${sessionId}:${message.id}:${index}`;
|
|
330
|
+
// The controller shows a message the moment it sends it, and retires that
|
|
331
|
+
// optimistic copy by matching `clientId` against the durable row's
|
|
332
|
+
// (`projectOptimisticUserMessages` splices out the echo on exactly that
|
|
333
|
+
// field). A synthetic id here means the echo is never recognised, so the
|
|
334
|
+
// user sees their message twice — once spinning, once real — until some
|
|
335
|
+
// later reconciliation drops one. Carrying the prompt's own `clientId` on
|
|
336
|
+
// the user row is what lets the controller retire its copy immediately.
|
|
337
|
+
const clientId = role === 'user' && typeof promptClientId === 'string' && promptClientId !== '' ? promptClientId : id;
|
|
338
|
+
const base = {
|
|
339
|
+
id,
|
|
340
|
+
clientId,
|
|
341
|
+
sessionId,
|
|
342
|
+
role: kind === 'text' ? role : kind,
|
|
343
|
+
toolUseId: null,
|
|
344
|
+
agentMeta: null,
|
|
345
|
+
createdAt: time,
|
|
346
|
+
};
|
|
347
|
+
|
|
348
|
+
switch (block.type) {
|
|
349
|
+
case 'text':
|
|
350
|
+
// Reasoning models emit empty text blocks for usage only; the phone's
|
|
351
|
+
// normalizer drops them, but not emitting them at all is cheaper.
|
|
352
|
+
if (typeof block.text !== 'string' || block.text === '') return;
|
|
353
|
+
const carriesAttachments = attachments.length > 0 && !attachmentsCarried;
|
|
354
|
+
rows.push({
|
|
355
|
+
...base,
|
|
356
|
+
// Desktop stringifies objects before rendering assistant markdown; use native prose strings.
|
|
357
|
+
content: carriesAttachments ? { text: block.text, files: attachments } : block.text,
|
|
358
|
+
});
|
|
359
|
+
attachmentsCarried = attachmentsCarried || carriesAttachments;
|
|
360
|
+
return;
|
|
361
|
+
case 'reasoning':
|
|
362
|
+
if (typeof block.text !== 'string' || block.text === '') return;
|
|
363
|
+
rows.push({ ...base, content: block.text });
|
|
364
|
+
return;
|
|
365
|
+
case 'tool-call':
|
|
366
|
+
rows.push({
|
|
367
|
+
...base,
|
|
368
|
+
toolUseId: typeof block.id === 'string' ? block.id : null,
|
|
369
|
+
content: {
|
|
370
|
+
toolName: typeof block.name === 'string' ? cindyToolName(block.name) : '',
|
|
371
|
+
input: parseToolArguments(block.arguments),
|
|
372
|
+
...(typeof block.id === 'string' ? { toolUseId: block.id } : {}),
|
|
373
|
+
},
|
|
374
|
+
});
|
|
375
|
+
return;
|
|
376
|
+
case 'tool-result':
|
|
377
|
+
rows.push({
|
|
378
|
+
...base,
|
|
379
|
+
toolUseId: typeof block.toolCallId === 'string' ? block.toolCallId : null,
|
|
380
|
+
content: { text: flattenToolResult(block.content), isError: block.isError === true },
|
|
381
|
+
});
|
|
382
|
+
return;
|
|
383
|
+
default:
|
|
384
|
+
return;
|
|
385
|
+
}
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
// A message that was nothing but attachments still has to show them: the phone
|
|
389
|
+
// renders the chip from the user row, and a message with no rows at all would
|
|
390
|
+
// simply not exist in the transcript.
|
|
391
|
+
if (attachments.length > 0 && !attachmentsCarried) {
|
|
392
|
+
const id = `${sessionId}:${message.id}:attachments`;
|
|
393
|
+
rows.unshift({
|
|
394
|
+
id,
|
|
395
|
+
clientId: typeof promptClientId === 'string' && promptClientId !== '' ? promptClientId : id,
|
|
396
|
+
sessionId,
|
|
397
|
+
role: 'user',
|
|
398
|
+
toolUseId: null,
|
|
399
|
+
agentMeta: null,
|
|
400
|
+
createdAt: time,
|
|
401
|
+
content: { text: '', files: attachments },
|
|
402
|
+
});
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
return rows;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/**
|
|
409
|
+
* Fold a whole session's messages, newest first.
|
|
410
|
+
*
|
|
411
|
+
* The phone's paging contract is `ORDER BY createdAt DESC` with the oldest row
|
|
412
|
+
* at the page tail (`apps/mobile/src/session/historyWindowGap.ts`), and its
|
|
413
|
+
* cursor is a timestamp, so the order here is part of the contract.
|
|
414
|
+
* @param messages - DSH model messages with their event times.
|
|
415
|
+
* @param context - the session id.
|
|
416
|
+
* @returns the rows the phone parses, newest first.
|
|
417
|
+
*/
|
|
418
|
+
export function toCindyMessages(messages, { sessionId, now = () => new Date() }) {
|
|
419
|
+
const list = Array.isArray(messages) ? messages : [];
|
|
420
|
+
// Reverse the MESSAGE order only. Within one message the blocks keep their
|
|
421
|
+
// order (thought → tool → answer): they share a timestamp, so the phone's
|
|
422
|
+
// stable sort would otherwise present them backwards.
|
|
423
|
+
const perMessage = list.map((entry) => toCindyMessageRows(entry.message, {
|
|
424
|
+
sessionId,
|
|
425
|
+
createdAt: entry.createdAt,
|
|
426
|
+
now,
|
|
427
|
+
promptClientId: entry.promptClientId ?? null,
|
|
428
|
+
}));
|
|
429
|
+
const rows = [];
|
|
430
|
+
for (const group of perMessage.reverse()) rows.push(...group);
|
|
431
|
+
return rows;
|
|
432
|
+
}
|