@ayni.org/ayni-ui-core 0.3.1 → 0.3.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -16,7 +16,7 @@ The browser draws with CSS and the Ayni app with React Native styles. What the t
16
16
 
17
17
  | Surface | Design values and colour roles | What it shows |
18
18
  | --- | --- | --- |
19
- | A conversation | `CONVERSATION_DESIGN`, `CONVERSATION_COLORS` | `replyQuoteOf`, `reactionPillOf`, `plainText` |
19
+ | A conversation | `CONVERSATION_DESIGN`, `CONVERSATION_COLORS` | `replyQuoteOf`, `reactionPillOf`, `plainText`, `progressOf` (the agent is working) |
20
20
  | The home page (rail and column) | `HOME_DESIGN`, `HOME_COLORS` | `TRUST_MARK`, `CONTROLLER_KIND_ICON`, `formatTime` |
21
21
  | The Proof of Trust | `PROOF_OF_TRUST_DESIGN`, `PROOF_OF_TRUST_COLORS` | `verdictOf`, `identityStepOf`, `accreditationLine`, `otherCredentialLine`, `claimRows`, and its English wording (`PROOF_OF_TRUST_COPY`) |
22
22
 
@@ -24,5 +24,20 @@ The browser's stylesheet is the reference: tests in this repository compare it,
24
24
  with these values, so a change made on one side only fails. The app builds its styles from the same values. A change
25
25
  of look or wording on one of these surfaces starts here.
26
26
 
27
+ ## One conversation, every device
28
+
29
+ A conversation shows the same transcript on every device of a person (AG-UI profile PROF-STREAM-005,
30
+ PROF-HIST-007). The agent sends a session what the person's other devices add; the reducer places a text message
31
+ by its role and its id, so a message typed elsewhere lands on the person's side. For what a client missed while it
32
+ was away:
33
+
34
+ | Function | What it does |
35
+ | --- | --- |
36
+ | `readCatchUp(transcript, read)` | Reads the newest pages of history, going back until one reaches what the transcript holds, history ends, or four pages were read; then merges them. |
37
+ | `catchUp(transcript, pages)` | The merge itself. The transcript keeps its own paging position; when more was missed than was read, it starts over from the pages, so it never shows a hole. |
38
+ | `reachesTranscript(transcript, page)` | Whether a page of history holds a message the transcript already has. |
39
+
40
+ A client calls `readCatchUp` every time a session's stream attaches.
41
+
27
42
  Published to npm from this repository by the `npm-publish` workflow whenever `package.json` carries a version that is
28
43
  not on npm yet.
@@ -75,6 +75,33 @@ export declare const CONVERSATION_DESIGN: {
75
75
  readonly bottom: 12;
76
76
  readonly awayBeyond: 320;
77
77
  };
78
+ /**
79
+ * The agent is working (UI-CONV-016): a bubble on the agent's side with three bouncing dots, then the steps of
80
+ * the run in progress or "Thinking…".
81
+ */
82
+ readonly progress: {
83
+ readonly fontSize: 13;
84
+ readonly gap: 10;
85
+ readonly paddingY: 10;
86
+ readonly paddingX: 14;
87
+ readonly radius: 16;
88
+ readonly tailRadius: 4;
89
+ };
90
+ /**
91
+ * The three dots: each rises and brightens, one after the other. Over one period a dot is at rest until it
92
+ * starts, at its peak at `peakAt` of the period and back at rest from `restFrom`; the second and third dots
93
+ * start `delayMs` and twice `delayMs` later.
94
+ */
95
+ readonly dots: {
96
+ readonly size: 7;
97
+ readonly gap: 4;
98
+ readonly periodMs: 1200;
99
+ readonly delayMs: 150;
100
+ readonly lift: 4;
101
+ readonly restOpacity: 0.35;
102
+ readonly peakAt: 0.4;
103
+ readonly restFrom: 0.8;
104
+ };
78
105
  };
79
106
  export type ConversationDesign = typeof CONVERSATION_DESIGN;
80
107
  /** The colour roles both palettes define, in the browser as custom properties and in the app as theme colours. */
@@ -113,6 +140,13 @@ export declare const CONVERSATION_COLORS: {
113
140
  readonly border: "border";
114
141
  readonly icon: "text";
115
142
  };
143
+ readonly progress: {
144
+ readonly background: "surface";
145
+ readonly text: "muted";
146
+ };
147
+ readonly dots: {
148
+ readonly color: "accent";
149
+ };
116
150
  };
117
151
  /** The side a message sits on, and the corner its bubble tightens. */
118
152
  export declare function bubbleSide(role: 'user' | 'assistant' | 'system'): 'mine' | 'theirs' | 'system';
@@ -165,3 +199,26 @@ export declare function reactionPillOf(reactions: readonly {
165
199
  }[];
166
200
  mine?: string;
167
201
  } | null;
202
+ /** The wording of a conversation the two renderers share, in English (the browser's `conversation.*` keys). */
203
+ export declare const CONVERSATION_COPY: {
204
+ readonly thinking: "Thinking…";
205
+ };
206
+ /**
207
+ * What the transcript shows while the agent works (UI-CONV-016): the steps of the run in progress when it reports
208
+ * some; else "thinking", from the start of a run until its reply begins to stream; nothing when no run is in
209
+ * progress or the reply is already arriving. A run started from another device of the person counts the same.
210
+ */
211
+ export declare function progressOf(transcript: {
212
+ run: {
213
+ steps: readonly string[];
214
+ } | null;
215
+ messages: readonly {
216
+ role: string;
217
+ streaming?: boolean;
218
+ }[];
219
+ } | null | undefined): {
220
+ kind: 'steps';
221
+ steps: readonly string[];
222
+ } | {
223
+ kind: 'thinking';
224
+ } | null;
@@ -59,6 +59,17 @@ export const CONVERSATION_DESIGN = {
59
59
  },
60
60
  /** the button that returns to the latest message */
61
61
  latestButton: { size: 36, right: 20, bottom: 12, awayBeyond: 320 },
62
+ /**
63
+ * The agent is working (UI-CONV-016): a bubble on the agent's side with three bouncing dots, then the steps of
64
+ * the run in progress or "Thinking…".
65
+ */
66
+ progress: { fontSize: 13, gap: 10, paddingY: 10, paddingX: 14, radius: 16, tailRadius: 4 },
67
+ /**
68
+ * The three dots: each rises and brightens, one after the other. Over one period a dot is at rest until it
69
+ * starts, at its peak at `peakAt` of the period and back at rest from `restFrom`; the second and third dots
70
+ * start `delayMs` and twice `delayMs` later.
71
+ */
72
+ dots: { size: 7, gap: 4, periodMs: 1200, delayMs: 150, lift: 4, restOpacity: 0.35, peakAt: 0.4, restFrom: 0.8 },
62
73
  };
63
74
  /** Which colour role paints what in a conversation. */
64
75
  export const CONVERSATION_COLORS = {
@@ -70,6 +81,8 @@ export const CONVERSATION_COLORS = {
70
81
  quote: { border: 'accent', who: 'accent' },
71
82
  reactionPill: { background: 'bg', border: 'border', text: 'text', mineBorder: 'accent' },
72
83
  latestButton: { background: 'bg', border: 'border', icon: 'text' },
84
+ progress: { background: 'surface', text: 'muted' },
85
+ dots: { color: 'accent' },
73
86
  };
74
87
  /** The side a message sits on, and the corner its bubble tightens. */
75
88
  export function bubbleSide(role) {
@@ -123,3 +136,21 @@ export function reactionPillOf(reactions, me) {
123
136
  mine: own?.emoji,
124
137
  };
125
138
  }
139
+ /** The wording of a conversation the two renderers share, in English (the browser's `conversation.*` keys). */
140
+ export const CONVERSATION_COPY = {
141
+ thinking: 'Thinking…',
142
+ };
143
+ /**
144
+ * What the transcript shows while the agent works (UI-CONV-016): the steps of the run in progress when it reports
145
+ * some; else "thinking", from the start of a run until its reply begins to stream; nothing when no run is in
146
+ * progress or the reply is already arriving. A run started from another device of the person counts the same.
147
+ */
148
+ export function progressOf(transcript) {
149
+ const run = transcript?.run;
150
+ if (!run)
151
+ return null;
152
+ if (run.steps.length)
153
+ return { kind: 'steps', steps: run.steps };
154
+ const replying = transcript.messages.some((m) => m.role !== 'activity' && m.streaming);
155
+ return replying ? null : { kind: 'thinking' };
156
+ }
package/dist/reducer.d.ts CHANGED
@@ -4,9 +4,14 @@ export interface Reduction {
4
4
  transcript: Transcript;
5
5
  session: SessionRecord;
6
6
  /** signals the shell reacts to (title, badge, sound) */
7
- signals: ('message' | 'bound' | 'closed' | 'token' | 'approval' | 'identified')[];
7
+ signals: ('message' | 'bound' | 'closed' | 'token' | 'approval' | 'identified' | 'conversations')[];
8
8
  }
9
9
  export declare function emptyTranscript(sessionId: string): Transcript;
10
+ /**
11
+ * The agent's signal that the person's list of conversations changed (PROF-CONV-007): a title, an archive, a
12
+ * closure, made from any device. Named here until the profile package that lists it is the one every client has.
13
+ */
14
+ export declare const CONVERSATION_CHANGED = "ayni.conversation.changed";
10
15
  /** The five closed states of a flow (flow profile §5.2). */
11
16
  export declare function isClosedFlow(state: unknown): boolean;
12
17
  export declare function reduce(transcript: Transcript, session: SessionRecord, event: AgUiEvent, fromRun: boolean, isOnScreen: boolean): Reduction;
@@ -19,3 +24,23 @@ export declare function reactionsOf(page: Record<string, unknown>[]): Record<str
19
24
  }[]>;
20
25
  /** Merges a history page (newest last) into the transcript without duplicates (UI-CONV-012, UI-PRIV-003). */
21
26
  export declare function mergeHistory(transcript: Transcript, page: Record<string, unknown>[], nextBefore: string | null): Transcript;
27
+ /** A page of history as the agent returns it (PROF-HIST-001): newest last, with where the page before it starts. */
28
+ export interface HistoryPage {
29
+ messages: Record<string, unknown>[];
30
+ nextBefore: string | null;
31
+ }
32
+ /** Whether a page of history reaches what the transcript holds: one of its messages is already there. */
33
+ export declare function reachesTranscript(transcript: Transcript, page: Record<string, unknown>[]): boolean;
34
+ /**
35
+ * What a device missed while it was away, merged into the transcript it holds (UI-CONV-022, PROF-HIST-007): a
36
+ * conversation continued from another device, or events its stream could not replay. `pages` are the newest pages
37
+ * of history, newest first, read until one reached the transcript or history ended. The transcript keeps its own
38
+ * paging position when a page reached it. When none did, more was missed than was read: the transcript starts
39
+ * over from the pages, so it never shows a hole between what it held and what it read.
40
+ */
41
+ export declare function catchUp(transcript: Transcript, pages: readonly HistoryPage[]): Transcript;
42
+ /**
43
+ * Reads what `catchUp` needs: the newest pages of history, going back until one reaches the transcript, history
44
+ * ends, or `maxPages` were read; then merges them.
45
+ */
46
+ export declare function readCatchUp(transcript: Transcript, read: (before?: string) => Promise<HistoryPage>, maxPages?: number): Promise<Transcript>;
package/dist/reducer.js CHANGED
@@ -7,6 +7,11 @@ export function emptyTranscript(sessionId) {
7
7
  return { sessionId, messages: [], oldestLoadedId: null, complete: false, run: null, routed: {}, reactions: {} };
8
8
  }
9
9
  const now = () => new Date().toISOString();
10
+ /**
11
+ * The agent's signal that the person's list of conversations changed (PROF-CONV-007): a title, an archive, a
12
+ * closure, made from any device. Named here until the profile package that lists it is the one every client has.
13
+ */
14
+ export const CONVERSATION_CHANGED = 'ayni.conversation.changed';
10
15
  /** The five closed states of a flow (flow profile §5.2). */
11
16
  export function isClosedFlow(state) {
12
17
  return (state === 'completed' || state === 'declined' || state === 'cancelled' || state === 'failed' || state === 'expired');
@@ -75,9 +80,13 @@ export function reduce(transcript, session, event, fromRun, isOnScreen) {
75
80
  break;
76
81
  case 'TEXT_MESSAGE_START': {
77
82
  const role = event.role === 'user' ? 'user' : event.role === 'system' ? 'system' : 'assistant';
83
+ const id = String(event.messageId);
84
+ // a message already held comes again when a stream replays it after a reconnect: it keeps its time, so
85
+ // it stays where it is in the transcript
86
+ const held = t.messages.find((m) => m.id === id);
78
87
  t = {
79
88
  ...t,
80
- messages: upsertMessage(t.messages, { id: String(event.messageId), role, content: '', at, streaming: true }),
89
+ messages: upsertMessage(t.messages, { id, role, content: '', at: held?.at ?? at, streaming: true }),
81
90
  };
82
91
  break;
83
92
  }
@@ -187,6 +196,11 @@ export function reduce(transcript, session, event, fromRun, isOnScreen) {
187
196
  }
188
197
  break;
189
198
  }
199
+ case CONVERSATION_CHANGED:
200
+ // a conversation of the person was renamed, archived or closed, here or on another device: the client
201
+ // reads the agent's list again (PROF-CONV-007)
202
+ signals.push('conversations');
203
+ break;
190
204
  case CUSTOM_EVENT.sessionBound:
191
205
  s = {
192
206
  ...s,
@@ -308,3 +322,50 @@ export function mergeHistory(transcript, page, nextBefore) {
308
322
  complete: nextBefore === null || page.length === 0,
309
323
  };
310
324
  }
325
+ /** Whether a page of history reaches what the transcript holds: one of its messages is already there. */
326
+ export function reachesTranscript(transcript, page) {
327
+ const known = new Set(transcript.messages.map((m) => m.id));
328
+ return page.some((m) => {
329
+ const external = m.metadata?.externalId;
330
+ return known.has(String(m.id)) || (!!external && known.has(external));
331
+ });
332
+ }
333
+ /**
334
+ * What a device missed while it was away, merged into the transcript it holds (UI-CONV-022, PROF-HIST-007): a
335
+ * conversation continued from another device, or events its stream could not replay. `pages` are the newest pages
336
+ * of history, newest first, read until one reached the transcript or history ended. The transcript keeps its own
337
+ * paging position when a page reached it. When none did, more was missed than was read: the transcript starts
338
+ * over from the pages, so it never shows a hole between what it held and what it read.
339
+ */
340
+ export function catchUp(transcript, pages) {
341
+ if (!pages.length)
342
+ return transcript;
343
+ const held = transcript.messages.length > 0;
344
+ const reached = !held || pages.some((p) => p.nextBefore === null || reachesTranscript(transcript, p.messages));
345
+ let merged = reached ? transcript : { ...transcript, messages: [], reactions: {} };
346
+ for (const page of pages)
347
+ merged = mergeHistory(merged, page.messages, page.nextBefore);
348
+ return held && reached
349
+ ? { ...merged, oldestLoadedId: transcript.oldestLoadedId, complete: transcript.complete }
350
+ : merged;
351
+ }
352
+ /**
353
+ * Reads what `catchUp` needs: the newest pages of history, going back until one reaches the transcript, history
354
+ * ends, or `maxPages` were read; then merges them.
355
+ */
356
+ export async function readCatchUp(transcript, read, maxPages = 4) {
357
+ const pages = [];
358
+ let before;
359
+ for (let i = 0; i < maxPages; i++) {
360
+ const page = await read(before);
361
+ pages.push(page);
362
+ const done = page.nextBefore === null ||
363
+ page.messages.length === 0 ||
364
+ transcript.messages.length === 0 ||
365
+ reachesTranscript(transcript, page.messages);
366
+ if (done)
367
+ break;
368
+ before = page.nextBefore ?? undefined;
369
+ }
370
+ return catchUp(transcript, pages);
371
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ayni.org/ayni-ui-core",
3
- "version": "0.3.1",
3
+ "version": "0.3.3",
4
4
  "description": "Framework-independent core of the Ayni UI (UI-SHELL-004): agents and controllers, trust resolution, sessions and binding, the conversation reducer, the local store and sync; shared by the browser UI and the Ayni app",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",