create-pyric 0.1.0-alpha.13 → 0.1.0-alpha.14
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/package.json
CHANGED
|
@@ -13,8 +13,7 @@ import type { ChatPageServices, UiConversation, UiMessage, UiNotification, UiPre
|
|
|
13
13
|
import type { ChatMode } from '../../chat-mode';
|
|
14
14
|
import { MarkdownMessage } from './markdown-message';
|
|
15
15
|
import { storePreviewComponent } from './preview-component-state';
|
|
16
|
-
|
|
17
|
-
const starterMessages: UiMessage[] = [{ id: 'welcome', role: 'assistant', text: 'Good morning. What would you like to work through?', status: 'complete' }];
|
|
16
|
+
import { reconcileMessages, starterMessages } from './message-reconciliation';
|
|
18
17
|
|
|
19
18
|
const initials = (user: UiUser | null): string =>
|
|
20
19
|
(user?.displayName ?? user?.email ?? 'You').split(' ').map((part) => part[0]).join('').slice(0, 2).toUpperCase();
|
|
@@ -27,11 +26,6 @@ const modeOptions: Array<{ id: ChatMode; label: string; description: string; pla
|
|
|
27
26
|
{ id: 'plan', label: 'Plan', description: 'Make it actionable', placeholder: 'Turn an idea into a plan…' },
|
|
28
27
|
{ id: 'refine', label: 'Refine', description: 'Pressure-test a direction', placeholder: 'Pressure-test a direction…' },
|
|
29
28
|
];
|
|
30
|
-
const chronological = (items: UiMessage[]): UiMessage[] => [...items].sort((a, b) => {
|
|
31
|
-
if (!a.createdAt || !b.createdAt) return 0;
|
|
32
|
-
return a.createdAt.getTime() - b.createdAt.getTime();
|
|
33
|
-
});
|
|
34
|
-
|
|
35
29
|
const conversationIdFromUrl = (): string | null => new URLSearchParams(window.location.search).get('conversation');
|
|
36
30
|
|
|
37
31
|
const writeConversationUrl = (conversationId: string | null, replace = false): void => {
|
|
@@ -41,65 +35,6 @@ const writeConversationUrl = (conversationId: string | null, replace = false): v
|
|
|
41
35
|
window.history[replace ? 'replaceState' : 'pushState']({}, '', `${url.pathname}${url.search}${url.hash}`);
|
|
42
36
|
};
|
|
43
37
|
|
|
44
|
-
const reconcileMessages = (serverMessages: UiMessage[], currentMessages: UiMessage[]): UiMessage[] => {
|
|
45
|
-
const seenServerMessages = new Set<string>();
|
|
46
|
-
const server = chronological(serverMessages).filter((message) => {
|
|
47
|
-
if (!message.clientMessageId) return true;
|
|
48
|
-
const key = `${message.role}:${message.clientMessageId}`;
|
|
49
|
-
if (seenServerMessages.has(key)) return false;
|
|
50
|
-
seenServerMessages.add(key);
|
|
51
|
-
return true;
|
|
52
|
-
});
|
|
53
|
-
const serverClientIds = new Set(server.map((message) => message.clientMessageId).filter(Boolean));
|
|
54
|
-
const localAssistants = currentMessages.filter((message) => message.role === 'assistant' && message.id !== 'welcome');
|
|
55
|
-
const persistedAssistantIds = new Set(server.filter((message) => message.role === 'assistant').map((message) => message.clientMessageId).filter(Boolean));
|
|
56
|
-
const linkedAssistants = new Set<string>();
|
|
57
|
-
const result: UiMessage[] = [];
|
|
58
|
-
|
|
59
|
-
const addReply = (message: UiMessage) => {
|
|
60
|
-
for (const assistant of localAssistants) {
|
|
61
|
-
if (assistant.replyToClientMessageId === message.clientMessageId && !persistedAssistantIds.has(assistant.id)) {
|
|
62
|
-
result.push(assistant);
|
|
63
|
-
linkedAssistants.add(assistant.id);
|
|
64
|
-
}
|
|
65
|
-
}
|
|
66
|
-
};
|
|
67
|
-
|
|
68
|
-
for (const message of server) {
|
|
69
|
-
result.push(message);
|
|
70
|
-
if (message.role === 'user') addReply(message);
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
for (const message of currentMessages) {
|
|
74
|
-
if (message.role === 'user' && message.clientMessageId && !serverClientIds.has(message.clientMessageId)) {
|
|
75
|
-
result.push(message);
|
|
76
|
-
addReply(message);
|
|
77
|
-
}
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
result.push(...localAssistants.filter((message) => !linkedAssistants.has(message.id) && !message.replyToClientMessageId));
|
|
81
|
-
|
|
82
|
-
const turnKey = (message: UiMessage): string => {
|
|
83
|
-
if (message.role === 'assistant') {
|
|
84
|
-
if (message.replyToClientMessageId) return message.replyToClientMessageId;
|
|
85
|
-
if (message.clientMessageId?.startsWith('assistant-')) return message.clientMessageId.slice('assistant-'.length);
|
|
86
|
-
}
|
|
87
|
-
return message.clientMessageId ?? message.id;
|
|
88
|
-
};
|
|
89
|
-
const turnOrder = new Map<string, number>();
|
|
90
|
-
result.forEach((message, index) => {
|
|
91
|
-
const key = turnKey(message);
|
|
92
|
-
if (!turnOrder.has(key)) turnOrder.set(key, index);
|
|
93
|
-
});
|
|
94
|
-
return result.length ? [...result].sort((a, b) => {
|
|
95
|
-
const orderDifference = (turnOrder.get(turnKey(a)) ?? 0) - (turnOrder.get(turnKey(b)) ?? 0);
|
|
96
|
-
if (orderDifference !== 0) return orderDifference;
|
|
97
|
-
if (a.role === 'user' && b.role !== 'user') return -1;
|
|
98
|
-
if (a.role !== 'user' && b.role === 'user') return 1;
|
|
99
|
-
return 0;
|
|
100
|
-
}) : starterMessages;
|
|
101
|
-
};
|
|
102
|
-
|
|
103
38
|
function CopyMessageButton({ text }: { text: string }) {
|
|
104
39
|
const [copied, setCopied] = useState(false);
|
|
105
40
|
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import type { UiMessage } from './chat-types';
|
|
2
|
+
|
|
3
|
+
export const starterMessages: UiMessage[] = [{
|
|
4
|
+
id: 'welcome',
|
|
5
|
+
role: 'assistant',
|
|
6
|
+
text: 'Good morning. What would you like to work through?',
|
|
7
|
+
status: 'complete',
|
|
8
|
+
}];
|
|
9
|
+
|
|
10
|
+
const chronological = (items: UiMessage[]): UiMessage[] => [...items].sort((a, b) => {
|
|
11
|
+
if (!a.createdAt || !b.createdAt) return 0;
|
|
12
|
+
return a.createdAt.getTime() - b.createdAt.getTime();
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
export const reconcileMessages = (
|
|
16
|
+
serverMessages: UiMessage[],
|
|
17
|
+
currentMessages: UiMessage[],
|
|
18
|
+
): UiMessage[] => {
|
|
19
|
+
const seenServerMessages = new Set<string>();
|
|
20
|
+
const server = chronological(serverMessages).filter((message) => {
|
|
21
|
+
if (!message.clientMessageId) return true;
|
|
22
|
+
const key = `${message.role}:${message.clientMessageId}`;
|
|
23
|
+
if (seenServerMessages.has(key)) return false;
|
|
24
|
+
seenServerMessages.add(key);
|
|
25
|
+
return true;
|
|
26
|
+
});
|
|
27
|
+
const serverIds = new Set(server.map((message) => message.id));
|
|
28
|
+
const serverClientIds = new Set(server.map((message) => message.clientMessageId).filter(Boolean));
|
|
29
|
+
const localAssistants = currentMessages.filter((message) => message.role === 'assistant' && message.id !== 'welcome');
|
|
30
|
+
const persistedAssistantIds = new Set(server.filter((message) => message.role === 'assistant').map((message) => message.clientMessageId).filter(Boolean));
|
|
31
|
+
const linkedAssistants = new Set<string>();
|
|
32
|
+
const result: UiMessage[] = [];
|
|
33
|
+
|
|
34
|
+
const addReply = (message: UiMessage) => {
|
|
35
|
+
for (const assistant of localAssistants) {
|
|
36
|
+
if (assistant.replyToClientMessageId === message.clientMessageId && !persistedAssistantIds.has(assistant.id)) {
|
|
37
|
+
result.push(assistant);
|
|
38
|
+
linkedAssistants.add(assistant.id);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
for (const message of server) {
|
|
44
|
+
result.push(message);
|
|
45
|
+
if (message.role === 'user') addReply(message);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
for (const message of currentMessages) {
|
|
49
|
+
if (message.role === 'user' && message.clientMessageId && !serverClientIds.has(message.clientMessageId)) {
|
|
50
|
+
result.push(message);
|
|
51
|
+
addReply(message);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// React state contains both optimistic assistants and the previous listener
|
|
56
|
+
// result. Keep only assistants that are genuinely absent from this snapshot.
|
|
57
|
+
result.push(...localAssistants.filter((message) =>
|
|
58
|
+
!serverIds.has(message.id)
|
|
59
|
+
&& !linkedAssistants.has(message.id)
|
|
60
|
+
&& !message.replyToClientMessageId));
|
|
61
|
+
|
|
62
|
+
const turnKey = (message: UiMessage): string => {
|
|
63
|
+
if (message.role === 'assistant') {
|
|
64
|
+
if (message.replyToClientMessageId) return message.replyToClientMessageId;
|
|
65
|
+
if (message.clientMessageId?.startsWith('assistant-')) return message.clientMessageId.slice('assistant-'.length);
|
|
66
|
+
}
|
|
67
|
+
return message.clientMessageId ?? message.id;
|
|
68
|
+
};
|
|
69
|
+
const turnOrder = new Map<string, number>();
|
|
70
|
+
result.forEach((message, index) => {
|
|
71
|
+
const key = turnKey(message);
|
|
72
|
+
if (!turnOrder.has(key)) turnOrder.set(key, index);
|
|
73
|
+
});
|
|
74
|
+
return result.length ? [...result].sort((a, b) => {
|
|
75
|
+
const orderDifference = (turnOrder.get(turnKey(a)) ?? 0) - (turnOrder.get(turnKey(b)) ?? 0);
|
|
76
|
+
if (orderDifference !== 0) return orderDifference;
|
|
77
|
+
if (a.role === 'user' && b.role !== 'user') return -1;
|
|
78
|
+
if (a.role !== 'user' && b.role === 'user') return 1;
|
|
79
|
+
return 0;
|
|
80
|
+
}) : starterMessages;
|
|
81
|
+
};
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import test from 'node:test';
|
|
3
|
+
import { reconcileMessages } from '../src/ui/chat/message-reconciliation.ts';
|
|
4
|
+
import type { UiMessage } from '../src/ui/chat/chat-types.ts';
|
|
5
|
+
|
|
6
|
+
test('does not duplicate a persisted assistant when Firestore publishes again', () => {
|
|
7
|
+
const persistedAssistant: UiMessage = {
|
|
8
|
+
id: 'OK6MzPx25jG9GY9FwRA3',
|
|
9
|
+
role: 'assistant',
|
|
10
|
+
text: 'Persisted reply',
|
|
11
|
+
status: 'complete',
|
|
12
|
+
clientMessageId: 'assistant-client-message-1',
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
const firstSnapshot = reconcileMessages([persistedAssistant], []);
|
|
16
|
+
const secondSnapshot = reconcileMessages([persistedAssistant], firstSnapshot);
|
|
17
|
+
|
|
18
|
+
assert.deepEqual(secondSnapshot.map((message) => message.id), ['OK6MzPx25jG9GY9FwRA3']);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test('keeps a streaming assistant until its persisted replacement arrives', () => {
|
|
22
|
+
const user: UiMessage = {
|
|
23
|
+
id: 'server-user-1',
|
|
24
|
+
role: 'user',
|
|
25
|
+
text: 'Hello',
|
|
26
|
+
status: 'complete',
|
|
27
|
+
clientMessageId: 'client-message-1',
|
|
28
|
+
};
|
|
29
|
+
const streamingAssistant: UiMessage = {
|
|
30
|
+
id: 'assistant-client-message-1',
|
|
31
|
+
role: 'assistant',
|
|
32
|
+
text: 'Streaming',
|
|
33
|
+
status: 'streaming',
|
|
34
|
+
replyToClientMessageId: 'client-message-1',
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const whileStreaming = reconcileMessages([user], [user, streamingAssistant]);
|
|
38
|
+
assert.deepEqual(whileStreaming.map((message) => message.id), [
|
|
39
|
+
'server-user-1',
|
|
40
|
+
'assistant-client-message-1',
|
|
41
|
+
]);
|
|
42
|
+
|
|
43
|
+
const persistedAssistant: UiMessage = {
|
|
44
|
+
id: 'server-assistant-1',
|
|
45
|
+
role: 'assistant',
|
|
46
|
+
text: 'Complete',
|
|
47
|
+
status: 'complete',
|
|
48
|
+
clientMessageId: 'assistant-client-message-1',
|
|
49
|
+
};
|
|
50
|
+
const afterPersistence = reconcileMessages(
|
|
51
|
+
[user, persistedAssistant],
|
|
52
|
+
whileStreaming,
|
|
53
|
+
);
|
|
54
|
+
assert.deepEqual(afterPersistence.map((message) => message.id), [
|
|
55
|
+
'server-user-1',
|
|
56
|
+
'server-assistant-1',
|
|
57
|
+
]);
|
|
58
|
+
});
|