@aphrody/m3-ai 3.3.4 → 3.3.5

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
@@ -83,6 +83,28 @@ Helpers from `types.ts`: `pairTranscriptSteps`, `tryParseJson`, `isErrorResult`,
83
83
  `TASK_STATUS_ORDER`, `TASK_STATUS_LABEL`, `TASK_PRIORITY_LABEL`, and the transcript, task and
84
84
  model types.
85
85
 
86
+ ## Local chat
87
+
88
+ Everything a chat over an OpenAI-compatible server (llama-server, vLLM, the Aphrody gateway)
89
+ needs, with no visible string hard-coded:
90
+
91
+ | Export | Role |
92
+ | ------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
93
+ | `ChatLabels`, `chatLabelsFr` / `chatLabelsEn` / `chatLabelsJa`, `resolveChatLabels`, `formatLabel` | One dictionary for every visible string and aria-label. Every chat component takes `labels?: Partial<ChatLabels>`; missing keys fall back on English. |
94
+ | `streamChatCompletion`, `createSseParser`, `parseChatCompletionChunk`, `chatReducer`, `ChatStreamError` | Pure streaming client of `POST <baseUrl>/chat/completions` (`stream: true`): SSE framing, content / `reasoning_content` / tool-call deltas, `finish_reason`, top-level `citations` and `refusal`. Network failures and 404/502/503/504 are `unavailable`. |
95
+ | `useOpenAIChat({ baseUrl, model, fetch?, headers?, initialMessages?, preamble?, body? })` | React hook: `{ messages, send, stop, status, error, reset, setMessages }`, `status` is `idle`, `streaming`, `error` or `unavailable`. |
96
+ | `ConversationList` | Navigation-drawer list: new conversation, optional search, rename / delete callbacks, empty state, localized dates. |
97
+ | `ModelUnavailable` | Downloading / loading / unavailable state with a determinate linear indicator and a retry button. |
98
+ | `CitedMarkdown`, `CitationChip`, `CitationList`, `RefusalNotice` | Grounded answers: `[n]` markers as chips, the numbered sources, the "not established in the sources" notice. `MessageBubble` takes `citations` and `refusal` directly. |
99
+ | `MemoryPanel` | Saved memories with per-item deletion and a confirmed "clear all" (`confirmClear` injects your own dialog). |
100
+ | `ChatIcon` | The chat icons as inline Material Symbols SVG: no icon font is needed by the chat components. |
101
+
102
+ `Markdown` (and `MessageBubble`, `CitedMarkdown`) open external links with
103
+ `target="_blank" rel="noopener noreferrer"` and take `onLinkClick(href, event)` (call
104
+ `event.preventDefault()` to open the system browser from a Tauri webview) or `renderLink`.
105
+ `ChatComposer` takes `disabled` (the model is unavailable: input, send, voice and selectors are
106
+ inert), distinct from `isSending`.
107
+
86
108
  ## Tests
87
109
 
88
110
  ```sh
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aphrody/m3-ai",
3
- "version": "3.3.4",
3
+ "version": "3.3.5",
4
4
  "description": "Material Design 3 agent-interaction components (tool calls, markdown, chat composer, workers, tasks): the SpaceUI (@spacedrive/ai) set rebuilt on @aphrody/m3-primitives.",
5
5
  "keywords": [
6
6
  "material-3",
@@ -40,8 +40,8 @@
40
40
  },
41
41
  "dependencies": {
42
42
  "@aphrody/ag-ui": "1.0.1",
43
- "@aphrody/m3-motion": "3.3.4",
44
- "@aphrody/m3-primitives": "3.3.4",
43
+ "@aphrody/m3-motion": "3.3.5",
44
+ "@aphrody/m3-primitives": "3.3.5",
45
45
  "@aphrody/mcp-ui": "7.1.1-aphrody.1",
46
46
  "clsx": "^2.1.1",
47
47
  "motion": "^13.4.4",
@@ -50,7 +50,7 @@
50
50
  "remark-gfm": "^4.0.1"
51
51
  },
52
52
  "devDependencies": {
53
- "@aphrody/m3-config": "3.3.4"
53
+ "@aphrody/m3-config": "3.3.5"
54
54
  },
55
55
  "peerDependencies": {
56
56
  "react": ">=18.0.0",
@@ -3,7 +3,7 @@
3
3
  * @aphrody/m3-ai - A2APeerInspector
4
4
  *
5
5
  * Material Design 3 Agent-to-Agent (A2A 1.0) Fleet Inspector:
6
- * - Visualizes connected peer agents (codex, claude, agycli, shenron, nie, ocr, rag).
6
+ * - Visualizes connected peer agents (codex, claude, agycli, nie, ocr, rag, ...).
7
7
  * - Displays AgentCard interface, capabilities, and active tasks.
8
8
  * - Live inspection of .coord mailbox envelopes (ask, fact, ack, ping, error).
9
9
  * - File lock & resource claims view (.coord/claims).
@@ -72,14 +72,6 @@ const DEFAULT_PEERS: AgentPeer[] = [
72
72
  version: "3.7.0",
73
73
  skills: ["design-tokens", "docs-audit", "review"],
74
74
  },
75
- {
76
- id: "shenron",
77
- name: "Shenron Daemon",
78
- role: "Database & RAG Pipeline Gate",
79
- status: "idle",
80
- version: "2.1.0",
81
- skills: ["rag-index", "manga-publish", "embeddings"],
82
- },
83
75
  ];
84
76
 
85
77
  const KIND_COLORS: Record<EnvelopeKind, { bg: string; text: string; border: string }> = {
@@ -3,7 +3,7 @@
3
3
  * @aphrody/m3-ai - A2ATaskTimeline
4
4
  *
5
5
  * Material Design 3 Agent-to-Agent Delegation Timeline:
6
- * - Chronological trace of task handoffs between agents (e.g., user -> agycli -> codex -> claude -> shenron).
6
+ * - Chronological trace of task handoffs between agents (e.g., user -> agycli -> codex -> claude -> rag).
7
7
  * - Displays delegation phase, envelope kind, file claims, and execution status.
8
8
  */
9
9
 
@@ -163,14 +163,18 @@ export class AgUiEventAccumulator {
163
163
 
164
164
  case EventType.TOOL_CALL_RESULT: {
165
165
  const tcResult = event as ToolCallResultEvent;
166
- const resultContent = typeof tcResult.content === "string"
167
- ? tcResult.content
168
- : JSON.stringify(tcResult.content);
166
+ const resultContent =
167
+ typeof tcResult.content === "string"
168
+ ? tcResult.content
169
+ : JSON.stringify(tcResult.content);
169
170
 
170
171
  // Check if tool result contains A2UI operations
171
172
  this.inspectA2uiOperations(tcResult.content);
172
173
 
173
- const toolName = this.toolCallNames.get(tcResult.toolCallId) || (tcResult.metadata?.toolName as string) || "tool";
174
+ const toolName =
175
+ this.toolCallNames.get(tcResult.toolCallId) ||
176
+ (tcResult.metadata?.toolName as string) ||
177
+ "tool";
174
178
  this.steps.push({
175
179
  type: "tool_result",
176
180
  call_id: tcResult.toolCallId,
@@ -437,7 +441,7 @@ export function agUiEventToCoordEnvelope(
437
441
  id: val.id || id,
438
442
  from: val.from || from,
439
443
  to: val.to || to,
440
- kind: (kind as EnvelopeKind),
444
+ kind: kind as EnvelopeKind,
441
445
  topic: val.topic || "a2a-coordination",
442
446
  body: val.body || "",
443
447
  re: val.re,
@@ -3,12 +3,14 @@
3
3
 
4
4
  "use client";
5
5
 
6
- import { CircleButton, Icon, Popover, SelectPill, usePopover } from "@aphrody/m3-primitives";
6
+ import { CircleButton, Popover, SelectPill, usePopover } from "@aphrody/m3-primitives";
7
7
  import { m3Durations, m3Easings } from "@aphrody/m3-motion";
8
8
  import { clsx } from "clsx";
9
9
  import { AnimatePresence, motion } from "motion/react";
10
10
  import { type ReactNode } from "react";
11
11
 
12
+ import { type ChatLabels, resolveChatLabels } from "./chatLabels";
13
+ import { ChatIcon } from "./chatIcons";
12
14
  import { focusRing } from "./internal";
13
15
  import { ModelSelector } from "./ModelSelector";
14
16
  import type { ModelOption } from "./types";
@@ -26,6 +28,11 @@ export interface ChatComposerProps {
26
28
  heading?: ReactNode;
27
29
  /** Disable the composer — draft can still change, but send/voice are blocked */
28
30
  isSending?: boolean;
31
+ /**
32
+ * Disable the whole composer (e.g. the model is unavailable): the textarea, send, voice and
33
+ * the selectors are all inert. Distinct from `isSending`, which keeps the draft editable.
34
+ */
35
+ disabled?: boolean;
29
36
  /** Project selector config — omit to hide */
30
37
  projectSelector?: {
31
38
  value: string;
@@ -47,6 +54,8 @@ export interface ChatComposerProps {
47
54
  onStop?: () => void;
48
55
  /** Additional className on the composer container */
49
56
  className?: string;
57
+ /** Visible strings (placeholder, aria-labels); English defaults. `placeholder` wins over it. */
58
+ labels?: Partial<ChatLabels>;
50
59
  }
51
60
 
52
61
  const iconButtonEnter = {
@@ -72,24 +81,28 @@ export function ChatComposer({
72
81
  draft,
73
82
  onDraftChange,
74
83
  onSend,
75
- placeholder = "Ask something...",
84
+ placeholder,
76
85
  heading,
77
86
  isSending = false,
87
+ disabled = false,
78
88
  projectSelector,
79
89
  modelSelector,
80
90
  onOpenVoice,
81
91
  toolbarExtra,
82
92
  onStop,
83
93
  className,
94
+ labels,
84
95
  }: ChatComposerProps) {
85
- const canSend = !isSending && draft.trim().length > 0;
86
- const showStop = isSending && !!onStop;
96
+ const l = resolveChatLabels(labels);
97
+ const hint = placeholder ?? l.composerPlaceholder;
98
+ const canSend = !disabled && !isSending && draft.trim().length > 0;
99
+ const showStop = !disabled && isSending && !!onStop;
87
100
 
88
101
  return (
89
102
  <>
90
103
  {heading && (
91
104
  <div className="mb-3 flex items-center gap-2 px-1 text-title-small text-on-surface-variant">
92
- <Icon name="auto_awesome" size={18} filled className="text-primary" />
105
+ <ChatIcon name="auto_awesome_filled" size={18} className="text-primary" />
93
106
  {heading}
94
107
  </div>
95
108
  )}
@@ -107,12 +120,13 @@ export function ChatComposer({
107
120
  onKeyDown={(event) => {
108
121
  if (event.key === "Enter" && !event.shiftKey) {
109
122
  event.preventDefault();
110
- onSend();
123
+ if (canSend) onSend();
111
124
  }
112
125
  }}
113
- placeholder={placeholder}
114
- aria-label={placeholder}
126
+ placeholder={hint}
127
+ aria-label={hint}
115
128
  rows={2}
129
+ disabled={disabled}
116
130
  className="block w-full resize-none border-0 bg-transparent px-1 text-body-large text-on-surface caret-primary outline-none placeholder:text-on-surface-variant focus:outline-none focus:ring-0"
117
131
  />
118
132
 
@@ -124,8 +138,14 @@ export function ChatComposer({
124
138
  onOpenChange={projectSelector.popover.setOpen}
125
139
  >
126
140
  <Popover.Trigger asChild>
127
- <SelectPill className="w-full" aria-haspopup="listbox">
128
- {projectSelector.value}
141
+ <SelectPill
142
+ className="w-full"
143
+ aria-haspopup="listbox"
144
+ disabled={disabled}
145
+ hideCaret
146
+ >
147
+ <span className="min-w-0 flex-1 truncate">{projectSelector.value}</span>
148
+ <ChatIcon name="expand_more" size={18} />
129
149
  </SelectPill>
130
150
  </Popover.Trigger>
131
151
  <Popover.Content
@@ -136,7 +156,7 @@ export function ChatComposer({
136
156
  <div
137
157
  // oxlint-disable-next-line jsx-a11y/prefer-tag-over-role -- M3 menu in a popover with icon rows; a native <select>/<datalist> cannot render it.
138
158
  role="listbox"
139
- aria-label="Projects"
159
+ aria-label={l.projects}
140
160
  >
141
161
  {projectSelector.options.map((option) => {
142
162
  const selected = option === projectSelector.value;
@@ -162,7 +182,7 @@ export function ChatComposer({
162
182
  )}
163
183
  >
164
184
  <span className="min-w-0 flex-1 truncate">{option}</span>
165
- {selected && <Icon name="check" size={20} />}
185
+ {selected && <ChatIcon name="check" size={20} />}
166
186
  </button>
167
187
  );
168
188
  })}
@@ -182,6 +202,8 @@ export function ChatComposer({
182
202
  value={modelSelector.value}
183
203
  onChange={modelSelector.onChange}
184
204
  size="md"
205
+ disabled={disabled}
206
+ labels={labels}
185
207
  />
186
208
  </div>
187
209
  )}
@@ -190,11 +212,11 @@ export function ChatComposer({
190
212
 
191
213
  {onOpenVoice && (
192
214
  <CircleButton
193
- icon="mic"
215
+ icon={<ChatIcon name="mic" size={20} />}
194
216
  variant="standard"
195
217
  onClick={onOpenVoice}
196
- disabled={isSending}
197
- aria-label="Open Voice Input"
218
+ disabled={isSending || disabled}
219
+ aria-label={l.openVoice}
198
220
  />
199
221
  )}
200
222
 
@@ -205,14 +227,14 @@ export function ChatComposer({
205
227
  type="button"
206
228
  {...iconButtonEnter}
207
229
  onClick={onStop}
208
- aria-label="Stop"
230
+ aria-label={l.stop}
209
231
  className={clsx(
210
232
  "flex size-10 shrink-0 items-center justify-center rounded-full bg-primary text-on-primary",
211
233
  "transition-colors duration-200 ease-standard hover:bg-primary/92 active:bg-primary/90",
212
234
  focusRing,
213
235
  )}
214
236
  >
215
- <Icon name="stop" size={24} filled />
237
+ <ChatIcon name="stop_filled" size={24} />
216
238
  </motion.button>
217
239
  ) : canSend ? (
218
240
  <motion.button
@@ -220,14 +242,14 @@ export function ChatComposer({
220
242
  type="button"
221
243
  {...iconButtonEnter}
222
244
  onClick={onSend}
223
- aria-label="Send"
245
+ aria-label={l.send}
224
246
  className={clsx(
225
247
  "flex size-10 shrink-0 items-center justify-center rounded-full bg-primary text-on-primary",
226
248
  "transition-colors duration-200 ease-standard hover:bg-primary/92 active:bg-primary/90",
227
249
  focusRing,
228
250
  )}
229
251
  >
230
- <Icon name="arrow_upward" size={24} />
252
+ <ChatIcon name="arrow_upward" size={24} />
231
253
  </motion.button>
232
254
  ) : null}
233
255
  </AnimatePresence>
@@ -0,0 +1,207 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // Conversation list on the M3 navigation-drawer anatomy: a `surface-container-low` sheet, a
3
+ // "new conversation" FAB-like tonal button, optional search, and 56 dp items whose active one
4
+ // takes the `secondary-container` indicator.
5
+
6
+ "use client";
7
+
8
+ import { CircleButton } from "@aphrody/m3-primitives";
9
+ import { clsx } from "clsx";
10
+ import { useMemo, useState } from "react";
11
+
12
+ import { type ChatLabels, resolveChatLabels } from "./chatLabels";
13
+ import { ChatIcon } from "./chatIcons";
14
+ import { CircularIndicator, focusRing } from "./internal";
15
+
16
+ export interface ConversationSummary {
17
+ id: string;
18
+ title: string;
19
+ /** ISO string, epoch milliseconds or Date. */
20
+ updatedAt: string | number | Date;
21
+ preview?: string;
22
+ }
23
+
24
+ export interface ConversationListProps {
25
+ conversations: readonly ConversationSummary[];
26
+ selectedId?: string | null;
27
+ onSelect: (id: string) => void;
28
+ /** Shows the "new conversation" button. */
29
+ onNew?: () => void;
30
+ /** Shows a rename icon button on each item. */
31
+ onRename?: (id: string) => void;
32
+ /** Shows a delete icon button on each item. */
33
+ onDelete?: (id: string) => void;
34
+ /** Shows a search field that filters by title and preview. */
35
+ searchable?: boolean;
36
+ loading?: boolean;
37
+ labels?: Partial<ChatLabels>;
38
+ /** BCP 47 locale of the dates (`fr`, `en`, `ja`). */
39
+ locale?: string;
40
+ /** Overrides the date formatting. */
41
+ formatDate?: (updatedAt: Date) => string;
42
+ className?: string;
43
+ }
44
+
45
+ function toDate(value: string | number | Date): Date {
46
+ return value instanceof Date ? value : new Date(value);
47
+ }
48
+
49
+ function isoDate(value: string | number | Date): string | undefined {
50
+ const date = toDate(value);
51
+ return Number.isNaN(date.getTime()) ? undefined : date.toISOString();
52
+ }
53
+
54
+ /** Filter conversations by a case-insensitive query on title and preview. */
55
+ export function filterConversations(
56
+ conversations: readonly ConversationSummary[],
57
+ query: string,
58
+ ): ConversationSummary[] {
59
+ const needle = query.trim().toLocaleLowerCase();
60
+ if (!needle) return [...conversations];
61
+ return conversations.filter((conversation) =>
62
+ `${conversation.title}\n${conversation.preview ?? ""}`.toLocaleLowerCase().includes(needle),
63
+ );
64
+ }
65
+
66
+ export function ConversationList({
67
+ conversations,
68
+ selectedId,
69
+ onSelect,
70
+ onNew,
71
+ onRename,
72
+ onDelete,
73
+ searchable = false,
74
+ loading = false,
75
+ labels,
76
+ locale,
77
+ formatDate,
78
+ className,
79
+ }: ConversationListProps) {
80
+ const l = resolveChatLabels(labels);
81
+ const [query, setQuery] = useState("");
82
+ const visible = useMemo(() => filterConversations(conversations, query), [conversations, query]);
83
+ const dateFormat = useMemo(
84
+ () => new Intl.DateTimeFormat(locale, { dateStyle: "short" }),
85
+ [locale],
86
+ );
87
+ const format = (value: string | number | Date) => {
88
+ const date = toDate(value);
89
+ if (Number.isNaN(date.getTime())) return "";
90
+ return formatDate ? formatDate(date) : dateFormat.format(date);
91
+ };
92
+
93
+ return (
94
+ <nav
95
+ aria-label={l.conversations}
96
+ className={clsx(
97
+ "m3-conversation-list flex h-full min-h-0 flex-col gap-2 bg-surface-container-low px-3 py-3 text-on-surface",
98
+ className,
99
+ )}
100
+ >
101
+ <div className="flex items-center justify-between gap-2 px-1">
102
+ <h2 className="m-0 text-title-small text-on-surface-variant">{l.conversations}</h2>
103
+ </div>
104
+ {onNew ? (
105
+ <button
106
+ type="button"
107
+ onClick={onNew}
108
+ className={clsx(
109
+ "flex h-14 items-center gap-3 rounded-large bg-primary-container px-4 text-label-large text-on-primary-container",
110
+ "transition-colors duration-200 ease-standard hover:bg-primary-container/92",
111
+ focusRing,
112
+ )}
113
+ >
114
+ <ChatIcon name="edit_square" size={24} />
115
+ <span>{l.newConversation}</span>
116
+ </button>
117
+ ) : null}
118
+ {searchable ? (
119
+ <label className="flex h-12 items-center gap-3 rounded-extra-large bg-surface-container-high px-4 focus-within:bg-surface-container-highest">
120
+ <ChatIcon name="search" size={20} className="text-on-surface-variant" />
121
+ <input
122
+ type="search"
123
+ value={query}
124
+ onChange={(event) => setQuery(event.target.value)}
125
+ placeholder={l.searchConversations}
126
+ aria-label={l.searchConversations}
127
+ className="min-w-0 flex-1 border-0 bg-transparent text-body-large text-on-surface caret-primary outline-none placeholder:text-on-surface-variant"
128
+ />
129
+ </label>
130
+ ) : null}
131
+ {loading ? (
132
+ <div className="flex justify-center py-6">
133
+ <CircularIndicator size={24} label={l.loading} />
134
+ </div>
135
+ ) : visible.length === 0 ? (
136
+ <p className="m-0 px-4 py-6 text-center text-body-medium text-on-surface-variant">
137
+ {query.trim() ? l.noSearchResults : l.noConversations}
138
+ </p>
139
+ ) : (
140
+ <ul className="m-0 flex min-h-0 flex-1 list-none flex-col gap-0.5 overflow-y-auto p-0">
141
+ {visible.map((conversation) => {
142
+ const active = conversation.id === selectedId;
143
+ const title = conversation.title.trim() || l.untitledConversation;
144
+ return (
145
+ <li key={conversation.id} className="group relative">
146
+ <button
147
+ type="button"
148
+ onClick={() => onSelect(conversation.id)}
149
+ aria-current={active ? "page" : undefined}
150
+ className={clsx(
151
+ "flex min-h-14 w-full flex-col justify-center rounded-extra-large px-4 py-2 text-left",
152
+ "transition-colors duration-200 ease-standard",
153
+ active
154
+ ? "bg-secondary-container text-on-secondary-container"
155
+ : "text-on-surface-variant hover:bg-on-surface/8",
156
+ (onRename || onDelete) && "pr-24",
157
+ focusRing,
158
+ "focus-visible:outline-offset-[-3px]",
159
+ )}
160
+ >
161
+ <span className="flex w-full items-baseline gap-2">
162
+ <span className="min-w-0 flex-1 truncate text-label-large">{title}</span>
163
+ <time
164
+ className="shrink-0 text-label-small opacity-80"
165
+ dateTime={isoDate(conversation.updatedAt)}
166
+ >
167
+ {format(conversation.updatedAt)}
168
+ </time>
169
+ </span>
170
+ {conversation.preview ? (
171
+ <span className="mt-0.5 w-full truncate text-body-small opacity-80">
172
+ {conversation.preview}
173
+ </span>
174
+ ) : null}
175
+ </button>
176
+ {onRename || onDelete ? (
177
+ <span className="absolute top-1/2 right-2 flex -translate-y-1/2 gap-0.5 opacity-0 transition-opacity duration-200 ease-standard group-focus-within:opacity-100 group-hover:opacity-100">
178
+ {onRename ? (
179
+ <CircleButton
180
+ icon={<ChatIcon name="edit" size={18} />}
181
+ variant="standard"
182
+ size="sm"
183
+ onClick={() => onRename(conversation.id)}
184
+ aria-label={`${l.renameConversation}: ${title}`}
185
+ title={l.renameConversation}
186
+ />
187
+ ) : null}
188
+ {onDelete ? (
189
+ <CircleButton
190
+ icon={<ChatIcon name="delete" size={18} />}
191
+ variant="standard"
192
+ size="sm"
193
+ onClick={() => onDelete(conversation.id)}
194
+ aria-label={`${l.deleteConversation}: ${title}`}
195
+ title={l.deleteConversation}
196
+ />
197
+ ) : null}
198
+ </span>
199
+ ) : null}
200
+ </li>
201
+ );
202
+ })}
203
+ </ul>
204
+ )}
205
+ </nav>
206
+ );
207
+ }