create-pyric 0.1.0-alpha.12 → 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 +1 -1
- package/templates/chat/.env.example +7 -5
- package/templates/chat/README.md +39 -5
- package/templates/chat/functions/index.js +1 -1
- package/templates/chat/src/ui/chat/chat-page.tsx +1 -66
- package/templates/chat/src/ui/chat/message-reconciliation.ts +81 -0
- package/templates/chat/test/message-reconciliation.test.ts +58 -0
- package/templates/chat/vite.config.ts +7 -40
package/package.json
CHANGED
|
@@ -12,9 +12,11 @@ VITE_FIREBASE_DATABASE_URL=
|
|
|
12
12
|
# production FCM token registration; the dev sandbox mints tokens without it.
|
|
13
13
|
VITE_FIREBASE_VAPID_KEY=
|
|
14
14
|
|
|
15
|
-
# Optional local
|
|
16
|
-
#
|
|
17
|
-
#
|
|
18
|
-
#
|
|
19
|
-
PYRIC_AI_PROXY_UPSTREAM=
|
|
15
|
+
# Optional local model. Setting this switches from Pyric's scripted AI to its
|
|
16
|
+
# OpenAI-compatible engine and supplies the model name sent upstream.
|
|
17
|
+
# The model-only path uses Ollama at http://localhost:11434/v1 by default.
|
|
18
|
+
# Example: ollama pull qwen3:4b
|
|
20
19
|
PYRIC_AI_MODEL=
|
|
20
|
+
# Override only for another OpenAI-compatible model server. This is the
|
|
21
|
+
# server-side base URL, not the browser proxy path; include /v1 when required.
|
|
22
|
+
PYRIC_AI_PROXY_UPSTREAM=
|
package/templates/chat/README.md
CHANGED
|
@@ -13,16 +13,41 @@ npm run dev
|
|
|
13
13
|
```
|
|
14
14
|
|
|
15
15
|
With no model configuration, Pyric's built-in scripted engine answers locally.
|
|
16
|
-
To use
|
|
17
|
-
|
|
16
|
+
To use Ollama, pull a model, copy `.env.example` to `.env.local`, and select it:
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
ollama pull qwen3:4b
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
```dotenv
|
|
23
|
+
PYRIC_AI_MODEL=qwen3:4b
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Pyric forwards the unchanged `firebase/ai` calls to Ollama's default
|
|
27
|
+
OpenAI-compatible endpoint at `http://localhost:11434/v1`. For another model
|
|
28
|
+
server, also set its base URL (including `/v1` when required):
|
|
18
29
|
|
|
19
30
|
```dotenv
|
|
20
|
-
PYRIC_AI_PROXY_UPSTREAM=http://localhost:8080/v1
|
|
21
31
|
PYRIC_AI_MODEL=minimax-m2.7
|
|
32
|
+
PYRIC_AI_PROXY_UPSTREAM=http://localhost:8080/v1
|
|
22
33
|
```
|
|
23
34
|
|
|
24
|
-
Start
|
|
25
|
-
|
|
35
|
+
Start the model server separately, then restart Vite.
|
|
36
|
+
See the [Pyric AI Logic guide](https://pyric.dev/docs/build/ai-logic/) for the full
|
|
37
|
+
engine and proxy behaviour.
|
|
38
|
+
|
|
39
|
+
For a one-off run on macOS, Linux, or another POSIX shell, prefix the same
|
|
40
|
+
command with the variables:
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
PYRIC_AI_MODEL=qwen3:4b \
|
|
44
|
+
PYRIC_AI_PROXY_UPSTREAM=http://localhost:11434/v1 \
|
|
45
|
+
npm run dev
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Do not put `&&` before `npm run dev`: that makes the values unexported shell
|
|
49
|
+
variables instead of environment variables for Vite. Use `.env.local` for the
|
|
50
|
+
normal, persistent setup.
|
|
26
51
|
|
|
27
52
|
## What runs in the sandbox
|
|
28
53
|
|
|
@@ -38,6 +63,15 @@ The same bundled module service worker handles `onBackgroundMessage` in local
|
|
|
38
63
|
development and production. Local delivery still requires an open page to keep
|
|
39
64
|
the Pyric sandbox alive.
|
|
40
65
|
|
|
66
|
+
## Runtime status
|
|
67
|
+
|
|
68
|
+
Pyric adds a collapsed runtime chip to the bottom-right corner during Vite
|
|
69
|
+
development. It signals sandbox errors and tells you when the long-lived worker
|
|
70
|
+
needs an update. Open it to copy an error, activate the new worker safely,
|
|
71
|
+
or open Pyric Studio in a new tab. Updating finishes accepted sandbox work and
|
|
72
|
+
then reloads every connected tab onto the new worker. The chip is not included
|
|
73
|
+
in a normal production build.
|
|
74
|
+
|
|
41
75
|
## Test and build
|
|
42
76
|
|
|
43
77
|
```bash
|
|
@@ -24,7 +24,7 @@ export const onPresenceOnline = onValueCreated('/presence/{uid}', async (event)
|
|
|
24
24
|
const profile = getFirestore().collection('users').doc(uid);
|
|
25
25
|
const snapshot = await profile.get();
|
|
26
26
|
const now = FieldValue.serverTimestamp();
|
|
27
|
-
const seen = snapshot.exists && snapshot.
|
|
27
|
+
const seen = snapshot.exists && snapshot.data()?.firstSeenAt
|
|
28
28
|
? { lastSeenAt: now }
|
|
29
29
|
: { firstSeenAt: now, lastSeenAt: now };
|
|
30
30
|
await profile.set(seen, { merge: true });
|
|
@@ -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
|
+
});
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { defineConfig
|
|
1
|
+
import { defineConfig } from 'vite';
|
|
2
2
|
import react from '@vitejs/plugin-react';
|
|
3
3
|
import tailwindcss from '@tailwindcss/vite';
|
|
4
4
|
import { pyric } from '@pyric/cli/vite';
|
|
@@ -11,44 +11,11 @@ import path from 'node:path';
|
|
|
11
11
|
// self-contained sandbox preview you can serve under `pyric dev`, build with a
|
|
12
12
|
// non-production mode: `vite build --mode development` (see the `build:sandbox`
|
|
13
13
|
// script). That output is marked and can never be deployed.
|
|
14
|
-
export default defineConfig(
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
throw new Error(
|
|
20
|
-
'PyChat local AI configuration requires both PYRIC_AI_PROXY_UPSTREAM and PYRIC_AI_MODEL.',
|
|
21
|
-
);
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
return {
|
|
25
|
-
plugins: [
|
|
26
|
-
react(),
|
|
27
|
-
tailwindcss(),
|
|
28
|
-
pyric({
|
|
29
|
-
bridge: true,
|
|
30
|
-
// The authored 2+modules source is the source of truth; the plugin
|
|
31
|
-
// resolves it in-memory, so no vendored firestore.rules is needed in
|
|
32
|
-
// dev. `npm run rules:resolve` still generates it for Firebase deploy.
|
|
33
|
-
rules: 'firestore.modules.rules',
|
|
34
|
-
...(proxyUpstream && model
|
|
35
|
-
? {
|
|
36
|
-
ai: {
|
|
37
|
-
proxyUpstream,
|
|
38
|
-
engine: {
|
|
39
|
-
kind: 'openai' as const,
|
|
40
|
-
model,
|
|
41
|
-
modelMap: { 'gemini-2.5-flash': model },
|
|
42
|
-
},
|
|
43
|
-
},
|
|
44
|
-
}
|
|
45
|
-
: {}),
|
|
46
|
-
}),
|
|
47
|
-
],
|
|
48
|
-
resolve: {
|
|
49
|
-
alias: {
|
|
50
|
-
'@': path.resolve(__dirname, './src'),
|
|
51
|
-
},
|
|
14
|
+
export default defineConfig({
|
|
15
|
+
plugins: [react(), tailwindcss(), pyric()],
|
|
16
|
+
resolve: {
|
|
17
|
+
alias: {
|
|
18
|
+
'@': path.resolve(__dirname, './src'),
|
|
52
19
|
},
|
|
53
|
-
}
|
|
20
|
+
},
|
|
54
21
|
});
|