@pmate/chat-sdk 0.12.0-next.56 → 0.12.0-next.57

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 CHANGED
@@ -39,7 +39,7 @@ A dropped socket (including a network change or a suspended laptop) previously r
39
39
 
40
40
  `client.watchConnection(handler)` immediately reports the current state and returns an unsubscribe function. States are `connecting`, `reconnecting`, `authenticating`, `connected`, `authentication_required`, and `closed`. Only `connected` means the Hub acknowledged authentication. A rejected authentication stops retries; supply a fresh login session using a new client. `close()` cancels retries, heartbeat timers and browser wake listeners.
41
41
 
42
- Messages sent without an acknowledgement are rejected on disconnect and are **not replayed**: delivery is uncertain, so automatic retries could duplicate them. Callers should preserve drafts, display a reconnecting state, and refresh message history after recovery. No durable offline outbox is provided. An initial `connect()` wait times out after 30 seconds, but recovery continues until explicitly closed; retain the client and observe its connection state.
42
+ The low-level `ChatClient.sendChat` rejects unacknowledged sends on disconnect and does not provide a durable outbox. Browser applications should use `createChat().sendMessage` below, which persists the exact wire in IndexedDB and owns delivery state and retries. An initial `connect()` wait times out after 30 seconds, but recovery continues until explicitly closed; retain the client and observe its connection state.
43
43
 
44
44
  ## Versioned Markdown and chart content
45
45
 
@@ -155,7 +155,10 @@ const offSummary = chat.subscribe(() => renderSummary(chat.getSnapshot()))
155
155
  await chat.observeMessages(threadId, visibleIds, "visible")
156
156
  // Report only the actual audio `playing` event.
157
157
  await chat.observeMessages(threadId, [voiceId], "played")
158
- await chat.sendMessage(threadId, { to, body, recipients })
158
+ const conversation = { id: threadId, kind: "dm" as const, targetId: peerId, teamId }
159
+ await chat.sendMessage(conversation, { text: "Hello" })
160
+ await chat.sendMessage(conversation, { image: { url: uploadedUrl, name: "photo.png" } })
161
+ await chat.sendMessage(conversation, { voice: { blob: recording, durationMs: 1200 } })
159
162
  const readers = await chat.getReaders(threadId, messageId)
160
163
  off(); offSummary()
161
164
  // Ordinary navigation/shutdown preserves offline data.
@@ -169,6 +172,14 @@ await chat.close()
169
172
  await chat.revokeAccess()
170
173
  ```
171
174
 
175
+ Conversation descriptors identify the target and optional group `memberIds`; the SDK
176
+ checks the descriptor against its identity and team before acceptance. It builds
177
+ text, image, quote and voice envelopes and expands group `@all`/`@所有人` to concrete
178
+ members. Content callers do not provide `appId`, protocol `body` or `recipients`.
179
+ The existing `sendMessage(threadId, { to, body, recipients })` form remains available
180
+ for protocol integrations. Blob voice uploads stay in the durable sender; offline
181
+ uploads wait for retry instead of starting HTTP.
182
+
172
183
  The facade owns all clients below. Message reads transparently restore IndexedDB
173
184
  windows and fetch missing ranges through the authenticated Hub. Its normalized
174
185
  messages retain the protocol body; product rendering remains with the host.
@@ -1,3 +1,4 @@
1
+ import { type ChatConversation, type OutgoingContent } from "./outgoingContent.js";
1
2
  import { type TimelineItem } from "./messageTimeline.js";
2
3
  import { type ReadingCandidate, type MessageReadingState } from "./conversationReading.js";
3
4
  import type { ConversationReadMessage } from "./conversationRead.js";
@@ -120,6 +121,7 @@ export declare class ChatSession<T extends StoredMessage> {
120
121
  private rememberHistoryEdge;
121
122
  private readEarlier;
122
123
  private readAll;
124
+ sendMessage(thread: ChatConversation, input: OutgoingContent): Promise<ChatRuntimeMessage>;
123
125
  sendMessage(threadId: string, input: SendMessageInput): Promise<ChatRuntimeMessage>;
124
126
  getReaders(threadId: string, messageId: string, page?: Omit<MessageReadersQuery, "threadId" | "messageId">, signal?: AbortSignal): Promise<import("./messageReaders.js").MessageReadersPage>;
125
127
  retryMessage(messageId: string): Promise<ChatRuntimeMessage>;
@@ -1,3 +1,4 @@
1
+ import { outgoingContent } from "./outgoingContent.js";
1
2
  import { projectMessageTimeline } from "./messageTimeline.js";
2
3
  import { projectConversationReading } from "./conversationReading.js";
3
4
  import { ReadCountsClient } from "./readCounts.js";
@@ -359,9 +360,11 @@ export class ChatSession {
359
360
  }
360
361
  throw new Error("MESSAGE_SYNC_RESET_REQUIRED");
361
362
  }
362
- sendMessage(threadId, input) {
363
+ sendMessage(thread, input) {
363
364
  this.assertOpen();
364
- return this.sender.enqueue(threadId, input).then(next => { if (this.withdrawn)
365
+ const threadId = typeof thread === "string" ? thread : thread.id;
366
+ const wire = typeof thread === "string" ? input : outgoingContent(this.options.messages.scope, thread, input);
367
+ return this.sender.enqueue(threadId, wire).then(next => { if (this.withdrawn)
365
368
  throw new Error("CHAT_ACCESS_REVOKED"); return next; });
366
369
  }
367
370
  async getReaders(threadId, messageId, page = {}, signal) {
@@ -33,3 +33,4 @@ export * from "./conversationRead.js";
33
33
  export { projectConversationReading, type ReadingCandidate, type MessageReadingState, type ConversationReadingView } from "./conversationReading.js";
34
34
  export { projectMessageTimeline, type TimelineItem, type TimelineReaction, type TimelineReactionEvent } from "./messageTimeline.js";
35
35
  export * from "./messageLaunch.js";
36
+ export * from "./outgoingContent.js";
@@ -33,3 +33,4 @@ export * from "./conversationRead.js";
33
33
  export { projectConversationReading } from "./conversationReading.js";
34
34
  export { projectMessageTimeline } from "./messageTimeline.js";
35
35
  export * from "./messageLaunch.js";
36
+ export * from "./outgoingContent.js";
@@ -0,0 +1,47 @@
1
+ import type { ChatMention } from "../mentions/model.js";
2
+ import type { MessageScope } from "./messageStore.js";
3
+ import type { SendMessageInput } from "./durableSender.js";
4
+ export type ChatConversation = {
5
+ id: string;
6
+ kind: "dm" | "group";
7
+ targetId: string;
8
+ teamId?: string;
9
+ memberIds?: readonly string[];
10
+ };
11
+ export type MessageQuote = {
12
+ messageId?: string;
13
+ senderName: string;
14
+ content: string;
15
+ };
16
+ export type OutgoingContent = {
17
+ text: string;
18
+ mentions?: ChatMention[];
19
+ quote?: MessageQuote;
20
+ image?: never;
21
+ voice?: never;
22
+ } | {
23
+ image: {
24
+ url: string;
25
+ name?: string;
26
+ };
27
+ text?: never;
28
+ voice?: never;
29
+ } | {
30
+ voice: ({
31
+ blob: Blob;
32
+ url?: never;
33
+ } | {
34
+ url: string;
35
+ blob?: never;
36
+ }) & {
37
+ durationMs: number;
38
+ transcript?: string;
39
+ };
40
+ text?: never;
41
+ image?: never;
42
+ };
43
+ export declare const ALL_MENTION_ID = "__pmate_mention_all__";
44
+ /** Expand group mentions into concrete members understood by all protocol readers. */
45
+ export declare function expandGroupMentions(text: string, mentions: ChatMention[] | undefined, memberIds: readonly string[], senderId: string): ChatMention[];
46
+ /** Build the existing wire protocol inside the owner, before durable acceptance. */
47
+ export declare function outgoingContent(scope: MessageScope, thread: ChatConversation, input: OutgoingContent): SendMessageInput;
@@ -0,0 +1,58 @@
1
+ import { dmThreadId, groupThreadId } from "./thread.js";
2
+ export const ALL_MENTION_ID = "__pmate_mention_all__";
3
+ /** Expand group mentions into concrete members understood by all protocol readers. */
4
+ export function expandGroupMentions(text, mentions, memberIds, senderId) {
5
+ const validMembers = [...new Set(memberIds)], members = validMembers.filter(id => id !== senderId), entries = [...(mentions ?? [])];
6
+ const pattern = /(?:^|[\s([{,。!?、:;])(@(?:all|所有人))(?=$|[\s,.;:!?,。!?、:;)\]}])/gu;
7
+ for (const match of text.matchAll(pattern)) {
8
+ const start = match.index + match[0].length - match[1].length;
9
+ if (!entries.some(entry => entry.start === start && entry.end === start + match[1].length))
10
+ entries.push({ id: ALL_MENTION_ID, name: match[1].slice(1), start, end: start + match[1].length });
11
+ }
12
+ const seen = new Set();
13
+ return entries.flatMap(entry => {
14
+ if (text.slice(entry.start, entry.end) !== `@${entry.name}`)
15
+ return [];
16
+ if (entry.id === ALL_MENTION_ID)
17
+ return ["all", "所有人"].includes(entry.name) ? members.map(id => ({ ...entry, id })) : [];
18
+ return validMembers.includes(entry.id) ? [entry] : [];
19
+ }).filter(entry => { const key = `${entry.id}:${entry.start}:${entry.end}`; if (seen.has(key))
20
+ return false; seen.add(key); return true; });
21
+ }
22
+ /** Build the existing wire protocol inside the owner, before durable acceptance. */
23
+ export function outgoingContent(scope, thread, input) {
24
+ if (!thread || !["dm", "group"].includes(thread.kind) || typeof thread.targetId !== "string" || !thread.targetId || thread.teamId !== undefined && thread.teamId !== scope.teamId)
25
+ throw new Error("INVALID_MESSAGE_CONVERSATION");
26
+ const expected = thread.kind === "group" ? groupThreadId(scope.appId, thread.targetId, scope.teamId) : dmThreadId(scope.appId, scope.profileId, thread.targetId, scope.teamId);
27
+ if (thread.id !== expected)
28
+ throw new Error("MESSAGE_CONVERSATION_MISMATCH");
29
+ const recipients = thread.kind === "group" ? [...new Set(thread.memberIds)] : undefined;
30
+ if (recipients && (!recipients.length || recipients.some(id => typeof id !== "string" || !id)))
31
+ throw new Error("MESSAGE_GROUP_MEMBERS_UNAVAILABLE");
32
+ const base = { appId: scope.appId, ...(scope.teamId === undefined ? {} : { teamId: scope.teamId }), ...(thread.kind === "group" ? { groupId: thread.targetId } : {}) };
33
+ if (!input || typeof input !== "object" || ["text", "image", "voice"].filter(key => Object.hasOwn(input, key)).length !== 1)
34
+ throw new Error("INVALID_MESSAGE_CONTENT");
35
+ let content, contentKind = "text", voiceBlob;
36
+ if ("text" in input) {
37
+ if (typeof input.text !== "string")
38
+ throw new Error("INVALID_MESSAGE_TEXT");
39
+ const mentions = thread.kind === "group" ? expandGroupMentions(input.text, input.mentions, recipients, scope.profileId) : input.mentions;
40
+ content = { ...base, text: input.text, ...(input.quote ? { quote: input.quote } : {}), ...(mentions?.length ? { mentions } : {}) };
41
+ }
42
+ else if ("image" in input && input.image) {
43
+ if (typeof input.image.url !== "string" || !input.image.url)
44
+ throw new Error("INVALID_MESSAGE_IMAGE");
45
+ contentKind = "image";
46
+ content = { ...base, url: input.image.url, ...(input.image.name === undefined ? {} : { name: input.image.name }) };
47
+ }
48
+ else if ("voice" in input && input.voice) {
49
+ const voice = input.voice;
50
+ if (!Number.isFinite(voice.durationMs) || voice.durationMs <= 0 || !("blob" in voice ? voice.blob instanceof Blob : typeof voice.url === "string" && voice.url.length > 0))
51
+ throw new Error("INVALID_MESSAGE_VOICE");
52
+ voiceBlob = voice.blob;
53
+ content = { ...base, text: "[语音]", voice: { url: voiceBlob ? "" : voice.url, durationMs: voice.durationMs, ...(voice.transcript ? { transcript: voice.transcript } : {}) } };
54
+ }
55
+ else
56
+ throw new Error("INVALID_MESSAGE_CONTENT");
57
+ return { to: thread.targetId, body: { kind: "message", threadKind: thread.kind, contentKind, content }, ...(recipients ? { recipients } : {}), ...(voiceBlob ? { voiceBlob } : {}) };
58
+ }
@@ -1,6 +1,11 @@
1
+ import { isOffline } from "./connectivity.js";
1
2
  /** The SDK owns upload I/O; hosts configure only their service endpoint. */
2
3
  export function voiceUploader(profileId, endpoint = "https://upload-fc.pmate.chat", fetcher = globalThis.fetch) {
3
4
  return async (blob, signal) => {
5
+ if (signal.aborted)
6
+ throw new DOMException("Upload aborted", "AbortError");
7
+ if (isOffline())
8
+ throw new Error("VOICE_UPLOAD_OFFLINE");
4
9
  const bytes = new Uint8Array(await blob.arrayBuffer());
5
10
  if (signal.aborted)
6
11
  throw new DOMException("Upload aborted", "AbortError");
@@ -9,6 +14,8 @@ export function voiceUploader(profileId, endpoint = "https://upload-fc.pmate.cha
9
14
  binary += String.fromCharCode(...bytes.subarray(offset, offset + 32768));
10
15
  const extension = blob.type.includes("mp4") ? "m4a" : blob.type.includes("mpeg") ? "mp3" : "webm";
11
16
  const type = extension === "m4a" ? "audio/mp4" : extension === "mp3" ? "audio/mpeg" : "audio/webm";
17
+ if (isOffline())
18
+ throw new Error("VOICE_UPLOAD_OFFLINE");
12
19
  const response = await fetcher(`${endpoint.replace(/\/+$/, "")}/file`, { method: "POST", signal, headers: { "Content-Type": "application/json", "x-file-content-type": type }, body: JSON.stringify({ user: profileId, base64: btoa(binary), filename: `voice-${Date.now()}.${extension}` }) });
13
20
  if (!response.ok)
14
21
  throw new Error("VOICE_UPLOAD_FAILED");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pmate/chat-sdk",
3
- "version": "0.12.0-next.56",
3
+ "version": "0.12.0-next.57",
4
4
  "description": "Cross-platform PMate chat input components, voice input primitives, and ASR helpers",
5
5
  "type": "module",
6
6
  "main": "./lib/index.js",