@pmate/chat-sdk 0.12.0-next.70 → 0.12.0-next.72
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 +18 -0
- package/lib/commands.d.ts +20 -0
- package/lib/commands.js +12 -0
- package/lib/components/ChatInputDesktop.js +5 -4
- package/lib/components/ChatInputMobile.js +5 -4
- package/lib/components/CommandMenu.d.ts +15 -0
- package/lib/components/CommandMenu.js +10 -0
- package/lib/hooks/useComposerSuggestions.d.ts +50 -0
- package/lib/hooks/useComposerSuggestions.js +70 -0
- package/lib/index.d.ts +3 -0
- package/lib/index.js +3 -0
- package/lib/runtime/indexedDbRecovery.d.ts +4 -0
- package/lib/runtime/indexedDbRecovery.js +27 -0
- package/lib/runtime/messageStore.d.ts +2 -0
- package/lib/runtime/messageStore.js +18 -14
- package/lib/runtime/readCounts.js +1 -1
- package/lib/runtime/responseCache.d.ts +1 -0
- package/lib/runtime/responseCache.js +9 -6
- package/lib/types/chatInput.d.ts +3 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -289,3 +289,21 @@ The persistent group cache is capped at 64 groups / 1MiB, with 128KiB records,
|
|
|
289
289
|
identities are stored separately without rosters; repeated registrations share
|
|
290
290
|
one write, concurrent lookups use bulk reads, and a concurrent memory-hit burst
|
|
291
291
|
shares one fresh authority read without retaining authorization across bursts. Scope withdrawal retires both.
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
## Slash command suggestions
|
|
295
|
+
|
|
296
|
+
### Background
|
|
297
|
+
|
|
298
|
+
Host applications know which commands are available in a conversation. The shared desktop/mobile inputs now display that catalog while typing a leading `/`, without coupling the SDK to a particular bot or executing commands from the picker.
|
|
299
|
+
|
|
300
|
+
```tsx
|
|
301
|
+
<ChatInputDesktop commands={{ commands: [
|
|
302
|
+
{ name: "review", description: "Review code", usage: "PR URL or branch" },
|
|
303
|
+
{ name: "qa", description: "Run independent tests", usage: "PR URL" },
|
|
304
|
+
] }} mentions={groupMembers} onSend={sendMessage} />
|
|
305
|
+
```
|
|
306
|
+
|
|
307
|
+
The same prop is supported by `ChatInputMobile`. Names omit the leading slash. Arrow keys navigate, Enter/Tab inserts a command and a space, Escape closes; touch selection edits the draft only. Command arguments and existing member mentions are preserved. IME composition, URLs and slashes in ordinary body text do not trigger command execution. Omit `commands` for unsupported conversations.
|
|
308
|
+
|
|
309
|
+
Host-owned textareas can use `useComposerSuggestions({ value, onChange, mentions, commands, disabled })`, spread `inputProps`, render `MentionMenu` with `menuProps` and `CommandMenu` with `commandMenuProps`, and send `getMentions()` with the untrimmed value.
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/** Host-owned command catalog. Selecting a suggestion edits the draft only. */
|
|
2
|
+
export type ChatCommand = {
|
|
3
|
+
name: string;
|
|
4
|
+
description: string;
|
|
5
|
+
usage?: string;
|
|
6
|
+
};
|
|
7
|
+
export type ChatCommandConfig = {
|
|
8
|
+
commands: ChatCommand[];
|
|
9
|
+
label?: string;
|
|
10
|
+
emptyText?: string;
|
|
11
|
+
};
|
|
12
|
+
export declare function commandQuery(value: string, caret: number): {
|
|
13
|
+
query: string;
|
|
14
|
+
end: number;
|
|
15
|
+
} | null;
|
|
16
|
+
export declare function filterCommands(commands: ChatCommand[], query: string): ChatCommand[];
|
|
17
|
+
export declare function completeCommand(value: string, end: number, command: ChatCommand): {
|
|
18
|
+
text: string;
|
|
19
|
+
caret: number;
|
|
20
|
+
};
|
package/lib/commands.js
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export function commandQuery(value, caret) {
|
|
2
|
+
const match = /^\/([^\s/]*)(?=\s|$)/.exec(value);
|
|
3
|
+
return match && caret >= 1 && caret <= match[0].length ? { query: value.slice(1, caret).toLowerCase(), end: match[0].length } : null;
|
|
4
|
+
}
|
|
5
|
+
export function filterCommands(commands, query) {
|
|
6
|
+
return commands.filter(c => /^[a-z][a-z0-9-]*$/.test(c.name) && c.name.toLowerCase().startsWith(query));
|
|
7
|
+
}
|
|
8
|
+
export function completeCommand(value, end, command) {
|
|
9
|
+
const prefix = `/${command.name}`, suffix = value.slice(end);
|
|
10
|
+
const text = prefix + (suffix || " ");
|
|
11
|
+
return { text, caret: prefix.length + 1 };
|
|
12
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { handleImagePaste } from "../ux/imagePaste";
|
|
3
|
-
import {
|
|
3
|
+
import { useComposerSuggestions } from "../hooks/useComposerSuggestions";
|
|
4
|
+
import { CommandMenu } from "./CommandMenu";
|
|
4
5
|
import { MentionMenu } from "./MentionMenu";
|
|
5
6
|
import React from "react";
|
|
6
7
|
import { Provider, createStore } from "jotai";
|
|
@@ -25,8 +26,8 @@ function DesktopInner(props) {
|
|
|
25
26
|
const tokens = mergeChatInputTokens(props.tokens);
|
|
26
27
|
const state = useChatInputState();
|
|
27
28
|
const attachments = useAttachmentState();
|
|
28
|
-
const mentions =
|
|
29
|
-
value: state.text,
|
|
29
|
+
const mentions = useComposerSuggestions({
|
|
30
|
+
value: state.text, mentions: props.mentions, commands: props.commands, disabled: props.disabled || state.status === "sending" || state.mode === "voice",
|
|
30
31
|
onChange(value) { state.setText(value); state.setStatus(value.trim() ? "typing" : "idle"); props.onChange?.(value); },
|
|
31
32
|
});
|
|
32
33
|
const [isEmojiOpen, setIsEmojiOpen] = useState(false);
|
|
@@ -186,7 +187,7 @@ function DesktopInner(props) {
|
|
|
186
187
|
alignSelf: "center",
|
|
187
188
|
transition: `color ${tokens.motion.stateTransition}, transform ${tokens.motion.pressFeedback}`,
|
|
188
189
|
};
|
|
189
|
-
return (_jsxs("div", { style: { position: "relative", width: "100%" }, children: [_jsx(MentionMenu, { ...mentions.menuProps, tokens: tokens }), _jsxs("div", { className: "grid gap-3", style: { position: "relative", width: "100%" }, children: [_jsx("input", { ref: imageInputRef, type: "file", accept: props.image?.accept ?? "image/*", style: { display: "none" }, onChange: (event) => {
|
|
190
|
+
return (_jsxs("div", { style: { position: "relative", width: "100%" }, children: [_jsx(MentionMenu, { ...mentions.menuProps, tokens: tokens }), _jsx(CommandMenu, { ...mentions.commandMenuProps, tokens: tokens }), _jsxs("div", { className: "grid gap-3", style: { position: "relative", width: "100%" }, children: [_jsx("input", { ref: imageInputRef, type: "file", accept: props.image?.accept ?? "image/*", style: { display: "none" }, onChange: (event) => {
|
|
190
191
|
const file = event.target.files?.[0];
|
|
191
192
|
if (file) {
|
|
192
193
|
void handleImageFile(file);
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
2
|
import { handleImagePaste } from "../ux/imagePaste";
|
|
3
|
-
import {
|
|
3
|
+
import { useComposerSuggestions } from "../hooks/useComposerSuggestions";
|
|
4
|
+
import { CommandMenu } from "./CommandMenu";
|
|
4
5
|
import { MentionMenu } from "./MentionMenu";
|
|
5
6
|
import React from "react";
|
|
6
7
|
import { Provider, createStore } from "jotai";
|
|
@@ -26,8 +27,8 @@ function MobileInner(props) {
|
|
|
26
27
|
const tokens = mergeChatInputTokens(props.tokens);
|
|
27
28
|
const state = useChatInputState();
|
|
28
29
|
const attachments = useAttachmentState();
|
|
29
|
-
const mentions =
|
|
30
|
-
value: state.text,
|
|
30
|
+
const mentions = useComposerSuggestions({
|
|
31
|
+
value: state.text, mentions: props.mentions, commands: props.commands, disabled: props.disabled || state.status === "sending" || state.mode === "voice",
|
|
31
32
|
onChange(value) { state.setText(value); state.setStatus(value.trim() ? "typing" : "idle"); props.onChange?.(value); },
|
|
32
33
|
});
|
|
33
34
|
const [recordingChoice, setRecordingChoice] = useState("send");
|
|
@@ -267,7 +268,7 @@ function MobileInner(props) {
|
|
|
267
268
|
userSelect: "none",
|
|
268
269
|
WebkitUserSelect: "none",
|
|
269
270
|
WebkitTouchCallout: "none",
|
|
270
|
-
}, children: [_jsx(MentionMenu, { ...mentions.menuProps, tokens: tokens }), isRecordingOverlayVisible ? (_jsxs("div", { style: {
|
|
271
|
+
}, children: [_jsx(MentionMenu, { ...mentions.menuProps, tokens: tokens }), _jsx(CommandMenu, { ...mentions.commandMenuProps, tokens: tokens }), isRecordingOverlayVisible ? (_jsxs("div", { style: {
|
|
271
272
|
position: "fixed",
|
|
272
273
|
left: "50%",
|
|
273
274
|
bottom: "calc(72px + env(safe-area-inset-bottom, 0px))",
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { RefObject } from "react";
|
|
2
|
+
import type { ChatCommand } from "../commands";
|
|
3
|
+
import type { ChatInputTokens, DeepPartial } from "../types/chatInput";
|
|
4
|
+
export declare function CommandMenu({ id, open, candidates, activeIndex, onSelect, onActiveIndex, menuRef, label, emptyText, tokens: overrides }: {
|
|
5
|
+
id: string;
|
|
6
|
+
open: boolean;
|
|
7
|
+
candidates: ChatCommand[];
|
|
8
|
+
activeIndex: number;
|
|
9
|
+
onSelect: (c: ChatCommand) => void;
|
|
10
|
+
onActiveIndex: (i: number) => void;
|
|
11
|
+
menuRef: RefObject<HTMLDivElement | null>;
|
|
12
|
+
label: string;
|
|
13
|
+
emptyText: string;
|
|
14
|
+
tokens?: DeepPartial<ChatInputTokens>;
|
|
15
|
+
}): import("react/jsx-runtime").JSX.Element | null;
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import React, { useEffect } from "react";
|
|
3
|
+
import { mergeChatInputTokens } from "../tokens/defaultTokens";
|
|
4
|
+
export function CommandMenu({ id, open, candidates, activeIndex, onSelect, onActiveIndex, menuRef, label, emptyText, tokens: overrides }) {
|
|
5
|
+
const tokens = mergeChatInputTokens(overrides);
|
|
6
|
+
useEffect(() => { menuRef.current?.querySelector('[aria-selected="true"]')?.scrollIntoView({ block: "nearest" }); }, [activeIndex, open]);
|
|
7
|
+
if (!open)
|
|
8
|
+
return null;
|
|
9
|
+
return _jsx("div", { id: id, ref: menuRef, role: "listbox", "aria-label": label, style: { position: "absolute", bottom: "calc(100% + 8px)", left: 0, width: "min(420px, 100%)", maxHeight: "min(320px, 40dvh)", overflowY: "auto", overscrollBehavior: "contain", zIndex: 20, boxSizing: "border-box", padding: 6, borderRadius: tokens.radius.inputSurface, border: `1px solid ${tokens.color.borderDefault}`, background: tokens.color.surfaceInputFocus, color: tokens.color.textPrimary, boxShadow: tokens.shadow.inputElevated, fontFamily: tokens.typography.familyBase }, children: candidates.length ? candidates.map((c, i) => _jsxs("button", { id: `${id}-${i}`, type: "button", role: "option", "aria-selected": i === activeIndex, tabIndex: -1, onMouseDown: e => e.preventDefault(), onMouseMove: () => onActiveIndex(i), onClick: () => onSelect(c), style: { display: "block", width: "100%", minHeight: 48, padding: "9px 12px", border: 0, borderRadius: 8, textAlign: "left", cursor: "pointer", color: "inherit", background: i === activeIndex ? `color-mix(in srgb, ${tokens.color.brandPrimary} 14%, ${tokens.color.surfaceInputFocus})` : "transparent" }, children: [_jsxs("span", { style: { display: "block", overflowWrap: "anywhere", fontSize: tokens.typography.inputText.size }, children: [_jsxs("strong", { style: { color: tokens.color.brandPrimary }, children: ["/", c.name] }), c.usage ? _jsx("span", { style: { marginLeft: 8, fontSize: 12, color: tokens.color.textSecondary }, children: c.usage }) : null] }), _jsx("small", { style: { display: "block", marginTop: 3, color: tokens.color.textSecondary }, children: c.description })] }, c.name)) : _jsx("div", { role: "status", style: { padding: 12, color: tokens.color.textSecondary }, children: emptyText }) });
|
|
10
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { ChangeEvent, KeyboardEvent, SyntheticEvent } from "react";
|
|
2
|
+
import { type ChatCommand, type ChatCommandConfig } from "../commands";
|
|
3
|
+
import type { MentionConfig } from "../mentions/model";
|
|
4
|
+
/** One input contract for host-owned composers, SDK inputs, @ mentions and / commands. */
|
|
5
|
+
export declare function useComposerSuggestions({ value, onChange, mentions: config, commands, disabled }: {
|
|
6
|
+
value: string;
|
|
7
|
+
onChange: (value: string) => void;
|
|
8
|
+
mentions?: MentionConfig;
|
|
9
|
+
commands?: ChatCommandConfig;
|
|
10
|
+
disabled?: boolean;
|
|
11
|
+
}): {
|
|
12
|
+
getMentions: () => import("..").ChatMention[];
|
|
13
|
+
menuProps: {
|
|
14
|
+
open: boolean;
|
|
15
|
+
id: string;
|
|
16
|
+
candidates: import("..").MentionCandidate[];
|
|
17
|
+
activeIndex: number;
|
|
18
|
+
onSelect: (candidate: import("..").MentionCandidate) => void;
|
|
19
|
+
onActiveIndex: import("react").Dispatch<import("react").SetStateAction<number>>;
|
|
20
|
+
menuRef: import("react").RefObject<HTMLDivElement | null>;
|
|
21
|
+
label: string;
|
|
22
|
+
emptyText: string;
|
|
23
|
+
};
|
|
24
|
+
commandMenuProps: {
|
|
25
|
+
id: string;
|
|
26
|
+
open: boolean;
|
|
27
|
+
candidates: ChatCommand[];
|
|
28
|
+
activeIndex: number;
|
|
29
|
+
onSelect: (command: ChatCommand) => void;
|
|
30
|
+
onActiveIndex: import("react").Dispatch<import("react").SetStateAction<number>>;
|
|
31
|
+
menuRef: import("react").RefObject<HTMLDivElement | null>;
|
|
32
|
+
label: string;
|
|
33
|
+
emptyText: string;
|
|
34
|
+
};
|
|
35
|
+
inputProps: {
|
|
36
|
+
onChange(event: ChangeEvent<HTMLTextAreaElement>): void;
|
|
37
|
+
onSelect(event: SyntheticEvent<HTMLTextAreaElement>): void;
|
|
38
|
+
onFocus(event: SyntheticEvent<HTMLTextAreaElement>): void;
|
|
39
|
+
onBlur(event: React.FocusEvent<HTMLTextAreaElement>): void;
|
|
40
|
+
onCompositionStart(): void;
|
|
41
|
+
onCompositionEnd(event: SyntheticEvent<HTMLTextAreaElement>): void;
|
|
42
|
+
onKeyDown(event: KeyboardEvent<HTMLTextAreaElement>): void;
|
|
43
|
+
role?: "combobox" | undefined;
|
|
44
|
+
"aria-autocomplete"?: "list" | undefined;
|
|
45
|
+
"aria-haspopup"?: "listbox" | undefined;
|
|
46
|
+
"aria-expanded"?: boolean | undefined;
|
|
47
|
+
"aria-controls"?: string | undefined;
|
|
48
|
+
"aria-activedescendant"?: string | undefined;
|
|
49
|
+
};
|
|
50
|
+
};
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { useId, useLayoutEffect, useRef, useState } from "react";
|
|
2
|
+
import { useMentions } from "./useMentions";
|
|
3
|
+
import { commandQuery, completeCommand, filterCommands } from "../commands";
|
|
4
|
+
/** One input contract for host-owned composers, SDK inputs, @ mentions and / commands. */
|
|
5
|
+
export function useComposerSuggestions({ value, onChange, mentions: config, commands, disabled = false }) {
|
|
6
|
+
const mentions = useMentions({ value, onChange, config, disabled });
|
|
7
|
+
const id = useId(), inputRef = useRef(null), menuRef = useRef(null);
|
|
8
|
+
const pending = useRef(null);
|
|
9
|
+
const [caret, setCaret] = useState(0), [focused, setFocused] = useState(false), [composing, setComposing] = useState(false);
|
|
10
|
+
const [dismissed, setDismissed] = useState(false), [selected, setSelected] = useState(0), [collapsed, setCollapsed] = useState(true);
|
|
11
|
+
const query = commands?.commands.length && focused && collapsed && !composing && !disabled && !dismissed ? commandQuery(value, caret) : null;
|
|
12
|
+
const candidates = query ? filterCommands(commands.commands, query.query) : [];
|
|
13
|
+
const index = Math.min(selected, Math.max(0, candidates.length - 1));
|
|
14
|
+
useLayoutEffect(() => {
|
|
15
|
+
if (pending.current?.text !== value)
|
|
16
|
+
return;
|
|
17
|
+
const position = pending.current.caret;
|
|
18
|
+
pending.current = null;
|
|
19
|
+
inputRef.current?.focus({ preventScroll: true });
|
|
20
|
+
inputRef.current?.setSelectionRange(position, position);
|
|
21
|
+
setCaret(position);
|
|
22
|
+
}, [value]);
|
|
23
|
+
function observe(event) {
|
|
24
|
+
inputRef.current = event.currentTarget;
|
|
25
|
+
setCaret(event.currentTarget.selectionStart);
|
|
26
|
+
setCollapsed(event.currentTarget.selectionStart === event.currentTarget.selectionEnd);
|
|
27
|
+
}
|
|
28
|
+
function select(command) {
|
|
29
|
+
if (!query)
|
|
30
|
+
return;
|
|
31
|
+
const next = completeCommand(value, query.end, command);
|
|
32
|
+
pending.current = next;
|
|
33
|
+
setDismissed(true);
|
|
34
|
+
onChange(next.text);
|
|
35
|
+
}
|
|
36
|
+
return {
|
|
37
|
+
getMentions: mentions.getMentions,
|
|
38
|
+
menuProps: { ...mentions.menuProps, open: mentions.menuProps.open && !query },
|
|
39
|
+
commandMenuProps: { id, open: Boolean(query), candidates, activeIndex: index, onSelect: select, onActiveIndex: setSelected, menuRef, label: commands?.label ?? "选择命令", emptyText: commands?.emptyText ?? "没有匹配的命令" },
|
|
40
|
+
inputProps: {
|
|
41
|
+
...mentions.inputProps,
|
|
42
|
+
...(commands ? { role: "combobox", "aria-autocomplete": "list", "aria-haspopup": "listbox", "aria-expanded": Boolean(query || mentions.menuProps.open), "aria-controls": query ? id : mentions.inputProps["aria-controls"], "aria-activedescendant": query && candidates[index] ? `${id}-${index}` : mentions.inputProps["aria-activedescendant"] } : {}),
|
|
43
|
+
onChange(event) { observe(event); setDismissed(false); setSelected(0); mentions.inputProps.onChange(event); },
|
|
44
|
+
onSelect(event) { observe(event); mentions.inputProps.onSelect(event); },
|
|
45
|
+
onFocus(event) { observe(event); setFocused(true); mentions.inputProps.onFocus(event); },
|
|
46
|
+
onBlur(event) { if (!menuRef.current?.contains(event.relatedTarget))
|
|
47
|
+
setFocused(false); mentions.inputProps.onBlur(event); },
|
|
48
|
+
onCompositionStart() { setComposing(true); mentions.inputProps.onCompositionStart(); },
|
|
49
|
+
onCompositionEnd(event) { setComposing(false); setDismissed(false); observe(event); mentions.inputProps.onCompositionEnd(event); },
|
|
50
|
+
onKeyDown(event) {
|
|
51
|
+
if (query && !event.nativeEvent.isComposing && event.keyCode !== 229) {
|
|
52
|
+
if (event.key === "Escape") {
|
|
53
|
+
event.preventDefault();
|
|
54
|
+
setDismissed(true);
|
|
55
|
+
}
|
|
56
|
+
else if (candidates.length && ["ArrowDown", "ArrowUp"].includes(event.key)) {
|
|
57
|
+
event.preventDefault();
|
|
58
|
+
setSelected((index + (event.key === "ArrowDown" ? 1 : candidates.length - 1)) % candidates.length);
|
|
59
|
+
}
|
|
60
|
+
else if (candidates[index] && ["Enter", "Tab"].includes(event.key) && !event.shiftKey && !event.metaKey && !event.ctrlKey && !event.altKey) {
|
|
61
|
+
event.preventDefault();
|
|
62
|
+
select(candidates[index]);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
if (!event.defaultPrevented)
|
|
66
|
+
mentions.inputProps.onKeyDown(event);
|
|
67
|
+
},
|
|
68
|
+
},
|
|
69
|
+
};
|
|
70
|
+
}
|
package/lib/index.d.ts
CHANGED
|
@@ -43,3 +43,6 @@ export * from "./hooks/useImageAttachments";
|
|
|
43
43
|
export * from "./components/ImageAttachmentTray";
|
|
44
44
|
export { createChat, type Chat, ChatError, type ChatThreadSnapshot, type ChatMessage as MessageView, type ChatErrorCode, type ConversationTarget, type OutgoingContent, type CreateChatOptions, type ChatSnapshot, type SentMessage, type ReadOptions, type ReadersQuery, type GroupChanges } from "./chat.js";
|
|
45
45
|
export { useChatSnapshot } from "./hooks/useChatSnapshot.js";
|
|
46
|
+
export * from "./commands";
|
|
47
|
+
export * from "./components/CommandMenu";
|
|
48
|
+
export * from "./hooks/useComposerSuggestions";
|
package/lib/index.js
CHANGED
|
@@ -43,3 +43,6 @@ export * from "./hooks/useImageAttachments";
|
|
|
43
43
|
export * from "./components/ImageAttachmentTray";
|
|
44
44
|
export { createChat, ChatError } from "./chat.js";
|
|
45
45
|
export { useChatSnapshot } from "./hooks/useChatSnapshot.js";
|
|
46
|
+
export * from "./commands";
|
|
47
|
+
export * from "./components/CommandMenu";
|
|
48
|
+
export * from "./hooks/useComposerSuggestions";
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { Dexie } from "dexie";
|
|
2
|
+
/** WebKit can invalidate an IDB connection while the application is suspended.
|
|
3
|
+
* Only replay a whole rejected transaction; never replay individual requests or clear data. */
|
|
4
|
+
export declare function recoverableTransactions(db: Dexie, isClosed: () => boolean): Dexie["transaction"];
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/** WebKit can invalidate an IDB connection while the application is suspended.
|
|
2
|
+
* Only replay a whole rejected transaction; never replay individual requests or clear data. */
|
|
3
|
+
export function recoverableTransactions(db, isClosed) {
|
|
4
|
+
const transaction = (...args) => db.transaction.apply(db, args);
|
|
5
|
+
let recovery;
|
|
6
|
+
return (async (...args) => {
|
|
7
|
+
try {
|
|
8
|
+
return await transaction(...args);
|
|
9
|
+
}
|
|
10
|
+
catch (error) {
|
|
11
|
+
const failure = error;
|
|
12
|
+
if (isClosed() || !(failure.name === "UnknownError" || failure.inner?.name === "UnknownError") || !/without an in-progress transaction/i.test(failure.message ?? ""))
|
|
13
|
+
throw error;
|
|
14
|
+
if (!recovery) {
|
|
15
|
+
db.close({ disableAutoOpen: false });
|
|
16
|
+
const opening = db.open();
|
|
17
|
+
recovery = opening;
|
|
18
|
+
void opening.finally(() => { if (recovery === opening)
|
|
19
|
+
recovery = undefined; }).catch(() => { });
|
|
20
|
+
}
|
|
21
|
+
await recovery;
|
|
22
|
+
if (isClosed())
|
|
23
|
+
throw error;
|
|
24
|
+
return transaction(...args);
|
|
25
|
+
}
|
|
26
|
+
});
|
|
27
|
+
}
|
|
@@ -35,12 +35,14 @@ export declare const messageScopeKey: (scope: MessageScope) => string;
|
|
|
35
35
|
export declare class IndexedDbMessageStore<T extends StoredMessage> {
|
|
36
36
|
private scope;
|
|
37
37
|
private merge;
|
|
38
|
+
private transact;
|
|
38
39
|
private db;
|
|
39
40
|
private rows;
|
|
40
41
|
private legacy;
|
|
41
42
|
private coverage;
|
|
42
43
|
private accessRows;
|
|
43
44
|
private controls;
|
|
45
|
+
private closed;
|
|
44
46
|
private key;
|
|
45
47
|
constructor(scope: MessageScope, merge: (previous: T | undefined, incoming: T) => T, options?: {
|
|
46
48
|
indexedDB?: IDBFactory;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { recoverableTransactions } from "./indexedDbRecovery.js";
|
|
1
2
|
import { Dexie } from "dexie";
|
|
2
3
|
import { quotedMessageIds, redactMessageQuotes } from "./messageQuotes.js";
|
|
3
4
|
import { messageControls, controlKey, newerControl, applyMessageControls } from "./messageControls.js";
|
|
@@ -7,18 +8,21 @@ export const messageScopeKey = (scope) => JSON.stringify([scope.appId, scope.acc
|
|
|
7
8
|
export class IndexedDbMessageStore {
|
|
8
9
|
scope;
|
|
9
10
|
merge;
|
|
11
|
+
transact;
|
|
10
12
|
db;
|
|
11
13
|
rows;
|
|
12
14
|
legacy;
|
|
13
15
|
coverage;
|
|
14
16
|
accessRows;
|
|
15
17
|
controls;
|
|
18
|
+
closed = false;
|
|
16
19
|
key;
|
|
17
20
|
constructor(scope, merge, options = {}) {
|
|
18
21
|
this.scope = scope;
|
|
19
22
|
this.merge = merge;
|
|
20
23
|
this.key = messageScopeKey(scope);
|
|
21
24
|
this.db = new Dexie(options.name ?? "pmate-chat-messages", { indexedDB: options.indexedDB ?? globalThis.indexedDB, IDBKeyRange: globalThis.IDBKeyRange });
|
|
25
|
+
this.transact = recoverableTransactions(this.db, () => this.closed);
|
|
22
26
|
this.db.version(1).stores({ messages: "[scope+thread+id],[scope+thread+time+id],[scope+thread],scope", coverage: "[scope+thread],scope" });
|
|
23
27
|
this.db.version(2).stores({ legacyMessages: "[scope+thread+id],[scope+thread+time+id],[scope+thread],scope" });
|
|
24
28
|
this.db.version(3).stores({ scopeAccess: "scope" });
|
|
@@ -34,7 +38,7 @@ export class IndexedDbMessageStore {
|
|
|
34
38
|
async read(thread, limit = 100, before) {
|
|
35
39
|
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 1000)
|
|
36
40
|
throw new Error("INVALID_MESSAGE_LIMIT");
|
|
37
|
-
const page = await this.
|
|
41
|
+
const page = await this.transact("r", this.rows, this.coverage, this.accessRows, this.controls, async () => {
|
|
38
42
|
const access = await this.scopeAccess();
|
|
39
43
|
if (access.revoked)
|
|
40
44
|
return { messages: [], hasEarlier: false, coverage: { complete: false, revoked: true, resetVersion: 0, scopeVersion: access.version } };
|
|
@@ -48,7 +52,7 @@ export class IndexedDbMessageStore {
|
|
|
48
52
|
async readSelected(thread, ids) {
|
|
49
53
|
if (ids.length > 100 || ids.some(id => !id || id.length > 256))
|
|
50
54
|
throw new Error("INVALID_MESSAGE_SELECTION");
|
|
51
|
-
const page = await this.
|
|
55
|
+
const page = await this.transact("r", this.rows, this.coverage, this.accessRows, this.controls, async () => {
|
|
52
56
|
const access = await this.scopeAccess();
|
|
53
57
|
const coverage = this.scopedCoverage(await this.coverage.get([this.key, thread]), access);
|
|
54
58
|
if (access.revoked || coverage?.revoked || (coverage?.scopeVersion ?? 0) !== access.version)
|
|
@@ -70,7 +74,7 @@ export class IndexedDbMessageStore {
|
|
|
70
74
|
if (bytes > 1_048_576)
|
|
71
75
|
throw new Error("MESSAGE_BATCH_TOO_LARGE");
|
|
72
76
|
}
|
|
73
|
-
return this.
|
|
77
|
+
return this.transact("rw", [this.rows, this.legacy, this.coverage, this.accessRows, this.controls], async () => {
|
|
74
78
|
const access = await this.scopeAccess();
|
|
75
79
|
const coverage = await this.coverage.get([this.key, thread]);
|
|
76
80
|
if (access.revoked || coverage?.revoked)
|
|
@@ -109,7 +113,7 @@ export class IndexedDbMessageStore {
|
|
|
109
113
|
async readFallback(thread, limit = 100, before) {
|
|
110
114
|
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 1000)
|
|
111
115
|
throw new Error("INVALID_MESSAGE_LIMIT");
|
|
112
|
-
const page = await this.
|
|
116
|
+
const page = await this.transact("r", [this.rows, this.legacy, this.coverage, this.accessRows, this.controls], async () => {
|
|
113
117
|
const access = await this.scopeAccess();
|
|
114
118
|
const coverage = access.revoked ? { complete: false, revoked: true, resetVersion: 0, scopeVersion: access.version } : this.scopedCoverage(await this.coverage.get([this.key, thread]), access);
|
|
115
119
|
if (coverage?.revoked)
|
|
@@ -127,7 +131,7 @@ export class IndexedDbMessageStore {
|
|
|
127
131
|
throw new Error("MESSAGE_SCOPE_MISMATCH");
|
|
128
132
|
if (messages.length > 1000)
|
|
129
133
|
throw new Error("MESSAGE_BATCH_TOO_LARGE");
|
|
130
|
-
return this.
|
|
134
|
+
return this.transact("rw", [this.rows, this.legacy, this.coverage, this.accessRows, this.controls], async () => {
|
|
131
135
|
const access = await this.scopeAccess();
|
|
132
136
|
if ((guard?.scopeVersion ?? 0) !== access.version)
|
|
133
137
|
throw new Error("MESSAGE_SCOPE_CHANGED");
|
|
@@ -207,7 +211,7 @@ export class IndexedDbMessageStore {
|
|
|
207
211
|
// v3 caches migrate only source proofs from already-read pages, with no body
|
|
208
212
|
// rewrite. Steady reads remain a single read-only IndexedDB transaction.
|
|
209
213
|
if (pendingControls?.length)
|
|
210
|
-
await this.
|
|
214
|
+
await this.transact("rw", this.controls, this.coverage, this.accessRows, async () => {
|
|
211
215
|
const access = await this.scopeAccess(), current = await this.coverage.get([this.key, thread]);
|
|
212
216
|
if (access.revoked || access.version !== (page.coverage?.scopeVersion ?? 0))
|
|
213
217
|
throw new Error("MESSAGE_SCOPE_CHANGED");
|
|
@@ -218,7 +222,7 @@ export class IndexedDbMessageStore {
|
|
|
218
222
|
return result;
|
|
219
223
|
}
|
|
220
224
|
async windows() {
|
|
221
|
-
return this.
|
|
225
|
+
return this.transact("r", this.coverage, this.accessRows, async () => {
|
|
222
226
|
if ((await this.scopeAccess()).revoked)
|
|
223
227
|
return [];
|
|
224
228
|
return this.coverage.where("scope").equals(this.key).toArray();
|
|
@@ -232,7 +236,7 @@ export class IndexedDbMessageStore {
|
|
|
232
236
|
}
|
|
233
237
|
/** Only fresh, server-confirmed authorization may lift this durable tombstone. */
|
|
234
238
|
async authorizeScope(expectedVersion) {
|
|
235
|
-
await this.
|
|
239
|
+
await this.transact("rw", [this.rows, this.legacy, this.coverage, this.accessRows, this.controls], async () => {
|
|
236
240
|
const access = await this.scopeAccess();
|
|
237
241
|
if (access.version !== expectedVersion)
|
|
238
242
|
throw new Error("MESSAGE_SCOPE_CHANGED");
|
|
@@ -253,7 +257,7 @@ export class IndexedDbMessageStore {
|
|
|
253
257
|
}
|
|
254
258
|
/** Persist the denial first; failed cleanup must never make cached rows readable. */
|
|
255
259
|
async revokeScope(expectedVersion) {
|
|
256
|
-
const access = await this.
|
|
260
|
+
const access = await this.transact("rw", this.accessRows, async () => {
|
|
257
261
|
const old = await this.scopeAccess();
|
|
258
262
|
if (expectedVersion !== undefined && old.version !== expectedVersion)
|
|
259
263
|
return old;
|
|
@@ -261,7 +265,7 @@ export class IndexedDbMessageStore {
|
|
|
261
265
|
await this.accessRows.put(next);
|
|
262
266
|
return next;
|
|
263
267
|
});
|
|
264
|
-
await this.
|
|
268
|
+
await this.transact("rw", [this.rows, this.legacy, this.coverage, this.accessRows, this.controls], async () => {
|
|
265
269
|
const current = await this.scopeAccess();
|
|
266
270
|
if (current.version === access.version && current.revoked)
|
|
267
271
|
await this.clearScopeRows();
|
|
@@ -270,7 +274,7 @@ export class IndexedDbMessageStore {
|
|
|
270
274
|
}
|
|
271
275
|
/** A durable epoch prevents another tab's pre-reset request from restoring rows. */
|
|
272
276
|
async resetThread(thread, revoked = false, scopeVersion = 0) {
|
|
273
|
-
return this.
|
|
277
|
+
return this.transact("rw", [this.rows, this.legacy, this.coverage, this.accessRows, this.controls], async () => {
|
|
274
278
|
const access = await this.scopeAccess();
|
|
275
279
|
if (access.version !== scopeVersion)
|
|
276
280
|
throw new Error("MESSAGE_SCOPE_CHANGED");
|
|
@@ -287,7 +291,7 @@ export class IndexedDbMessageStore {
|
|
|
287
291
|
});
|
|
288
292
|
}
|
|
289
293
|
async removeThread(thread) {
|
|
290
|
-
await this.
|
|
294
|
+
await this.transact("rw", [this.rows, this.legacy, this.coverage, this.accessRows, this.controls], async () => {
|
|
291
295
|
await this.controls.where("[scope+thread]").equals([this.key, thread]).delete();
|
|
292
296
|
await this.rows.where("[scope+thread]").equals([this.key, thread]).delete();
|
|
293
297
|
await this.legacy.where("[scope+thread]").equals([this.key, thread]).delete();
|
|
@@ -295,14 +299,14 @@ export class IndexedDbMessageStore {
|
|
|
295
299
|
});
|
|
296
300
|
}
|
|
297
301
|
async clear() {
|
|
298
|
-
await this.
|
|
302
|
+
await this.transact("rw", [this.rows, this.legacy, this.coverage, this.accessRows, this.controls], async () => {
|
|
299
303
|
await this.controls.where("scope").equals(this.key).delete();
|
|
300
304
|
await this.legacy.where("scope").equals(this.key).delete();
|
|
301
305
|
await this.rows.where("scope").equals(this.key).delete();
|
|
302
306
|
await this.coverage.where("scope").equals(this.key).delete();
|
|
303
307
|
});
|
|
304
308
|
}
|
|
305
|
-
close() { this.db.close(); }
|
|
309
|
+
close() { this.closed = true; this.db.close(); }
|
|
306
310
|
}
|
|
307
311
|
/** Keep tombstones monotonic; applications can supply stricter domain reducers. */
|
|
308
312
|
export function mergeStoredMessage(previous, incoming) {
|
|
@@ -238,7 +238,7 @@ export class ReadCountsClient {
|
|
|
238
238
|
this.heartbeat = undefined;
|
|
239
239
|
}
|
|
240
240
|
this.options.onError?.(error);
|
|
241
|
-
if (!isResponseCacheAuthorityError(error) && !/READ_COUNTS_HTTP_4\d\d|INVALID_READ_COUNTS/.test(String(error)) && this.retries < 3) {
|
|
241
|
+
if (!isResponseCacheAuthorityError(error) && (!/READ_COUNTS_HTTP_4\d\d|INVALID_READ_COUNTS/.test(String(error)) || /READ_COUNTS_HTTP_(409|429)/.test(String(error))) && this.retries < 3) {
|
|
242
242
|
retryDelay = 1000 * 2 ** this.retries++;
|
|
243
243
|
this.dirty = true;
|
|
244
244
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { recoverableTransactions } from "./indexedDbRecovery.js";
|
|
1
2
|
import { Dexie } from "dexie";
|
|
2
3
|
import { WindowedTasks } from "./coalescer.js";
|
|
3
4
|
export function isResponseCacheAuthorityError(error) {
|
|
@@ -18,6 +19,7 @@ export async function assertResponseCacheAccess(access, expectedVersion) {
|
|
|
18
19
|
export class IndexedDbResponseCache {
|
|
19
20
|
scope;
|
|
20
21
|
options;
|
|
22
|
+
transact;
|
|
21
23
|
db;
|
|
22
24
|
rows;
|
|
23
25
|
epochs;
|
|
@@ -34,6 +36,7 @@ export class IndexedDbResponseCache {
|
|
|
34
36
|
if (!Number.isSafeInteger(value) || value < 1 || value > max)
|
|
35
37
|
throw new Error("INVALID_RESPONSE_CACHE_LIMIT");
|
|
36
38
|
this.db = new Dexie(options.name ?? "pmate-chat-response-windows", { indexedDB: options.indexedDB ?? globalThis.indexedDB, IDBKeyRange: globalThis.IDBKeyRange });
|
|
39
|
+
this.transact = recoverableTransactions(this.db, () => this.closed);
|
|
37
40
|
this.db.version(1).stores({ windows: "[scope+key],scope", epochs: "scope" });
|
|
38
41
|
this.rows = this.db.table("windows");
|
|
39
42
|
this.epochs = this.db.table("epochs");
|
|
@@ -52,7 +55,7 @@ export class IndexedDbResponseCache {
|
|
|
52
55
|
// Ordinary cache reads must not take an IndexedDB write lock. Only an
|
|
53
56
|
// authority transition needs the atomic epoch bump and range deletion.
|
|
54
57
|
if (access.version > storedVersion)
|
|
55
|
-
await this.
|
|
58
|
+
await this.transact("rw", this.rows, this.epochs, async () => {
|
|
56
59
|
const current = await this.epochs.get(this.scope);
|
|
57
60
|
const version = current?.accessVersion ?? 0;
|
|
58
61
|
if (access.version < version)
|
|
@@ -69,7 +72,7 @@ export class IndexedDbResponseCache {
|
|
|
69
72
|
if (this.closed)
|
|
70
73
|
throw new Error("RESPONSE_CACHE_CLOSED");
|
|
71
74
|
await this.synchronizeAccess();
|
|
72
|
-
return this.
|
|
75
|
+
return this.transact("r", this.rows, this.epochs, async () => {
|
|
73
76
|
const epoch = (await this.epochs.get(this.scope))?.epoch ?? 0;
|
|
74
77
|
const row = await this.rows.get([this.scope, key]);
|
|
75
78
|
return { epoch, cached: row?.epoch === epoch ? { value: row.value, time: row.time } : undefined };
|
|
@@ -82,7 +85,7 @@ export class IndexedDbResponseCache {
|
|
|
82
85
|
if (keys.length > 128)
|
|
83
86
|
throw new Error("RESPONSE_CACHE_SELECTION_TOO_LARGE");
|
|
84
87
|
await this.synchronizeAccess();
|
|
85
|
-
return this.
|
|
88
|
+
return this.transact("r", this.rows, this.epochs, async () => {
|
|
86
89
|
const epoch = (await this.epochs.get(this.scope))?.epoch ?? 0, unique = [...new Set(keys)];
|
|
87
90
|
const rows = await this.rows.bulkGet(unique.map(key => [this.scope, key]));
|
|
88
91
|
return new Map(unique.map((key, index) => { const row = rows[index]; return [key, { epoch, cached: row?.epoch === epoch ? { value: row.value, time: row.time } : undefined }]; }));
|
|
@@ -109,7 +112,7 @@ export class IndexedDbResponseCache {
|
|
|
109
112
|
return Promise.reject(new Error("RESPONSE_CACHE_CLOSED"));
|
|
110
113
|
if (!Number.isSafeInteger(version) || version < 1)
|
|
111
114
|
return Promise.reject(new Error("INVALID_RESPONSE_SCOPE_VERSION"));
|
|
112
|
-
const task = this.
|
|
115
|
+
const task = this.transact("rw", this.rows, this.epochs, async () => {
|
|
113
116
|
const current = await this.epochs.get(this.scope);
|
|
114
117
|
if ((current?.accessVersion ?? 0) >= version)
|
|
115
118
|
return;
|
|
@@ -123,7 +126,7 @@ export class IndexedDbResponseCache {
|
|
|
123
126
|
if (this.closed)
|
|
124
127
|
return Promise.reject(new Error("RESPONSE_CACHE_CLOSED"));
|
|
125
128
|
const task = (async () => {
|
|
126
|
-
await this.
|
|
129
|
+
await this.transact("rw", this.rows, this.epochs, async () => {
|
|
127
130
|
const current = await this.epochs.get(this.scope);
|
|
128
131
|
const epoch = (current?.epoch ?? 0) + 1;
|
|
129
132
|
await this.epochs.put({ scope: this.scope, epoch, accessVersion: current?.accessVersion });
|
|
@@ -150,7 +153,7 @@ export class IndexedDbResponseCache {
|
|
|
150
153
|
const batch = this.pending.slice(0, this.options.batchRecords ?? 128);
|
|
151
154
|
try {
|
|
152
155
|
await this.synchronizeAccess();
|
|
153
|
-
const outcome = await this.
|
|
156
|
+
const outcome = await this.transact("rw", this.rows, this.epochs, async () => {
|
|
154
157
|
const epoch = (await this.epochs.get(this.scope))?.epoch ?? 0;
|
|
155
158
|
const valid = batch.filter(write => write.row.epoch === epoch);
|
|
156
159
|
const conditional = valid.filter(write => write.expectedRevision !== undefined);
|
package/lib/types/chatInput.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { ChatCommandConfig } from "../commands";
|
|
1
2
|
import type { MentionConfig, ChatMention } from "../mentions/model";
|
|
2
3
|
import type { ClipboardEventHandler, ComponentType, DragEventHandler, ReactNode } from "react";
|
|
3
4
|
import type { ChatAttachment } from "./attachment";
|
|
@@ -121,6 +122,8 @@ export type ChatImageConfig = {
|
|
|
121
122
|
uploadImage?: (file: File) => Promise<ChatImageUploadResult>;
|
|
122
123
|
};
|
|
123
124
|
export type ChatInputBaseProps = {
|
|
125
|
+
/** Host-provided slash commands; omit to disable command suggestions. Selection never sends. */
|
|
126
|
+
commands?: ChatCommandConfig;
|
|
124
127
|
/** Group members supplied by the host; omit for ordinary text input. */
|
|
125
128
|
mentions?: MentionConfig;
|
|
126
129
|
/** Use a taller composer with actions in a bottom toolbar. */
|
package/package.json
CHANGED