@hachej/boring-ui-kit 0.4.2 → 0.4.4
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 +51 -1
- package/bin/boring-ui-blocks.mjs +29 -0
- package/dist/canvas-editor-native.d.ts +8 -1
- package/dist/canvas-editor-native.js +13 -7
- package/dist/canvas-editor.d.ts +4 -2
- package/dist/canvas-editor.js +5 -0
- package/dist/chat-outbox.d.ts +18 -0
- package/dist/chat-outbox.js +12 -0
- package/dist/file-tree-view.d.ts +29 -1
- package/dist/file-tree-view.js +74 -7
- package/dist/mark-store.d.ts +38 -0
- package/dist/mark-store.js +84 -0
- package/dist/markdown-editor.d.ts +6 -0
- package/dist/markdown-editor.js +17 -2
- package/dist/markdown-highlight-layer.d.ts +26 -0
- package/dist/markdown-highlight-layer.js +107 -0
- package/dist/markdown-quote.d.ts +23 -0
- package/dist/markdown-quote.js +39 -0
- package/dist/native-chat.d.ts +21 -0
- package/dist/native-chat.js +111 -7
- package/dist/remote-chat.js +16 -14
- package/dist/shared-chat/error.d.ts +10 -0
- package/dist/shared-chat/error.js +13 -0
- package/dist/shared-chat/fake.d.ts +39 -0
- package/dist/shared-chat/fake.js +90 -0
- package/dist/shared-chat/index.d.ts +51 -0
- package/dist/shared-chat/index.js +255 -0
- package/dist/shared-chat/socket.d.ts +20 -0
- package/dist/shared-chat/socket.js +114 -0
- package/dist/shared-chat/source.d.ts +117 -0
- package/dist/shared-chat/source.js +6 -0
- package/package.json +15 -3
- package/r/agent-target.json +36 -0
- package/r/app-shell.json +26 -11
- package/r/board-viewer.json +49 -0
- package/r/button.json +1 -1
- package/r/diagram-viewer.json +42 -0
- package/r/dock.json +1 -1
- package/r/documents.json +11 -11
- package/r/feedback-preview.json +1 -1
- package/r/feedback.json +6 -6
- package/r/file-tree.json +7 -6
- package/r/highlights.json +29 -0
- package/r/html-viewer.json +3 -3
- package/r/maquette-viewer.json +10 -13
- package/r/markdown-editor.json +3 -3
- package/r/mockup-viewer.json +45 -0
- package/r/pi-ambient.json +7 -7
- package/r/pi-app.json +21 -18
- package/r/pi-chat.json +28 -20
- package/r/pi-workspace.json +4 -4
- package/r/provider-setup.json +2 -2
- package/r/record-viewer.json +40 -0
- package/r/registry.json +449 -83
- package/r/requests-board.json +41 -0
- package/r/shared-chat.json +79 -0
- package/r/task-list-viewer.json +2 -2
- package/r/theme.json +15 -0
- package/r/version-history.json +3 -3
- package/r/viewers.json +16 -10
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { ChannelRequestError } from './error.js';
|
|
2
|
+
/** Fictional in-memory host. The controls exercise exactly the source events a real host delivers. */
|
|
3
|
+
export function createFakeChannelSource(seed) {
|
|
4
|
+
const data = structuredClone(seed);
|
|
5
|
+
const listeners = new Set();
|
|
6
|
+
const sends = [], actions = [];
|
|
7
|
+
let online = true;
|
|
8
|
+
function emit(event) {
|
|
9
|
+
if (event.type === 'message') {
|
|
10
|
+
const index = data.messages.findIndex(message => message.id === event.message.id);
|
|
11
|
+
if (index < 0)
|
|
12
|
+
data.messages.push(structuredClone(event.message));
|
|
13
|
+
else
|
|
14
|
+
data.messages[index] = structuredClone(event.message);
|
|
15
|
+
}
|
|
16
|
+
else if (event.type === 'delta') {
|
|
17
|
+
const message = data.messages.find(message => message.id === event.messageId && message.conversationId === event.conversationId);
|
|
18
|
+
if (message) {
|
|
19
|
+
message.text = (message.text ?? '') + event.text;
|
|
20
|
+
message.streaming = true;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
else if (event.type === 'step') {
|
|
24
|
+
const message = data.messages.find(message => message.id === event.messageId && message.conversationId === event.conversationId);
|
|
25
|
+
if (message && Number.isSafeInteger(event.index) && event.index >= 0) {
|
|
26
|
+
const steps = [...(message.steps ?? [])];
|
|
27
|
+
steps[event.index] = { ...event.step };
|
|
28
|
+
message.steps = steps;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
else if (event.type === 'presence')
|
|
32
|
+
for (const update of event.members) {
|
|
33
|
+
const member = data.members.find(member => member.id === update.id);
|
|
34
|
+
if (member)
|
|
35
|
+
member.presence = update.presence;
|
|
36
|
+
}
|
|
37
|
+
else if (event.type === 'members') {
|
|
38
|
+
for (const member of event.members) {
|
|
39
|
+
const index = data.members.findIndex(existing => existing.id === member.id);
|
|
40
|
+
if (index < 0)
|
|
41
|
+
data.members.push(structuredClone(member));
|
|
42
|
+
else
|
|
43
|
+
data.members[index] = structuredClone(member);
|
|
44
|
+
}
|
|
45
|
+
const conversation = data.conversations.find(conversation => conversation.id === event.conversationId);
|
|
46
|
+
if (conversation)
|
|
47
|
+
conversation.members = event.members.map(member => member.id);
|
|
48
|
+
}
|
|
49
|
+
if (online)
|
|
50
|
+
for (const listener of [...listeners]) {
|
|
51
|
+
const id = event.type === 'message' ? event.message.conversationId : event.type === 'presence' ? undefined : event.conversationId;
|
|
52
|
+
if (id === undefined || id === listener.id)
|
|
53
|
+
listener.event(structuredClone(event));
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return {
|
|
57
|
+
me: data.me,
|
|
58
|
+
conversations: async () => structuredClone(data.conversations),
|
|
59
|
+
members: async (id) => structuredClone(data.members.filter(member => data.conversations.find(conversation => conversation.id === id)?.members.includes(member.id))),
|
|
60
|
+
history: async (id, opts) => {
|
|
61
|
+
const all = data.messages.filter(message => message.conversationId === id);
|
|
62
|
+
const end = opts.before ? all.findIndex(message => message.id === opts.before) : all.length;
|
|
63
|
+
if (end < 0)
|
|
64
|
+
throw new Error('Unknown history cursor');
|
|
65
|
+
const start = Math.max(0, end - opts.limit);
|
|
66
|
+
return { messages: structuredClone(all.slice(start, end)), ...(start > 0 && all[start] ? { before: all[start].id } : {}) };
|
|
67
|
+
},
|
|
68
|
+
send: async (input) => {
|
|
69
|
+
sends.push({ ...input, mentions: [...input.mentions], ...(input.attachments ? { attachments: [...input.attachments] } : {}) });
|
|
70
|
+
if (!online)
|
|
71
|
+
throw new ChannelRequestError('offline', 'Offline', true);
|
|
72
|
+
const duplicate = data.messages.find(message => message.clientId === input.clientId && message.conversationId === input.conversationId);
|
|
73
|
+
if (duplicate)
|
|
74
|
+
return structuredClone(duplicate);
|
|
75
|
+
const message = { id: `sent-${input.clientId}`, conversationId: input.conversationId, author: data.me.id, at: new Date().toISOString(), text: input.text, mentions: [...input.mentions], clientId: input.clientId, ...(input.replyTo ? { replyTo: input.replyTo } : {}) };
|
|
76
|
+
emit({ type: 'message', message });
|
|
77
|
+
return structuredClone(message);
|
|
78
|
+
},
|
|
79
|
+
action: async (input) => { actions.push(structuredClone(input)); },
|
|
80
|
+
subscribe: (id, event, status) => {
|
|
81
|
+
const listener = { id, event, status };
|
|
82
|
+
listeners.add(listener);
|
|
83
|
+
status?.(online ? 'live' : 'offline');
|
|
84
|
+
return () => { listeners.delete(listener); };
|
|
85
|
+
},
|
|
86
|
+
emit, sends, actions,
|
|
87
|
+
setStatus: (status) => { online = status === 'live'; for (const listener of [...listeners])
|
|
88
|
+
listener.status?.(status); },
|
|
89
|
+
};
|
|
90
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import type { ChatAttempt, ChatDraft, ChatSendInput, NativeChatSnapshot } from '../native-chat.js';
|
|
2
|
+
export { ChannelRequestError } from './error.js';
|
|
3
|
+
import type { Card, ChannelMessage, ChannelSource, Member, SharedChatState } from './source.js';
|
|
4
|
+
export type * from './source.js';
|
|
5
|
+
export { channelAvatarStates } from './source.js';
|
|
6
|
+
export type SharedChatSnapshot = Omit<NativeChatSnapshot, 'conversationId' | 'send' | 'shared'> & {
|
|
7
|
+
readonly conversationId: string;
|
|
8
|
+
readonly shared: SharedChatState;
|
|
9
|
+
readonly send: {
|
|
10
|
+
readonly kind: 'idle' | 'submitting' | 'admitted';
|
|
11
|
+
} | {
|
|
12
|
+
readonly kind: 'unknown';
|
|
13
|
+
readonly reason: string;
|
|
14
|
+
readonly retryable: boolean;
|
|
15
|
+
} | {
|
|
16
|
+
readonly kind: 'blocked';
|
|
17
|
+
readonly reason: string;
|
|
18
|
+
};
|
|
19
|
+
};
|
|
20
|
+
export type { ChatController, ChatSnapshot } from '../native-chat.js';
|
|
21
|
+
/** Host-owned multi-party log, not a Pi conversation or an agent runtime. One adapter per selected conversation. */
|
|
22
|
+
export declare function createSharedChat(source: ChannelSource, options: {
|
|
23
|
+
readonly conversationId: string;
|
|
24
|
+
readonly limit?: number;
|
|
25
|
+
}): {
|
|
26
|
+
getSnapshot: () => SharedChatSnapshot;
|
|
27
|
+
subscribe: (listener: () => void) => () => void;
|
|
28
|
+
setText: (text: string) => void;
|
|
29
|
+
setAttachments: (attachments: ChatDraft["attachments"]) => void;
|
|
30
|
+
setFiles: (files: readonly File[]) => void;
|
|
31
|
+
setReplyTo: (messageId: string | undefined) => void;
|
|
32
|
+
clearHistory: () => void;
|
|
33
|
+
loadEarlier: () => Promise<void>;
|
|
34
|
+
connect: () => Promise<void>;
|
|
35
|
+
send: (_whenBusy?: ChatAttempt["whenBusy"], input?: ChatSendInput) => Promise<ChannelMessage | undefined>;
|
|
36
|
+
reconcile: () => Promise<void>;
|
|
37
|
+
retrySameRequest: () => Promise<void>;
|
|
38
|
+
stop: () => Promise<never>;
|
|
39
|
+
dispose: () => Promise<undefined>;
|
|
40
|
+
action: (card: Card) => Promise<void>;
|
|
41
|
+
remove: ((message: ChannelMessage) => Promise<void>) | undefined;
|
|
42
|
+
canRemove: (message: ChannelMessage) => boolean;
|
|
43
|
+
};
|
|
44
|
+
export type SharedChatController = ReturnType<typeof createSharedChat>;
|
|
45
|
+
/** The composer uses the existing quoted @ token grammar. Tokens carry stable member ids; picker labels show names. */
|
|
46
|
+
export declare function memberMention(member: Member): string;
|
|
47
|
+
export { createFakeChannelSource } from './fake.js';
|
|
48
|
+
export type { FakeChannelSeed } from './fake.js';
|
|
49
|
+
export { createChannelSocket } from './socket.js';
|
|
50
|
+
export type { ChannelSocketOptions, ChannelSocketSource } from './socket.js';
|
|
51
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
import { randomUUID } from '@hachej/boring-files/platform';
|
|
2
|
+
import { createChatOutbox } from '../chat-outbox.js';
|
|
3
|
+
import { ChannelRequestError } from './error.js';
|
|
4
|
+
export { ChannelRequestError } from './error.js';
|
|
5
|
+
export { channelAvatarStates } from './source.js';
|
|
6
|
+
/** Host-owned multi-party log, not a Pi conversation or an agent runtime. One adapter per selected conversation. */
|
|
7
|
+
export function createSharedChat(source, options) {
|
|
8
|
+
const id = options.conversationId, limit = options.limit ?? 60;
|
|
9
|
+
let snapshot = Object.freeze({ identity: { runtimeId: 'shared-chat', scopeId: id, principalId: source.me.id }, conversationId: id,
|
|
10
|
+
connection: { kind: 'idle' }, draft: { text: '', attachments: [], version: 0 }, outbox: [], history: { kind: 'idle' }, send: { kind: 'idle' }, stop: 'idle', disposed: false,
|
|
11
|
+
shared: { me: structuredClone(source.me), members: [], messages: [], files: [], replyTo: undefined, status: 'reconnecting' } });
|
|
12
|
+
const listeners = new Set();
|
|
13
|
+
const outbox = createChatOutbox();
|
|
14
|
+
const replayable = new Set();
|
|
15
|
+
let unsubscribe, opening, paging, before;
|
|
16
|
+
let revision = 0;
|
|
17
|
+
const changed = new Map();
|
|
18
|
+
const publish = (change) => {
|
|
19
|
+
if (snapshot.disposed)
|
|
20
|
+
return;
|
|
21
|
+
snapshot = Object.freeze({ ...snapshot, ...change });
|
|
22
|
+
for (const listener of [...listeners])
|
|
23
|
+
listener();
|
|
24
|
+
};
|
|
25
|
+
const active = () => { if (snapshot.disposed)
|
|
26
|
+
throw new Error('Chat controller is disposed'); };
|
|
27
|
+
const merge = (messages, prepend = false) => {
|
|
28
|
+
// New stream events always win over an overlapping history read. Replacements keep the row's position and identity.
|
|
29
|
+
const existing = snapshot.shared.messages;
|
|
30
|
+
const all = prepend ? [...messages, ...existing] : [...existing, ...messages];
|
|
31
|
+
const byId = new Map();
|
|
32
|
+
for (const message of all) {
|
|
33
|
+
if (message.conversationId !== id)
|
|
34
|
+
continue;
|
|
35
|
+
if (message.clientId)
|
|
36
|
+
for (const [key, value] of byId)
|
|
37
|
+
if (value.clientId === message.clientId && key !== message.id)
|
|
38
|
+
byId.delete(key);
|
|
39
|
+
byId.set(message.id, structuredClone(message));
|
|
40
|
+
}
|
|
41
|
+
publish({ shared: { ...snapshot.shared, messages: [...byId.values()].sort((a, b) => Date.parse(a.at) - Date.parse(b.at)) } });
|
|
42
|
+
};
|
|
43
|
+
const acknowledge = (message) => {
|
|
44
|
+
if (message.conversationId !== id || snapshot.disposed)
|
|
45
|
+
return;
|
|
46
|
+
changed.set(message.id, ++revision);
|
|
47
|
+
merge([message]);
|
|
48
|
+
if (message.clientId) {
|
|
49
|
+
replayable.delete(message.clientId);
|
|
50
|
+
outbox.resolve(message.clientId, message);
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
const sendOne = (clientId, input) => {
|
|
54
|
+
void Promise.resolve().then(() => source.send({ ...input, mentions: [...input.mentions], ...(input.attachments ? { attachments: [...input.attachments] } : {}) })).then(message => {
|
|
55
|
+
if (snapshot.disposed)
|
|
56
|
+
return;
|
|
57
|
+
if (message.conversationId !== id || message.clientId !== clientId)
|
|
58
|
+
throw new Error('Shared send returned another clientId or conversation');
|
|
59
|
+
acknowledge(message);
|
|
60
|
+
}).catch(error => {
|
|
61
|
+
if (!outbox.get(clientId))
|
|
62
|
+
return;
|
|
63
|
+
replayable.delete(clientId);
|
|
64
|
+
if (error instanceof ChannelRequestError && error.refused && !error.retryable) {
|
|
65
|
+
outbox.reject(clientId, error);
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
// An unconfirmed result retains its exact identity. Only an explicitly retryable failure may replay.
|
|
69
|
+
const retryable = error instanceof ChannelRequestError && error.retryable;
|
|
70
|
+
if (retryable)
|
|
71
|
+
replayable.add(clientId);
|
|
72
|
+
publish({ send: { kind: 'unknown', reason: error instanceof Error ? error.message : 'Channel send is unconfirmed', retryable } });
|
|
73
|
+
});
|
|
74
|
+
};
|
|
75
|
+
const replay = () => { for (const call of outbox.values())
|
|
76
|
+
if (replayable.has(call.input.clientId))
|
|
77
|
+
sendOne(call.input.clientId, call.input); };
|
|
78
|
+
async function refresh() {
|
|
79
|
+
// Walk backward to the newest known message, not just one page: a long disconnect must not drop a gap.
|
|
80
|
+
const known = new Set(snapshot.shared.messages.map(message => message.id));
|
|
81
|
+
let cursor;
|
|
82
|
+
const seen = new Set();
|
|
83
|
+
do {
|
|
84
|
+
const started = revision;
|
|
85
|
+
const page = await source.history(id, { limit, ...(cursor ? { before: cursor } : {}) });
|
|
86
|
+
if (snapshot.disposed)
|
|
87
|
+
return;
|
|
88
|
+
merge(page.messages.filter(message => (changed.get(message.id) ?? 0) <= started));
|
|
89
|
+
if (!known.size && cursor === undefined)
|
|
90
|
+
before = page.before;
|
|
91
|
+
if (!page.before || !known.size || page.messages.some(message => known.has(message.id)))
|
|
92
|
+
break;
|
|
93
|
+
if (seen.has(page.before))
|
|
94
|
+
throw new Error('Channel history cursor did not progress');
|
|
95
|
+
seen.add(page.before);
|
|
96
|
+
cursor = page.before;
|
|
97
|
+
} while (cursor);
|
|
98
|
+
publish({ history: { kind: 'ready', entries: [], hasMore: before !== undefined } });
|
|
99
|
+
}
|
|
100
|
+
const event = (event) => {
|
|
101
|
+
if (snapshot.disposed)
|
|
102
|
+
return;
|
|
103
|
+
revision++;
|
|
104
|
+
if (event.type === 'message')
|
|
105
|
+
acknowledge(event.message);
|
|
106
|
+
else if (event.type === 'delta' && event.conversationId === id) {
|
|
107
|
+
const message = snapshot.shared.messages.find(message => message.id === event.messageId);
|
|
108
|
+
if (message) {
|
|
109
|
+
changed.set(message.id, revision);
|
|
110
|
+
merge([{ ...message, text: (message.text ?? '') + event.text, streaming: true }]);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
else if (event.type === 'step' && event.conversationId === id && Number.isSafeInteger(event.index) && event.index >= 0) {
|
|
114
|
+
const message = snapshot.shared.messages.find(message => message.id === event.messageId);
|
|
115
|
+
if (message) {
|
|
116
|
+
const steps = [...(message.steps ?? [])];
|
|
117
|
+
steps[event.index] = { ...event.step };
|
|
118
|
+
changed.set(message.id, revision);
|
|
119
|
+
merge([{ ...message, steps }]);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
else if (event.type === 'presence') {
|
|
123
|
+
publish({ shared: { ...snapshot.shared, members: snapshot.shared.members.map(member => {
|
|
124
|
+
const update = event.members.find(update => update.id === member.id);
|
|
125
|
+
return update ? { ...member, presence: update.presence } : member;
|
|
126
|
+
}) } });
|
|
127
|
+
}
|
|
128
|
+
else if (event.type === 'members' && event.conversationId === id)
|
|
129
|
+
publish({ shared: { ...snapshot.shared, members: structuredClone(event.members) } });
|
|
130
|
+
};
|
|
131
|
+
let reconnect;
|
|
132
|
+
const status = (value) => {
|
|
133
|
+
const previous = snapshot.shared.status;
|
|
134
|
+
publish({ shared: { ...snapshot.shared, status: value }, connection: { kind: value === 'live' ? 'connected' : 'reconnecting' } });
|
|
135
|
+
if (value === 'live' && previous !== 'live' && !opening && !reconnect) {
|
|
136
|
+
reconnect = refresh().then(replay).catch(error => publish({ connection: { kind: 'error', error } })).finally(() => { reconnect = undefined; });
|
|
137
|
+
}
|
|
138
|
+
};
|
|
139
|
+
const controller = {
|
|
140
|
+
getSnapshot: () => snapshot,
|
|
141
|
+
subscribe: (listener) => { listeners.add(listener); return () => { listeners.delete(listener); }; },
|
|
142
|
+
setText: (text) => { active(); publish({ draft: { ...snapshot.draft, text, version: snapshot.draft.version + 1 } }); },
|
|
143
|
+
setAttachments: (attachments) => { active(); publish({ draft: { ...snapshot.draft, attachments, version: snapshot.draft.version + 1 } }); },
|
|
144
|
+
setFiles: (files) => { active(); publish({ shared: { ...snapshot.shared, files: [...files] } }); },
|
|
145
|
+
setReplyTo: (messageId) => { active(); publish({ shared: { ...snapshot.shared, replyTo: messageId } }); },
|
|
146
|
+
clearHistory: () => { active(); },
|
|
147
|
+
loadEarlier: () => {
|
|
148
|
+
active();
|
|
149
|
+
if (paging)
|
|
150
|
+
return paging;
|
|
151
|
+
if (!before)
|
|
152
|
+
return Promise.resolve();
|
|
153
|
+
const cursor = before;
|
|
154
|
+
publish({ history: { kind: 'loading', entries: [], hasMore: true } });
|
|
155
|
+
paging = source.history(id, { before: cursor, limit }).then(page => {
|
|
156
|
+
if (page.before === cursor)
|
|
157
|
+
throw new Error('Channel history cursor did not progress');
|
|
158
|
+
before = page.before;
|
|
159
|
+
merge(page.messages, true);
|
|
160
|
+
publish({ history: { kind: 'ready', entries: [], hasMore: before !== undefined } });
|
|
161
|
+
}).catch(error => publish({ history: { kind: 'error', entries: [], hasMore: true, error } })).finally(() => { paging = undefined; });
|
|
162
|
+
return paging;
|
|
163
|
+
},
|
|
164
|
+
connect: () => {
|
|
165
|
+
active();
|
|
166
|
+
if (opening)
|
|
167
|
+
return opening;
|
|
168
|
+
if (unsubscribe)
|
|
169
|
+
return Promise.resolve();
|
|
170
|
+
publish({ connection: { kind: 'connecting' } });
|
|
171
|
+
opening = Promise.resolve().then(async () => {
|
|
172
|
+
// Subscribe first: events arriving during the initial reads cannot fall through the history gap.
|
|
173
|
+
unsubscribe = source.subscribe(id, event, status);
|
|
174
|
+
const started = revision;
|
|
175
|
+
const members = await source.members(id);
|
|
176
|
+
if (snapshot.disposed)
|
|
177
|
+
return;
|
|
178
|
+
if (started === revision || !snapshot.shared.members.length)
|
|
179
|
+
publish({ shared: { ...snapshot.shared, members: structuredClone(members) } });
|
|
180
|
+
await refresh();
|
|
181
|
+
if (!snapshot.disposed)
|
|
182
|
+
publish({ connection: { kind: snapshot.shared.status === 'live' ? 'connected' : 'reconnecting' } });
|
|
183
|
+
}).catch(error => { unsubscribe?.(); unsubscribe = undefined; publish({ connection: { kind: 'error', error } }); }).finally(() => { opening = undefined; });
|
|
184
|
+
return opening;
|
|
185
|
+
},
|
|
186
|
+
send: async (_whenBusy, input) => {
|
|
187
|
+
active();
|
|
188
|
+
const selected = input ?? snapshot.draft;
|
|
189
|
+
const files = [...snapshot.shared.files];
|
|
190
|
+
if (!selected.text.trim() && !selected.attachments?.length && !files.length)
|
|
191
|
+
return;
|
|
192
|
+
if (selected.attachments?.length)
|
|
193
|
+
throw new Error('Use shared file attachments, not native image payloads');
|
|
194
|
+
const clientId = randomUUID();
|
|
195
|
+
const mentions = snapshot.shared.members.filter(member => hasMemberMention(selected.text, member)).map(member => member.id);
|
|
196
|
+
const payload = { conversationId: id, text: selected.text, mentions, clientId, ...(snapshot.shared.replyTo ? { replyTo: snapshot.shared.replyTo } : {}), ...(files.length ? { attachments: files } : {}) };
|
|
197
|
+
const result = outbox.enqueue(clientId, payload);
|
|
198
|
+
publish({ shared: { ...snapshot.shared, files: [], replyTo: undefined }, ...(!input ? { draft: { text: '', attachments: [], version: snapshot.draft.version + 1 } } : {}), send: { kind: 'submitting' } });
|
|
199
|
+
sendOne(clientId, payload);
|
|
200
|
+
try {
|
|
201
|
+
const message = await result;
|
|
202
|
+
publish({ send: { kind: 'admitted' } });
|
|
203
|
+
return message;
|
|
204
|
+
}
|
|
205
|
+
catch (error) {
|
|
206
|
+
if (!snapshot.disposed && error instanceof ChannelRequestError && error.refused) {
|
|
207
|
+
const current = snapshot.draft;
|
|
208
|
+
publish({ send: { kind: 'blocked', reason: error.message }, draft: { ...current, text: [input?.restore?.text ?? selected.text, current.text].filter(Boolean).join('\n'), version: current.version + 1 },
|
|
209
|
+
shared: { ...snapshot.shared, files: [...files, ...snapshot.shared.files], replyTo: snapshot.shared.replyTo ?? payload.replyTo } });
|
|
210
|
+
}
|
|
211
|
+
return undefined;
|
|
212
|
+
}
|
|
213
|
+
},
|
|
214
|
+
reconcile: async () => {
|
|
215
|
+
active();
|
|
216
|
+
await refresh();
|
|
217
|
+
for (const call of outbox.values()) {
|
|
218
|
+
const message = snapshot.shared.messages.find(message => message.clientId === call.input.clientId);
|
|
219
|
+
if (message)
|
|
220
|
+
acknowledge(message);
|
|
221
|
+
}
|
|
222
|
+
},
|
|
223
|
+
retrySameRequest: async () => { active(); replay(); },
|
|
224
|
+
stop: async () => { throw new Error('Only the host controls when an agent speaks'); },
|
|
225
|
+
dispose: async () => {
|
|
226
|
+
if (snapshot.disposed)
|
|
227
|
+
return;
|
|
228
|
+
unsubscribe?.();
|
|
229
|
+
unsubscribe = undefined;
|
|
230
|
+
for (const call of outbox.values())
|
|
231
|
+
call.reject(new Error('Shared chat disposed'));
|
|
232
|
+
outbox.clear();
|
|
233
|
+
replayable.clear();
|
|
234
|
+
snapshot = Object.freeze({ ...snapshot, disposed: true, connection: { kind: 'closed' } });
|
|
235
|
+
for (const listener of [...listeners])
|
|
236
|
+
listener();
|
|
237
|
+
listeners.clear();
|
|
238
|
+
return undefined;
|
|
239
|
+
},
|
|
240
|
+
action: (card) => card.action ? source.action({ conversationId: id, requestId: card.requestId, actionId: card.action.id }) : Promise.resolve(),
|
|
241
|
+
remove: source.remove && source.canRemove ? (message) => source.canRemove?.(message) === true ? source.remove(message.id) : Promise.reject(new Error('Removal refused')) : undefined,
|
|
242
|
+
canRemove: (message) => Boolean(source.remove && source.canRemove?.(message)),
|
|
243
|
+
};
|
|
244
|
+
return controller;
|
|
245
|
+
}
|
|
246
|
+
/** The composer uses the existing quoted @ token grammar. Tokens carry stable member ids; picker labels show names. */
|
|
247
|
+
export function memberMention(member) {
|
|
248
|
+
return /[\s"\\]/.test(member.id) ? `@"${member.id.replace(/[\\"]/g, '\\$&')}"` : `@${member.id}`;
|
|
249
|
+
}
|
|
250
|
+
function hasMemberMention(text, member) {
|
|
251
|
+
const token = memberMention(member);
|
|
252
|
+
return [...text.matchAll(/(^|\s)(@"(?:[^"\\]|\\.)*"|@[^\s]+)/g)].some(match => match[2] === token);
|
|
253
|
+
}
|
|
254
|
+
export { createFakeChannelSource } from './fake.js';
|
|
255
|
+
export { createChannelSocket } from './socket.js';
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { ChannelSource } from './source.js';
|
|
2
|
+
type Routes = Pick<ChannelSource, 'conversations' | 'members' | 'history' | 'send' | 'action' | 'remove' | 'canRemove'>;
|
|
3
|
+
export interface ChannelSocketOptions {
|
|
4
|
+
/** App URL, for example `https://app.example/`. RPC lives under `_channel`; `me` supplies the live space. */
|
|
5
|
+
readonly url: string | URL;
|
|
6
|
+
readonly fetch?: (request: Request) => Promise<Response>;
|
|
7
|
+
/** Host HTTP functions override the default routes independently. */
|
|
8
|
+
readonly routes?: Partial<Routes>;
|
|
9
|
+
/** Host subprotocol authentication may be supplied here. Closing a viewer closes only its own socket. */
|
|
10
|
+
readonly open?: (url: URL) => WebSocket;
|
|
11
|
+
readonly reconnectMs?: number;
|
|
12
|
+
}
|
|
13
|
+
export interface ChannelSocketSource extends ChannelSource {
|
|
14
|
+
readonly space: string;
|
|
15
|
+
readonly join: () => Promise<void>;
|
|
16
|
+
}
|
|
17
|
+
/** The platform app host's POST `_channel/<op>` and space WebSocket. Policy and identity remain at that host. */
|
|
18
|
+
export declare function createChannelSocket(options: ChannelSocketOptions): Promise<ChannelSocketSource>;
|
|
19
|
+
export {};
|
|
20
|
+
//# sourceMappingURL=socket.d.ts.map
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { randomUUID } from '@hachej/boring-files/platform';
|
|
3
|
+
import { ChannelRequestError } from './error.js';
|
|
4
|
+
const member = z.object({ id: z.string(), name: z.string(), kind: z.enum(['person', 'agent', 'admin']), role: z.union([z.enum(['owner', 'validator', 'member', 'admin']), z.templateLiteral(['agent:', z.string()])]).optional(), avatarUrl: z.string().optional(), presence: z.enum(['here', 'away']).optional() });
|
|
5
|
+
const conversation = z.object({ id: z.string(), title: z.string(), kind: z.string(), members: z.array(z.string()) });
|
|
6
|
+
const step = z.object({ label: z.string(), detail: z.string().optional(), status: z.enum(['running', 'done', 'failed']) });
|
|
7
|
+
const message = z.object({ id: z.string(), conversationId: z.string(), author: z.string(), at: z.string(), via: z.string().optional(), text: z.string().optional(),
|
|
8
|
+
card: z.object({ requestId: z.string(), title: z.string(), line: z.string(), since: z.string().optional(), state: z.string().optional(), action: z.object({ id: z.string(), label: z.string() }).optional() }).optional(),
|
|
9
|
+
mentions: z.array(z.string()).optional(), replyTo: z.string().optional(), attachments: z.array(z.object({ path: z.string(), title: z.string().optional() })).optional(), steps: z.array(step).optional(), streaming: z.boolean().optional(),
|
|
10
|
+
removed: z.object({ by: z.string(), at: z.string() }).optional(), clientId: z.string().optional() });
|
|
11
|
+
const event = z.discriminatedUnion('type', [z.object({ type: z.literal('message'), message }),
|
|
12
|
+
z.object({ type: z.literal('delta'), messageId: z.string(), conversationId: z.string(), text: z.string() }),
|
|
13
|
+
z.object({ type: z.literal('step'), messageId: z.string(), conversationId: z.string(), index: z.number().int().nonnegative(), step }),
|
|
14
|
+
z.object({ type: z.literal('presence'), members: z.array(z.object({ id: z.string(), presence: z.enum(['here', 'away']) })) }),
|
|
15
|
+
z.object({ type: z.literal('members'), conversationId: z.string(), members: z.array(member) })]);
|
|
16
|
+
/** The platform app host's POST `_channel/<op>` and space WebSocket. Policy and identity remain at that host. */
|
|
17
|
+
export async function createChannelSocket(options) {
|
|
18
|
+
const base = new URL(options.url, globalThis.location?.href);
|
|
19
|
+
base.pathname = base.pathname.replace(/\/$/, '') + '/';
|
|
20
|
+
const request = options.fetch ?? (request => globalThis.fetch(request));
|
|
21
|
+
const url = (path) => new URL(path, base);
|
|
22
|
+
const failure = z.object({ error: z.object({ code: z.string(), message: z.string(), retryable: z.boolean() }) });
|
|
23
|
+
async function rpc(op, input = {}) {
|
|
24
|
+
let response;
|
|
25
|
+
try {
|
|
26
|
+
response = await request(new Request(url(`_channel/${op}`), { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(input) }));
|
|
27
|
+
}
|
|
28
|
+
catch (error) {
|
|
29
|
+
throw new ChannelRequestError('network', error instanceof Error ? error.message : 'Channel connection was lost', true);
|
|
30
|
+
}
|
|
31
|
+
const body = await response.json().catch(() => undefined);
|
|
32
|
+
const rejected = failure.safeParse(body);
|
|
33
|
+
if (rejected.success) {
|
|
34
|
+
const error = rejected.data.error;
|
|
35
|
+
throw new ChannelRequestError(error.code, error.message, error.retryable, { refused: true });
|
|
36
|
+
}
|
|
37
|
+
if (!response.ok || body === undefined)
|
|
38
|
+
throw new ChannelRequestError('invalid-response', `Channel ${op} returned an invalid response (${response.status})`, false);
|
|
39
|
+
return body;
|
|
40
|
+
}
|
|
41
|
+
const bootstrap = z.object({ space: z.string().min(1), me: member }).parse(await rpc('me'));
|
|
42
|
+
const space = bootstrap.space, me = bootstrap.me;
|
|
43
|
+
return {
|
|
44
|
+
me, space,
|
|
45
|
+
conversations: options.routes?.conversations ?? (async () => z.array(conversation).parse(await rpc('conversations'))),
|
|
46
|
+
members: options.routes?.members ?? (async (conversationId) => z.array(member).parse(await rpc('members', { conversationId }))),
|
|
47
|
+
history: options.routes?.history ?? (async (conversationId, opts) => z.object({ messages: z.array(message), before: z.string().optional() }).parse(await rpc('history', { conversationId, ...opts }))),
|
|
48
|
+
send: options.routes?.send ?? (async (input) => {
|
|
49
|
+
if (input.attachments?.length)
|
|
50
|
+
throw new ChannelRequestError('unsupported-attachments', 'File attachments are not available here.', false, { refused: true });
|
|
51
|
+
const { conversationId, text, mentions, replyTo, clientId } = input;
|
|
52
|
+
return message.parse(await rpc('send', { conversationId, text, mentions, ...(replyTo === undefined ? {} : { replyTo }), clientId, idempotencyKey: clientId }));
|
|
53
|
+
}),
|
|
54
|
+
action: options.routes?.action ?? (async (input) => { await rpc('action', { ...input, idempotencyKey: randomUUID() }); }),
|
|
55
|
+
remove: options.routes?.remove ?? (async (messageId) => { await rpc('remove', { messageId }); }),
|
|
56
|
+
join: async () => { z.object({ space: z.string() }).parse(await rpc('join')); },
|
|
57
|
+
...(options.routes?.canRemove ? { canRemove: options.routes.canRemove } : {}),
|
|
58
|
+
subscribe: (id, onEvent, onStatus) => {
|
|
59
|
+
let disposed = false, socket, retry, attempt = 0;
|
|
60
|
+
function connect() {
|
|
61
|
+
if (disposed)
|
|
62
|
+
return;
|
|
63
|
+
onStatus?.('reconnecting');
|
|
64
|
+
const target = url(`spaces/${encodeURIComponent(space)}/live`);
|
|
65
|
+
target.protocol = target.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
66
|
+
let current;
|
|
67
|
+
try {
|
|
68
|
+
current = (options.open ?? (url => new WebSocket(url)))(target);
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
lost();
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
socket = current;
|
|
75
|
+
current.onopen = () => { if (!disposed && current === socket) {
|
|
76
|
+
attempt = 0;
|
|
77
|
+
onStatus?.('live');
|
|
78
|
+
} };
|
|
79
|
+
current.onmessage = frame => {
|
|
80
|
+
if (disposed || current !== socket || typeof frame.data !== 'string')
|
|
81
|
+
return;
|
|
82
|
+
const parsed = event.safeParse((() => { try {
|
|
83
|
+
return JSON.parse(frame.data);
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
return undefined;
|
|
87
|
+
} })());
|
|
88
|
+
if (parsed.success) {
|
|
89
|
+
const update = parsed.data;
|
|
90
|
+
if (update.type === 'presence' || (update.type === 'message' ? update.message.conversationId : update.conversationId) === id)
|
|
91
|
+
onEvent(update);
|
|
92
|
+
}
|
|
93
|
+
else {
|
|
94
|
+
current.close(1002, 'Invalid channel event');
|
|
95
|
+
}
|
|
96
|
+
};
|
|
97
|
+
current.onerror = () => current.close();
|
|
98
|
+
current.onclose = () => { if (!disposed && current === socket) {
|
|
99
|
+
socket = undefined;
|
|
100
|
+
lost();
|
|
101
|
+
} };
|
|
102
|
+
}
|
|
103
|
+
function lost() {
|
|
104
|
+
if (disposed)
|
|
105
|
+
return;
|
|
106
|
+
onStatus?.('offline');
|
|
107
|
+
clearTimeout(retry);
|
|
108
|
+
retry = setTimeout(connect, Math.min(30000, (options.reconnectMs ?? 1000) * 2 ** attempt++));
|
|
109
|
+
}
|
|
110
|
+
connect();
|
|
111
|
+
return () => { disposed = true; clearTimeout(retry); const current = socket; socket = undefined; current?.close(); };
|
|
112
|
+
},
|
|
113
|
+
};
|
|
114
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
export type Member = {
|
|
2
|
+
id: string;
|
|
3
|
+
name: string;
|
|
4
|
+
kind: 'person' | 'agent' | 'admin';
|
|
5
|
+
role?: 'owner' | 'validator' | 'member' | 'admin' | `agent:${string}`;
|
|
6
|
+
avatarUrl?: string;
|
|
7
|
+
presence?: 'here' | 'away';
|
|
8
|
+
};
|
|
9
|
+
export type Conversation = {
|
|
10
|
+
id: string;
|
|
11
|
+
title: string;
|
|
12
|
+
kind: 'main' | 'admin' | 'direct' | string;
|
|
13
|
+
members: string[];
|
|
14
|
+
};
|
|
15
|
+
export type Card = {
|
|
16
|
+
requestId: string;
|
|
17
|
+
title: string;
|
|
18
|
+
line: string;
|
|
19
|
+
since?: string;
|
|
20
|
+
state?: string;
|
|
21
|
+
action?: {
|
|
22
|
+
id: string;
|
|
23
|
+
label: string;
|
|
24
|
+
};
|
|
25
|
+
};
|
|
26
|
+
export type Step = {
|
|
27
|
+
label: string;
|
|
28
|
+
detail?: string;
|
|
29
|
+
status: 'running' | 'done' | 'failed';
|
|
30
|
+
};
|
|
31
|
+
export type AvatarState = 'working' | 'thinking' | 'waiting' | 'blocked' | 'done' | 'neutral';
|
|
32
|
+
/** Host lifecycle ids affect presentation only. Missing and unknown ids have no indicator. */
|
|
33
|
+
export declare const channelAvatarStates: Readonly<Record<string, AvatarState | undefined>>;
|
|
34
|
+
export type ChannelMessage = {
|
|
35
|
+
id: string;
|
|
36
|
+
conversationId: string;
|
|
37
|
+
author: string;
|
|
38
|
+
at: string;
|
|
39
|
+
via?: string;
|
|
40
|
+
text?: string;
|
|
41
|
+
card?: Card;
|
|
42
|
+
mentions?: string[];
|
|
43
|
+
replyTo?: string;
|
|
44
|
+
attachments?: {
|
|
45
|
+
path: string;
|
|
46
|
+
title?: string;
|
|
47
|
+
}[];
|
|
48
|
+
steps?: Step[];
|
|
49
|
+
streaming?: boolean;
|
|
50
|
+
removed?: {
|
|
51
|
+
by: string;
|
|
52
|
+
at: string;
|
|
53
|
+
};
|
|
54
|
+
clientId?: string;
|
|
55
|
+
};
|
|
56
|
+
export type ChannelEvent = {
|
|
57
|
+
type: 'message';
|
|
58
|
+
message: ChannelMessage;
|
|
59
|
+
} | {
|
|
60
|
+
type: 'delta';
|
|
61
|
+
messageId: string;
|
|
62
|
+
conversationId: string;
|
|
63
|
+
text: string;
|
|
64
|
+
} | {
|
|
65
|
+
type: 'step';
|
|
66
|
+
messageId: string;
|
|
67
|
+
conversationId: string;
|
|
68
|
+
index: number;
|
|
69
|
+
step: Step;
|
|
70
|
+
} | {
|
|
71
|
+
type: 'presence';
|
|
72
|
+
members: {
|
|
73
|
+
id: string;
|
|
74
|
+
presence: 'here' | 'away';
|
|
75
|
+
}[];
|
|
76
|
+
} | {
|
|
77
|
+
type: 'members';
|
|
78
|
+
conversationId: string;
|
|
79
|
+
members: Member[];
|
|
80
|
+
};
|
|
81
|
+
export interface ChannelSource {
|
|
82
|
+
readonly me: Member;
|
|
83
|
+
readonly conversations: () => Promise<Conversation[]>;
|
|
84
|
+
readonly members: (conversationId: string) => Promise<Member[]>;
|
|
85
|
+
readonly history: (conversationId: string, opts: {
|
|
86
|
+
before?: string;
|
|
87
|
+
limit: number;
|
|
88
|
+
}) => Promise<{
|
|
89
|
+
messages: ChannelMessage[];
|
|
90
|
+
before?: string;
|
|
91
|
+
}>;
|
|
92
|
+
readonly send: (input: {
|
|
93
|
+
conversationId: string;
|
|
94
|
+
text: string;
|
|
95
|
+
mentions: string[];
|
|
96
|
+
replyTo?: string;
|
|
97
|
+
clientId: string;
|
|
98
|
+
attachments?: File[];
|
|
99
|
+
}) => Promise<ChannelMessage>;
|
|
100
|
+
readonly action: (input: {
|
|
101
|
+
conversationId: string;
|
|
102
|
+
requestId: string;
|
|
103
|
+
actionId: string;
|
|
104
|
+
}) => Promise<void>;
|
|
105
|
+
readonly remove?: (messageId: string) => Promise<void>;
|
|
106
|
+
readonly canRemove?: (message: ChannelMessage) => boolean;
|
|
107
|
+
readonly subscribe: (conversationId: string, onEvent: (event: ChannelEvent) => void, onStatus?: (status: 'live' | 'reconnecting' | 'offline') => void) => () => void;
|
|
108
|
+
}
|
|
109
|
+
export interface SharedChatState {
|
|
110
|
+
readonly me: Member;
|
|
111
|
+
readonly members: readonly Member[];
|
|
112
|
+
readonly messages: readonly ChannelMessage[];
|
|
113
|
+
readonly files: readonly File[];
|
|
114
|
+
readonly replyTo: string | undefined;
|
|
115
|
+
readonly status: 'live' | 'reconnecting' | 'offline';
|
|
116
|
+
}
|
|
117
|
+
//# sourceMappingURL=source.d.ts.map
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/** Host lifecycle ids affect presentation only. Missing and unknown ids have no indicator. */
|
|
2
|
+
export const channelAvatarStates = Object.freeze({
|
|
3
|
+
building: 'working', executing: 'working', checks: 'working', asked: 'thinking',
|
|
4
|
+
question: 'waiting', answer: 'waiting', review: 'waiting', preview: 'waiting',
|
|
5
|
+
platform: 'blocked', fixing: 'blocked', live: 'done', declined: 'neutral',
|
|
6
|
+
});
|