@clowder-ai/companion 0.1.0-alpha.24 → 0.1.0-alpha.26
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 +15 -2
- package/manifest.json +3 -3
- package/package.json +3 -3
- package/plugin.yaml +3 -3
- package/renderer/client.mjs +53 -1
- package/renderer/conversation-draft.mjs +304 -0
- package/renderer/conversation-history.mjs +176 -0
- package/renderer/conversation-surface.mjs +188 -0
- package/renderer/conversation.mjs +16 -2
- package/renderer/index.html +21 -4
- package/renderer/pet-controls.mjs +8 -2
- package/renderer/reply-letter-controller.mjs +343 -0
- package/renderer/reply-letter-state.mjs +38 -0
- package/renderer/reply-letter-view.mjs +103 -0
- package/renderer/style.css +36 -6
- package/renderer/surface.mjs +33 -34
package/README.md
CHANGED
|
@@ -26,9 +26,9 @@ borrowing today's selected companion.
|
|
|
26
26
|
|
|
27
27
|
## Host integration
|
|
28
28
|
|
|
29
|
-
This surface requires the additive desktop bridge **1.
|
|
29
|
+
This surface requires the additive desktop bridge **1.5.0**. The Host computes
|
|
30
30
|
placement within the display work area, keeps the cat as its stable anchor, and
|
|
31
|
-
owns bounded drag gestures and transparent-area hit testing. Hosts below 1.
|
|
31
|
+
owns bounded drag gestures and transparent-area hit testing. Hosts below 1.5
|
|
32
32
|
reject this manifest before installation. Media admission and capture
|
|
33
33
|
remain in the trusted Host; this package receives neither streams nor provider
|
|
34
34
|
SDP. Hiding releases media; the existing Clowder **聊聊** entry restores the cat.
|
|
@@ -41,6 +41,19 @@ contract as the transcript surfaces that supply them. A Host
|
|
|
41
41
|
another current reference may be observed without retrying the rejected one.
|
|
42
42
|
Other failures retry after 1 and 2 seconds, then leave the call's
|
|
43
43
|
first-visible result unknown; later text never backfills a manufactured success.
|
|
44
|
+
Bridge 1.5 adds Host-bound conversation pages and versioned body continuations.
|
|
45
|
+
The renderer can only return opaque cursors issued by the Host; it cannot select
|
|
46
|
+
an owner, thread, message, call or byte offset. History progress, response phase
|
|
47
|
+
and body completeness remain separate, so a processing draft or truncated body
|
|
48
|
+
cannot masquerade as a completed reply.
|
|
49
|
+
|
|
50
|
+
It also binds the one-line composer to the Host-owned durable draft. Versioned
|
|
51
|
+
text submission carries the Host-issued conversation `scopeRef`, draft revision
|
|
52
|
+
and caller message identity. An unknown settlement is read back but never
|
|
53
|
+
automatically resent; an explicit retry keeps the original text, identity,
|
|
54
|
+
revision and route. If the canonical thread changes, the old unconfirmed input
|
|
55
|
+
stays visible as invalidated local text and is never replayed into the new scope.
|
|
56
|
+
The legacy text and conversation methods remain unchanged for older pairs.
|
|
44
57
|
|
|
45
58
|
Xianxian uses one layered sitting body or one VP9 clip at a time. Expiring Host
|
|
46
59
|
facts with task, result and event identities select tool work, workspace fetch,
|
package/manifest.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"pluginId": "official.companion",
|
|
3
|
-
"version": "0.1.0-alpha.
|
|
3
|
+
"version": "0.1.0-alpha.26",
|
|
4
4
|
"contractVersion": "0.1.0",
|
|
5
5
|
"name": "猫猫球",
|
|
6
6
|
"icon": {
|
|
@@ -37,10 +37,10 @@
|
|
|
37
37
|
"type": "desktop-window",
|
|
38
38
|
"id": "companion",
|
|
39
39
|
"role": "companion",
|
|
40
|
-
"bridgeVersion": "1.
|
|
40
|
+
"bridgeVersion": "1.5.0",
|
|
41
41
|
"surface": {
|
|
42
42
|
"entrypoint": "renderer/index.html",
|
|
43
|
-
"integrity": "sha256-
|
|
43
|
+
"integrity": "sha256-Q5N69iKFCZpX1mGm9KJWOvJaU3xd5mdGbojDBRZUTx4="
|
|
44
44
|
},
|
|
45
45
|
"presentation": {
|
|
46
46
|
"width": 300,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@clowder-ai/companion",
|
|
3
|
-
"version": "0.1.0-alpha.
|
|
3
|
+
"version": "0.1.0-alpha.26",
|
|
4
4
|
"description": "The desktop voice body for your Clowder companion",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -27,8 +27,8 @@
|
|
|
27
27
|
"prepack": "pnpm build"
|
|
28
28
|
},
|
|
29
29
|
"devDependencies": {
|
|
30
|
-
"@clowder-ai/plugin-contract": "0.1.0-beta.
|
|
31
|
-
"@clowder-ai/plugin-sdk": "0.1.0-beta.
|
|
30
|
+
"@clowder-ai/plugin-contract": "0.1.0-beta.28",
|
|
31
|
+
"@clowder-ai/plugin-sdk": "0.1.0-beta.24",
|
|
32
32
|
"playwright": "1.61.1"
|
|
33
33
|
},
|
|
34
34
|
"repository": {
|
package/plugin.yaml
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"pluginId": "official.companion",
|
|
3
|
-
"version": "0.1.0-alpha.
|
|
3
|
+
"version": "0.1.0-alpha.26",
|
|
4
4
|
"contractVersion": "0.1.0",
|
|
5
5
|
"name": "猫猫球",
|
|
6
6
|
"icon": {
|
|
@@ -37,10 +37,10 @@
|
|
|
37
37
|
"type": "desktop-window",
|
|
38
38
|
"id": "companion",
|
|
39
39
|
"role": "companion",
|
|
40
|
-
"bridgeVersion": "1.
|
|
40
|
+
"bridgeVersion": "1.5.0",
|
|
41
41
|
"surface": {
|
|
42
42
|
"entrypoint": "renderer/index.html",
|
|
43
|
-
"integrity": "sha256-
|
|
43
|
+
"integrity": "sha256-Q5N69iKFCZpX1mGm9KJWOvJaU3xd5mdGbojDBRZUTx4="
|
|
44
44
|
},
|
|
45
45
|
"presentation": {
|
|
46
46
|
"width": 300,
|
package/renderer/client.mjs
CHANGED
|
@@ -16,6 +16,50 @@ export function createCompanionClient(bridge) {
|
|
|
16
16
|
throw new CompanionRequestError('unavailable');
|
|
17
17
|
return reply;
|
|
18
18
|
}
|
|
19
|
+
async function readConversation() {
|
|
20
|
+
const response = await invoke({ kind: 'conversation.read' }, 'conversation');
|
|
21
|
+
if ('v' in response)
|
|
22
|
+
throw new CompanionRequestError('unavailable');
|
|
23
|
+
return response;
|
|
24
|
+
}
|
|
25
|
+
async function readConversationPage(options = {}) {
|
|
26
|
+
const command = options.before === undefined
|
|
27
|
+
? options.limit === undefined
|
|
28
|
+
? { kind: 'conversation.read', v: 2 }
|
|
29
|
+
: { kind: 'conversation.read', v: 2, limit: options.limit }
|
|
30
|
+
: options.limit === undefined
|
|
31
|
+
? { kind: 'conversation.read', v: 2, before: options.before }
|
|
32
|
+
: { kind: 'conversation.read', v: 2, before: options.before, limit: options.limit };
|
|
33
|
+
const response = await invoke(command, 'conversation');
|
|
34
|
+
if (!('v' in response) || response.v !== 2)
|
|
35
|
+
throw new CompanionRequestError('unavailable');
|
|
36
|
+
return response;
|
|
37
|
+
}
|
|
38
|
+
async function readConversationBody(continuation) {
|
|
39
|
+
return invoke({ kind: 'conversation.body.read', v: 2, continuation }, 'conversation-body');
|
|
40
|
+
}
|
|
41
|
+
async function readConversationDraft() {
|
|
42
|
+
return invoke({ kind: 'conversation.draft.read', v: 1 }, 'conversation-draft');
|
|
43
|
+
}
|
|
44
|
+
async function saveConversationDraft(expectedRevision, text, scopeRef) {
|
|
45
|
+
return invoke({
|
|
46
|
+
kind: 'conversation.draft.save', v: 1, scopeRef, expectedRevision, text,
|
|
47
|
+
}, 'conversation-draft');
|
|
48
|
+
}
|
|
49
|
+
async function invokeDeliveryV2(command) {
|
|
50
|
+
const response = await invoke(command, 'delivery');
|
|
51
|
+
if (!('v' in response) || response.v !== 2)
|
|
52
|
+
throw new CompanionRequestError('unavailable');
|
|
53
|
+
return response;
|
|
54
|
+
}
|
|
55
|
+
async function submitConversationText(text, clientMessageId, submissionRevision, scopeRef) {
|
|
56
|
+
return invokeDeliveryV2({
|
|
57
|
+
kind: 'text', v: 2, scopeRef, text, clientMessageId, submissionRevision,
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
async function readConversationDelivery(clientMessageId, scopeRef) {
|
|
61
|
+
return invokeDeliveryV2({ kind: 'conversation.delivery.read', v: 1, scopeRef, clientMessageId });
|
|
62
|
+
}
|
|
19
63
|
return {
|
|
20
64
|
state: () => invoke({ kind: 'state' }, 'state'),
|
|
21
65
|
prepare: () => invoke({ kind: 'prepare' }, 'state'),
|
|
@@ -26,6 +70,8 @@ export function createCompanionClient(bridge) {
|
|
|
26
70
|
stop: () => invoke({ kind: 'stop' }, 'ok'),
|
|
27
71
|
text: async (text, clientMessageId) => {
|
|
28
72
|
const receipt = await invoke({ kind: 'text', text, clientMessageId }, 'delivery');
|
|
73
|
+
if ('v' in receipt)
|
|
74
|
+
throw new CompanionRequestError('unavailable');
|
|
29
75
|
if (receipt.delivery !== 'accepted')
|
|
30
76
|
throw new CompanionRequestError('unconfirmed');
|
|
31
77
|
return receipt;
|
|
@@ -56,7 +102,13 @@ export function createCompanionClient(bridge) {
|
|
|
56
102
|
readSettings: () => invoke({ kind: 'settings.read' }, 'settings'),
|
|
57
103
|
updateSetting: (field, value) => invoke({ kind: 'settings.update', field, value }, 'settings-update'),
|
|
58
104
|
disableCompanion: () => invoke({ kind: 'companion.disable' }, 'companion-lifecycle'),
|
|
59
|
-
readConversation
|
|
105
|
+
readConversation,
|
|
106
|
+
readConversationPage,
|
|
107
|
+
readConversationBody,
|
|
108
|
+
readConversationDraft,
|
|
109
|
+
saveConversationDraft,
|
|
110
|
+
submitConversationText,
|
|
111
|
+
readConversationDelivery,
|
|
60
112
|
readTranscript: () => invoke({ kind: 'transcript.read' }, 'transcript'),
|
|
61
113
|
subscribe: (listener) => bridge.subscribe(listener),
|
|
62
114
|
};
|
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
const receiptFingerprint = receipt => [
|
|
2
|
+
receipt.delivery,
|
|
3
|
+
receipt.messageId ?? '',
|
|
4
|
+
receipt.reason ?? '',
|
|
5
|
+
receipt.draftRevision ?? '',
|
|
6
|
+
].join('\u0000');
|
|
7
|
+
|
|
8
|
+
const copySubmission = submission => ({
|
|
9
|
+
clientMessageId: submission.clientMessageId,
|
|
10
|
+
text: submission.text,
|
|
11
|
+
submissionRevision: submission.submissionRevision,
|
|
12
|
+
receipt: { ...submission.receipt },
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Renderer coordinator for the Host-owned composer draft.
|
|
17
|
+
*
|
|
18
|
+
* It keeps the current local edit separate from the last canonical Host text.
|
|
19
|
+
* Reads may reconcile receipts, but only submit() and retry() can send text.
|
|
20
|
+
*/
|
|
21
|
+
export class HostConversationDraft {
|
|
22
|
+
constructor({ client, changed = () => {}, delivery = () => {}, invalidated = () => {},
|
|
23
|
+
uuid = () => crypto.randomUUID(), schedule = setTimeout, cancel = clearTimeout,
|
|
24
|
+
debounceMs = 180 }) {
|
|
25
|
+
Object.assign(this, { client, changed, delivery, invalidated, uuid, debounceMs });
|
|
26
|
+
this.schedule = (...args) => Reflect.apply(schedule, globalThis, args);
|
|
27
|
+
this.cancel = (...args) => Reflect.apply(cancel, globalThis, args);
|
|
28
|
+
this.scopeRef = null;
|
|
29
|
+
this.revision = null;
|
|
30
|
+
this.canonicalText = '';
|
|
31
|
+
this.localText = '';
|
|
32
|
+
this.localVersion = 0;
|
|
33
|
+
this.localEdited = false;
|
|
34
|
+
this.writable = false;
|
|
35
|
+
this.status = 'loading';
|
|
36
|
+
this.submissions = new Map();
|
|
37
|
+
this.reportedReceipts = new Map();
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
snapshot() {
|
|
41
|
+
return {
|
|
42
|
+
scopeRef: this.scopeRef,
|
|
43
|
+
revision: this.revision,
|
|
44
|
+
canonicalText: this.canonicalText,
|
|
45
|
+
localText: this.localText,
|
|
46
|
+
writable: this.writable,
|
|
47
|
+
status: this.status,
|
|
48
|
+
submissions: [...this.submissions.values()].map(copySubmission),
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
canonicalReply() {
|
|
53
|
+
if (this.scopeRef === null || this.revision === null) return null;
|
|
54
|
+
return {
|
|
55
|
+
kind: 'conversation-draft', v: 1, status: 'available', scopeRef: this.scopeRef,
|
|
56
|
+
revision: this.revision, text: this.canonicalText, writable: this.writable,
|
|
57
|
+
submissions: [...this.submissions.values()].map(copySubmission),
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
emit() { this.changed(this.snapshot()); }
|
|
62
|
+
|
|
63
|
+
update(text) {
|
|
64
|
+
this.localText = text;
|
|
65
|
+
this.localEdited = true;
|
|
66
|
+
++this.localVersion;
|
|
67
|
+
if (!['scope_changed', 'readonly'].includes(this.status)) this.status = 'editing';
|
|
68
|
+
this.emit();
|
|
69
|
+
if (this.saveTimer !== undefined) this.cancel(this.saveTimer);
|
|
70
|
+
this.saveTimer = this.schedule(() => {
|
|
71
|
+
this.saveTimer = undefined;
|
|
72
|
+
void this.save();
|
|
73
|
+
}, this.debounceMs);
|
|
74
|
+
this.saveTimer?.unref?.();
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async load(options = {}) {
|
|
78
|
+
if (this.loading) return this.loading;
|
|
79
|
+
const operation = this.loadOnce(options);
|
|
80
|
+
this.loading = operation;
|
|
81
|
+
try { return await operation; }
|
|
82
|
+
finally { if (this.loading === operation) this.loading = undefined; }
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async loadOnce({ preserveStatus = null } = {}) {
|
|
86
|
+
try {
|
|
87
|
+
const response = await this.client.readConversationDraft();
|
|
88
|
+
if (response.status !== 'available') {
|
|
89
|
+
this.revision = response.revision;
|
|
90
|
+
this.writable = false;
|
|
91
|
+
this.status = response.status;
|
|
92
|
+
this.emit();
|
|
93
|
+
return response;
|
|
94
|
+
}
|
|
95
|
+
if (response.scopeRef === this.scopeRef && this.revision !== null
|
|
96
|
+
&& response.revision < this.revision) return response;
|
|
97
|
+
const scopeChanged = this.scopeRef !== null && this.scopeRef !== response.scopeRef;
|
|
98
|
+
const previousCanonical = this.canonicalText;
|
|
99
|
+
const previousSubmissions = [...this.submissions.values()];
|
|
100
|
+
if (scopeChanged) {
|
|
101
|
+
const unconfirmed = previousSubmissions.filter(value => value.receipt.delivery === 'unconfirmed');
|
|
102
|
+
const latest = unconfirmed.at(-1);
|
|
103
|
+
if (latest && (!this.localEdited || this.localText === previousCanonical)) {
|
|
104
|
+
this.localText = latest.text;
|
|
105
|
+
this.localEdited = true;
|
|
106
|
+
++this.localVersion;
|
|
107
|
+
}
|
|
108
|
+
for (const submission of unconfirmed) this.invalidated({
|
|
109
|
+
...copySubmission(submission), scopeRef: this.scopeRef, reason: 'scope_changed',
|
|
110
|
+
});
|
|
111
|
+
this.reportedReceipts.clear();
|
|
112
|
+
} else if (!this.localEdited || this.localText === previousCanonical) {
|
|
113
|
+
this.localText = response.text;
|
|
114
|
+
this.localEdited = false;
|
|
115
|
+
}
|
|
116
|
+
this.scopeRef = response.scopeRef;
|
|
117
|
+
this.revision = response.revision;
|
|
118
|
+
this.canonicalText = response.text;
|
|
119
|
+
this.writable = response.writable;
|
|
120
|
+
this.submissions = new Map(response.submissions.map(value => [value.clientMessageId, copySubmission(value)]));
|
|
121
|
+
this.status = scopeChanged ? 'scope_changed'
|
|
122
|
+
: preserveStatus ?? (response.writable ? 'ready' : 'readonly');
|
|
123
|
+
for (const submission of this.submissions.values()) this.report(submission);
|
|
124
|
+
this.emit();
|
|
125
|
+
if (!scopeChanged) await this.reconcileUnconfirmed();
|
|
126
|
+
return response;
|
|
127
|
+
} catch (error) {
|
|
128
|
+
this.status = error?.code ?? 'unavailable';
|
|
129
|
+
this.emit();
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
report(submission, restored = true) {
|
|
135
|
+
const key = `${this.scopeRef}\u0000${submission.clientMessageId}`;
|
|
136
|
+
const fingerprint = receiptFingerprint(submission.receipt);
|
|
137
|
+
if (this.reportedReceipts.get(key) === fingerprint) return;
|
|
138
|
+
this.reportedReceipts.set(key, fingerprint);
|
|
139
|
+
this.delivery({ ...copySubmission(submission), scopeRef: this.scopeRef, restored });
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
async reconcileUnconfirmed() {
|
|
143
|
+
const scopeRef = this.scopeRef;
|
|
144
|
+
for (const submission of [...this.submissions.values()]) {
|
|
145
|
+
if (submission.receipt.delivery !== 'unconfirmed' || scopeRef !== this.scopeRef) continue;
|
|
146
|
+
try {
|
|
147
|
+
const receipt = await this.client.readConversationDelivery(submission.clientMessageId, scopeRef);
|
|
148
|
+
if (scopeRef !== this.scopeRef) return;
|
|
149
|
+
const updated = { ...submission, receipt };
|
|
150
|
+
if (receipt.delivery !== 'rejected') this.submissions.set(submission.clientMessageId, updated);
|
|
151
|
+
this.report(updated);
|
|
152
|
+
} catch (error) {
|
|
153
|
+
if (error?.code === 'invalid_request' && scopeRef !== this.scopeRef) return;
|
|
154
|
+
this.status = error?.code ?? 'unavailable';
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
this.emit();
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
async save() {
|
|
161
|
+
if (this.saving) {
|
|
162
|
+
this.saveAgain = true;
|
|
163
|
+
return this.saving;
|
|
164
|
+
}
|
|
165
|
+
const operation = (async () => {
|
|
166
|
+
let result;
|
|
167
|
+
do {
|
|
168
|
+
this.saveAgain = false;
|
|
169
|
+
result = await this.saveOnce();
|
|
170
|
+
} while (this.saveAgain);
|
|
171
|
+
return result;
|
|
172
|
+
})();
|
|
173
|
+
this.saving = operation;
|
|
174
|
+
try { return await operation; }
|
|
175
|
+
finally { if (this.saving === operation) this.saving = undefined; }
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
async saveOnce() {
|
|
179
|
+
if (this.scopeRef === null) await this.load();
|
|
180
|
+
const text = this.localText;
|
|
181
|
+
const version = this.localVersion;
|
|
182
|
+
if (this.scopeRef === null || this.revision === null) return null;
|
|
183
|
+
if (!this.writable || text.length > 8000) {
|
|
184
|
+
this.status = 'readonly';
|
|
185
|
+
this.emit();
|
|
186
|
+
return null;
|
|
187
|
+
}
|
|
188
|
+
if (text === this.canonicalText) return this.canonicalReply();
|
|
189
|
+
const expectedRevision = this.revision;
|
|
190
|
+
const scopeRef = this.scopeRef;
|
|
191
|
+
this.status = 'saving';
|
|
192
|
+
this.emit();
|
|
193
|
+
try {
|
|
194
|
+
const response = await this.client.saveConversationDraft(expectedRevision, text, scopeRef);
|
|
195
|
+
if (response.status !== 'available') {
|
|
196
|
+
this.revision = response.revision;
|
|
197
|
+
this.status = response.status;
|
|
198
|
+
this.emit();
|
|
199
|
+
await this.load({ preserveStatus: response.status });
|
|
200
|
+
return response;
|
|
201
|
+
}
|
|
202
|
+
if (response.scopeRef !== scopeRef) {
|
|
203
|
+
await this.load({ preserveStatus: 'scope_changed' });
|
|
204
|
+
return null;
|
|
205
|
+
}
|
|
206
|
+
if (this.revision !== null && response.revision < this.revision) return this.canonicalReply();
|
|
207
|
+
const preserveStatus = version === this.localVersion ? 'ready' : 'editing';
|
|
208
|
+
this.applySaved(response, preserveStatus);
|
|
209
|
+
return response;
|
|
210
|
+
} catch (error) {
|
|
211
|
+
this.status = error?.code ?? 'unavailable';
|
|
212
|
+
this.emit();
|
|
213
|
+
return null;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
applySaved(response, status) {
|
|
218
|
+
this.scopeRef = response.scopeRef;
|
|
219
|
+
this.revision = response.revision;
|
|
220
|
+
this.canonicalText = response.text;
|
|
221
|
+
this.writable = response.writable;
|
|
222
|
+
this.submissions = new Map(response.submissions.map(value => [value.clientMessageId, copySubmission(value)]));
|
|
223
|
+
this.status = response.writable ? status : 'readonly';
|
|
224
|
+
for (const submission of this.submissions.values()) this.report(submission);
|
|
225
|
+
this.emit();
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
async flush() {
|
|
229
|
+
if (this.saveTimer !== undefined) {
|
|
230
|
+
this.cancel(this.saveTimer);
|
|
231
|
+
this.saveTimer = undefined;
|
|
232
|
+
}
|
|
233
|
+
return this.save();
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
async submit() {
|
|
237
|
+
const text = this.localText.trim();
|
|
238
|
+
if (!text) return null;
|
|
239
|
+
if (text !== this.localText) {
|
|
240
|
+
this.localText = text;
|
|
241
|
+
this.localEdited = true;
|
|
242
|
+
++this.localVersion;
|
|
243
|
+
this.emit();
|
|
244
|
+
}
|
|
245
|
+
const submittedVersion = this.localVersion;
|
|
246
|
+
const saved = await this.flush();
|
|
247
|
+
if (!saved || saved.status !== 'available' || saved.text !== text || !saved.writable) return null;
|
|
248
|
+
const submission = {
|
|
249
|
+
clientMessageId: this.uuid(), text, submissionRevision: saved.revision,
|
|
250
|
+
receipt: null,
|
|
251
|
+
};
|
|
252
|
+
return this.send(submission, submittedVersion);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
async retry(submission) {
|
|
256
|
+
const current = this.submissions.get(submission.clientMessageId);
|
|
257
|
+
if (!current || current.receipt.delivery !== 'unconfirmed'
|
|
258
|
+
|| current.text !== submission.text
|
|
259
|
+
|| current.submissionRevision !== submission.submissionRevision) return null;
|
|
260
|
+
return this.send(current, null);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
async send(submission, submittedVersion) {
|
|
264
|
+
const scopeRef = this.scopeRef;
|
|
265
|
+
if (scopeRef === null) return null;
|
|
266
|
+
try {
|
|
267
|
+
const receipt = await this.client.submitConversationText(
|
|
268
|
+
submission.text, submission.clientMessageId, submission.submissionRevision, scopeRef,
|
|
269
|
+
);
|
|
270
|
+
if (scopeRef !== this.scopeRef) return null;
|
|
271
|
+
const updated = { ...submission, receipt };
|
|
272
|
+
if (receipt.delivery !== 'rejected') this.submissions.set(submission.clientMessageId, updated);
|
|
273
|
+
this.reportedReceipts.delete(`${scopeRef}\u0000${submission.clientMessageId}`);
|
|
274
|
+
this.report(updated, false);
|
|
275
|
+
if (receipt.delivery === 'accepted') {
|
|
276
|
+
if (submittedVersion !== null && submittedVersion === this.localVersion
|
|
277
|
+
&& this.localText === submission.text) {
|
|
278
|
+
this.localText = '';
|
|
279
|
+
this.localEdited = false;
|
|
280
|
+
++this.localVersion;
|
|
281
|
+
}
|
|
282
|
+
this.status = 'accepted';
|
|
283
|
+
this.emit();
|
|
284
|
+
await this.load();
|
|
285
|
+
} else if (receipt.delivery === 'unconfirmed') {
|
|
286
|
+
this.status = 'unconfirmed';
|
|
287
|
+
this.emit();
|
|
288
|
+
} else {
|
|
289
|
+
this.status = receipt.reason;
|
|
290
|
+
this.emit();
|
|
291
|
+
}
|
|
292
|
+
return receipt;
|
|
293
|
+
} catch (error) {
|
|
294
|
+
this.status = error?.code ?? 'unavailable';
|
|
295
|
+
this.emit();
|
|
296
|
+
return null;
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
close() {
|
|
301
|
+
if (this.saveTimer !== undefined) this.cancel(this.saveTimer);
|
|
302
|
+
this.saveTimer = undefined;
|
|
303
|
+
}
|
|
304
|
+
}
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
const copyRow = row => ({
|
|
2
|
+
...row,
|
|
3
|
+
body: { ...row.body },
|
|
4
|
+
inputMessageIds: [...row.inputMessageIds],
|
|
5
|
+
});
|
|
6
|
+
|
|
7
|
+
/** In-memory presentation cache for the Host-owned v2 conversation projection. */
|
|
8
|
+
export class ConversationHistory {
|
|
9
|
+
constructor({ changed = () => {} } = {}) {
|
|
10
|
+
this.changed = changed;
|
|
11
|
+
this.rows = new Map();
|
|
12
|
+
this.order = [];
|
|
13
|
+
this.latestIds = new Set();
|
|
14
|
+
this.tokens = new Map();
|
|
15
|
+
this.consumed = new Map();
|
|
16
|
+
this.threadTitle = '';
|
|
17
|
+
this.hasMoreOlder = false;
|
|
18
|
+
this.olderCursor = null;
|
|
19
|
+
this.pageStatus = 'idle';
|
|
20
|
+
this.loadedOlder = false;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
loadPage(page, { older = false } = {}) {
|
|
24
|
+
if (page.status !== 'available') {
|
|
25
|
+
this.pageStatus = 'stale';
|
|
26
|
+
this.emit();
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
29
|
+
this.pageStatus = 'available';
|
|
30
|
+
this.threadTitle = page.threadTitle;
|
|
31
|
+
const pageIds = [];
|
|
32
|
+
for (const value of page.messages) {
|
|
33
|
+
pageIds.push(value.id);
|
|
34
|
+
this.mergeRow(value);
|
|
35
|
+
}
|
|
36
|
+
const inPage = new Set(pageIds);
|
|
37
|
+
if (older) {
|
|
38
|
+
this.order = [...pageIds, ...this.order.filter(id => !inPage.has(id))];
|
|
39
|
+
} else {
|
|
40
|
+
const overlapsLatest = pageIds.some(id => this.latestIds.has(id));
|
|
41
|
+
const keepEarlier = this.latestIds.size === 0 || overlapsLatest || this.loadedOlder;
|
|
42
|
+
this.order = [...(keepEarlier ? this.order.filter(id => !inPage.has(id)) : []), ...pageIds];
|
|
43
|
+
this.latestIds = inPage;
|
|
44
|
+
for (const id of [...this.rows.keys()]) {
|
|
45
|
+
if (!this.order.includes(id)) this.remove(id);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
if (older || !this.loadedOlder) {
|
|
49
|
+
this.hasMoreOlder = page.hasMoreOlder;
|
|
50
|
+
this.olderCursor = page.olderCursor;
|
|
51
|
+
}
|
|
52
|
+
if (older) this.loadedOlder = true;
|
|
53
|
+
this.emit();
|
|
54
|
+
return true;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
mergeRow(value) {
|
|
58
|
+
const previous = this.rows.get(value.id);
|
|
59
|
+
const next = {
|
|
60
|
+
...value,
|
|
61
|
+
body: { ...value.body },
|
|
62
|
+
inputMessageIds: [...value.inputMessageIds],
|
|
63
|
+
bodyStatus: value.body.complete ? 'complete' : 'loading',
|
|
64
|
+
};
|
|
65
|
+
if (!previous || previous.body.revision !== value.body.revision) {
|
|
66
|
+
this.forgetMessageTokens(value.id);
|
|
67
|
+
this.consumed.set(value.id, new Set());
|
|
68
|
+
this.rows.set(value.id, next);
|
|
69
|
+
this.registerContinuation(next);
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (previous.text.startsWith(value.text)) {
|
|
74
|
+
next.text = previous.text;
|
|
75
|
+
next.body = previous.body;
|
|
76
|
+
next.bodyStatus = previous.bodyStatus;
|
|
77
|
+
} else if (!value.text.startsWith(previous.text)) {
|
|
78
|
+
next.text = previous.text;
|
|
79
|
+
next.body = previous.body;
|
|
80
|
+
next.bodyStatus = 'stale';
|
|
81
|
+
}
|
|
82
|
+
this.rows.set(value.id, next);
|
|
83
|
+
this.registerContinuation(next);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
bodyContinuation(messageId) {
|
|
87
|
+
const value = this.rows.get(messageId);
|
|
88
|
+
return value?.bodyStatus === 'loading' ? value.body.continuation : null;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
applyBody(continuation, reply) {
|
|
92
|
+
const binding = this.tokens.get(continuation);
|
|
93
|
+
if (!binding) return false;
|
|
94
|
+
const value = this.rows.get(binding.messageId);
|
|
95
|
+
if (!value || value.body.revision !== binding.revision) return false;
|
|
96
|
+
if (reply.status === 'withdrawn') {
|
|
97
|
+
this.remove(binding.messageId);
|
|
98
|
+
this.emit();
|
|
99
|
+
return true;
|
|
100
|
+
}
|
|
101
|
+
if (reply.status === 'stale') {
|
|
102
|
+
value.bodyStatus = 'stale';
|
|
103
|
+
this.emit();
|
|
104
|
+
return true;
|
|
105
|
+
}
|
|
106
|
+
const used = this.consumed.get(binding.messageId) ?? new Set();
|
|
107
|
+
if (used.has(continuation)) return true;
|
|
108
|
+
if (reply.messageId !== binding.messageId || reply.body.revision !== binding.revision
|
|
109
|
+
|| value.body.continuation !== continuation) {
|
|
110
|
+
value.bodyStatus = 'stale';
|
|
111
|
+
this.emit();
|
|
112
|
+
return false;
|
|
113
|
+
}
|
|
114
|
+
used.add(continuation);
|
|
115
|
+
this.consumed.set(binding.messageId, used);
|
|
116
|
+
value.text += reply.text;
|
|
117
|
+
value.body = { ...reply.body };
|
|
118
|
+
value.bodyStatus = reply.body.complete ? 'complete' : 'loading';
|
|
119
|
+
if (!reply.body.complete && used.has(reply.body.continuation)) value.bodyStatus = 'stale';
|
|
120
|
+
this.registerContinuation(value);
|
|
121
|
+
this.emit();
|
|
122
|
+
return value.bodyStatus !== 'stale';
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
registerContinuation(value) {
|
|
126
|
+
const continuation = value.body.continuation;
|
|
127
|
+
if (!continuation || value.bodyStatus !== 'loading') return;
|
|
128
|
+
this.tokens.set(continuation, { messageId: value.id, revision: value.body.revision });
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
forgetMessageTokens(messageId) {
|
|
132
|
+
for (const [token, binding] of this.tokens) {
|
|
133
|
+
if (binding.messageId === messageId) this.tokens.delete(token);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
remove(messageId) {
|
|
138
|
+
this.rows.delete(messageId);
|
|
139
|
+
this.order = this.order.filter(id => id !== messageId);
|
|
140
|
+
this.forgetMessageTokens(messageId);
|
|
141
|
+
this.consumed.delete(messageId);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
clearProtected(reason) {
|
|
145
|
+
this.rows.clear();
|
|
146
|
+
this.order = [];
|
|
147
|
+
this.latestIds.clear();
|
|
148
|
+
this.tokens.clear();
|
|
149
|
+
this.consumed.clear();
|
|
150
|
+
this.threadTitle = '';
|
|
151
|
+
this.hasMoreOlder = false;
|
|
152
|
+
this.olderCursor = null;
|
|
153
|
+
this.pageStatus = reason;
|
|
154
|
+
this.emit();
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
message(messageId) {
|
|
158
|
+
const value = this.rows.get(messageId);
|
|
159
|
+
return value ? copyRow(value) : undefined;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
snapshot() {
|
|
163
|
+
return {
|
|
164
|
+
threadTitle: this.threadTitle,
|
|
165
|
+
messages: this.order.flatMap(id => {
|
|
166
|
+
const value = this.rows.get(id);
|
|
167
|
+
return value ? [copyRow(value)] : [];
|
|
168
|
+
}),
|
|
169
|
+
hasMoreOlder: this.hasMoreOlder,
|
|
170
|
+
olderCursor: this.olderCursor,
|
|
171
|
+
pageStatus: this.pageStatus,
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
emit() { this.changed(this.snapshot()); }
|
|
176
|
+
}
|