@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.
@@ -0,0 +1,286 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // Grounded answers: `[n]` markers rendered as citation chips, the numbered source list, and the
3
+ // refusal notice of a backend that declined to answer outside its sources.
4
+
5
+ "use client";
6
+
7
+ import { clsx } from "clsx";
8
+ import { useMemo } from "react";
9
+ import type { Components } from "react-markdown";
10
+
11
+ import { type ChatLabels, formatLabel, resolveChatLabels } from "./chatLabels";
12
+ import { ChatIcon } from "./chatIcons";
13
+ import { focusRing } from "./internal";
14
+ import { Markdown, type MarkdownLinkHandler, externalLinkProps, isExternalHref } from "./Markdown";
15
+
16
+ /** One numbered passage an answer cites (`citations` of a grounded chat response). */
17
+ export interface Citation {
18
+ /** Marker number used in the text as `[n]`. */
19
+ n: number;
20
+ id?: string;
21
+ title: string;
22
+ url?: string;
23
+ sourceId?: string;
24
+ }
25
+
26
+ const CITATION_HREF = "#citation-";
27
+ const CODE_SPANS = /(```[\s\S]*?```|`[^`\n]*`)/;
28
+ const MARKER = /(?<![\]\\!])\[(\d{1,4})\](?!\()/g;
29
+
30
+ /**
31
+ * Rewrite the `[n]` markers of `content` whose number is cited into markdown links
32
+ * `[[n]](#citation-n)`. Code spans and fenced blocks are left untouched, as are markers
33
+ * without a matching citation.
34
+ */
35
+ export function linkCitationMarkers(content: string, citations: readonly Citation[]): string {
36
+ if (citations.length === 0) return content;
37
+ const known = new Set(citations.map((citation) => citation.n));
38
+ return content
39
+ .split(CODE_SPANS)
40
+ .map((part, index) =>
41
+ index % 2 === 1
42
+ ? part
43
+ : part.replace(MARKER, (match, digits: string) =>
44
+ known.has(Number(digits)) ? `[\\[${digits}\\]](${CITATION_HREF}${digits})` : match,
45
+ ),
46
+ )
47
+ .join("");
48
+ }
49
+
50
+ /** The numbers cited in `content`, in order of first appearance. */
51
+ export function citedNumbers(content: string): number[] {
52
+ const seen: number[] = [];
53
+ for (const part of content.split(CODE_SPANS).filter((_, index) => index % 2 === 0)) {
54
+ for (const match of part.matchAll(MARKER)) {
55
+ const n = Number(match[1]);
56
+ if (!seen.includes(n)) seen.push(n);
57
+ }
58
+ }
59
+ return seen;
60
+ }
61
+
62
+ export interface CitationChipProps {
63
+ citation: Citation;
64
+ labels?: Partial<ChatLabels>;
65
+ onSelect?: (citation: Citation) => void;
66
+ className?: string;
67
+ }
68
+
69
+ /** Inline citation marker: a dense M3 assist chip showing `n`, labelled with the source title. */
70
+ export function CitationChip({ citation, labels, onSelect, className }: CitationChipProps) {
71
+ const l = resolveChatLabels(labels);
72
+ const label = `${formatLabel(l.citation, { n: citation.n })}: ${citation.title}`;
73
+ const chipClass = clsx(
74
+ "mx-0.5 inline-flex h-5 min-w-5 items-center justify-center rounded-small px-1 align-baseline",
75
+ "bg-secondary-container text-label-small text-on-secondary-container no-underline",
76
+ "transition-colors duration-200 ease-standard hover:bg-secondary-container/80",
77
+ focusRing,
78
+ className,
79
+ );
80
+ if (!onSelect && citation.url) {
81
+ return (
82
+ <a
83
+ href={citation.url}
84
+ target="_blank"
85
+ rel="noreferrer noopener"
86
+ className={chipClass}
87
+ aria-label={label}
88
+ title={citation.title}
89
+ >
90
+ {citation.n}
91
+ </a>
92
+ );
93
+ }
94
+ return (
95
+ <button
96
+ type="button"
97
+ className={chipClass}
98
+ aria-label={label}
99
+ title={citation.title}
100
+ onClick={() => onSelect?.(citation)}
101
+ >
102
+ {citation.n}
103
+ </button>
104
+ );
105
+ }
106
+
107
+ /** Markdown overrides of `CitedMarkdown`: citation links become chips, other links stay safe. */
108
+ function citationComponents(
109
+ citations: readonly Citation[],
110
+ labels: Partial<ChatLabels> | undefined,
111
+ onCitationSelect: ((citation: Citation) => void) | undefined,
112
+ onLinkClick: MarkdownLinkHandler | undefined,
113
+ ): Components {
114
+ const byNumber = new Map(citations.map((citation) => [citation.n, citation]));
115
+ return {
116
+ a: ({ node: _node, href, children, onClick, ...props }) => {
117
+ if (href?.startsWith(CITATION_HREF)) {
118
+ const citation = byNumber.get(Number(href.slice(CITATION_HREF.length)));
119
+ if (citation)
120
+ return <CitationChip citation={citation} labels={labels} onSelect={onCitationSelect} />;
121
+ }
122
+ return (
123
+ <a
124
+ href={href}
125
+ {...(isExternalHref(href) ? externalLinkProps : {})}
126
+ onClick={(event) => {
127
+ onClick?.(event);
128
+ if (href) onLinkClick?.(href, event);
129
+ }}
130
+ className={clsx(
131
+ "rounded-extra-small text-primary underline underline-offset-2 hover:decoration-2",
132
+ focusRing,
133
+ )}
134
+ {...props}
135
+ >
136
+ {children}
137
+ </a>
138
+ );
139
+ },
140
+ };
141
+ }
142
+
143
+ export interface CitedMarkdownProps {
144
+ content: string;
145
+ citations: readonly Citation[];
146
+ labels?: Partial<ChatLabels>;
147
+ /** Called when a marker is activated; without it, markers with a `url` open it. */
148
+ onCitationSelect?: (citation: Citation) => void;
149
+ /** Activation of an ordinary link (not a citation marker); see `Markdown.onLinkClick`. */
150
+ onLinkClick?: MarkdownLinkHandler;
151
+ size?: "small" | "medium" | "large";
152
+ className?: string;
153
+ }
154
+
155
+ /** Markdown whose `[n]` markers become citation chips. */
156
+ export function CitedMarkdown({
157
+ content,
158
+ citations,
159
+ labels,
160
+ onCitationSelect,
161
+ onLinkClick,
162
+ size = "large",
163
+ className,
164
+ }: CitedMarkdownProps) {
165
+ const components = useMemo(
166
+ () => citationComponents(citations, labels, onCitationSelect, onLinkClick),
167
+ [citations, labels, onCitationSelect, onLinkClick],
168
+ );
169
+ return (
170
+ <Markdown
171
+ content={linkCitationMarkers(content, citations)}
172
+ components={components}
173
+ labels={labels}
174
+ size={size}
175
+ className={className}
176
+ />
177
+ );
178
+ }
179
+
180
+ export interface CitationListProps {
181
+ citations: readonly Citation[];
182
+ labels?: Partial<ChatLabels>;
183
+ onSelect?: (citation: Citation) => void;
184
+ className?: string;
185
+ }
186
+
187
+ /** The numbered sources of an answer, as an M3 list on `surface-container-low`. */
188
+ export function CitationList({ citations, labels, onSelect, className }: CitationListProps) {
189
+ if (citations.length === 0) return null;
190
+ const l = resolveChatLabels(labels);
191
+ const sorted = [...citations];
192
+ sorted.sort((a, b) => a.n - b.n);
193
+ return (
194
+ <section
195
+ aria-label={l.sources}
196
+ className={clsx(
197
+ "m3-citation-list mt-2 rounded-medium bg-surface-container-low py-2",
198
+ className,
199
+ )}
200
+ >
201
+ <h3 className="px-4 pb-1 text-label-large text-on-surface-variant">{l.sources}</h3>
202
+ <ol className="m-0 list-none p-0">
203
+ {sorted.map((citation) => {
204
+ const body = (
205
+ <>
206
+ <span className="flex size-6 shrink-0 items-center justify-center rounded-small bg-secondary-container text-label-small text-on-secondary-container">
207
+ {citation.n}
208
+ </span>
209
+ <span className="min-w-0 flex-1 truncate text-body-medium text-on-surface">
210
+ {citation.title}
211
+ </span>
212
+ {citation.url || onSelect ? (
213
+ <ChatIcon name="open_in_new" size={18} className="text-on-surface-variant" />
214
+ ) : null}
215
+ </>
216
+ );
217
+ const rowClass = clsx(
218
+ "flex min-h-12 w-full items-center gap-3 px-4 py-1 text-left",
219
+ "transition-colors duration-200 ease-standard hover:bg-on-surface/8",
220
+ focusRing,
221
+ "focus-visible:outline-offset-[-3px]",
222
+ );
223
+ const name = `${formatLabel(l.citation, { n: citation.n })}: ${citation.title}`;
224
+ return (
225
+ <li
226
+ key={`${citation.n}-${citation.id ?? citation.title}`}
227
+ id={`citation-${citation.n}`}
228
+ >
229
+ {onSelect ? (
230
+ <button
231
+ type="button"
232
+ className={rowClass}
233
+ aria-label={name}
234
+ onClick={() => onSelect(citation)}
235
+ >
236
+ {body}
237
+ </button>
238
+ ) : citation.url ? (
239
+ <a
240
+ href={citation.url}
241
+ target="_blank"
242
+ rel="noreferrer noopener"
243
+ className={rowClass}
244
+ aria-label={`${name} (${l.openSource})`}
245
+ >
246
+ {body}
247
+ </a>
248
+ ) : (
249
+ <div className="flex min-h-12 items-center gap-3 px-4 py-1">{body}</div>
250
+ )}
251
+ </li>
252
+ );
253
+ })}
254
+ </ol>
255
+ </section>
256
+ );
257
+ }
258
+
259
+ export interface RefusalNoticeProps {
260
+ labels?: Partial<ChatLabels>;
261
+ /** Backend explanation, shown under the title instead of the generic body. */
262
+ detail?: string;
263
+ className?: string;
264
+ }
265
+
266
+ /** `refusal: true`: the answer is not established in the sources (M3 tonal note, not an error). */
267
+ export function RefusalNotice({ labels, detail, className }: RefusalNoticeProps) {
268
+ const l = resolveChatLabels(labels);
269
+ return (
270
+ <div
271
+ role="note"
272
+ className={clsx(
273
+ "m3-refusal-notice flex items-start gap-3 rounded-medium bg-surface-container-high px-4 py-3",
274
+ className,
275
+ )}
276
+ >
277
+ <ChatIcon name="info" size={20} className="mt-0.5 text-on-surface-variant" />
278
+ <div className="min-w-0">
279
+ <p className="m-0 text-title-small text-on-surface">{l.refusalTitle}</p>
280
+ <p className="m-0 mt-0.5 text-body-medium text-on-surface-variant">
281
+ {detail || l.refusalBody}
282
+ </p>
283
+ </div>
284
+ </div>
285
+ );
286
+ }
package/src/index.ts CHANGED
@@ -32,12 +32,21 @@ export {
32
32
  // Components
33
33
  export { ToolCall } from "./ToolCall";
34
34
  export type { ToolCallProps } from "./ToolCall";
35
- export { Markdown, CodeBlock, InlineMath, BlockMath } from "./Markdown";
35
+ export {
36
+ Markdown,
37
+ CodeBlock,
38
+ InlineMath,
39
+ BlockMath,
40
+ externalLinkProps,
41
+ isExternalHref,
42
+ } from "./Markdown";
36
43
  export type {
37
44
  MarkdownProps,
38
45
  CodeBlockProps,
39
46
  InlineMathProps,
40
47
  BlockMathProps,
48
+ MarkdownLinkHandler,
49
+ MarkdownLinkRenderer,
41
50
  } from "./Markdown";
42
51
  export { MessageBubble } from "./MessageBubble";
43
52
  export type { MessageBubbleProps } from "./MessageBubble";
@@ -53,10 +62,7 @@ export type { ChatComposerProps } from "./ChatComposer";
53
62
  // Google & Gemini Desktop / Web benchmark components
54
63
  export { GoogleSpotlightBar } from "./GoogleSpotlightBar";
55
64
  export type { GoogleSpotlightBarProps } from "./GoogleSpotlightBar";
56
- export {
57
- GeminiAccountMenu,
58
- ComposerMoreUploadsMenu,
59
- } from "./GeminiAccountMenu";
65
+ export { GeminiAccountMenu, ComposerMoreUploadsMenu } from "./GeminiAccountMenu";
60
66
  export type {
61
67
  GeminiAccountMenuProps,
62
68
  ComposerMoreUploadsMenuProps,
@@ -65,7 +71,6 @@ export type {
65
71
  export { GeminiPersonalContextEdit } from "./GeminiPersonalContextEdit";
66
72
  export type { GeminiPersonalContextEditProps } from "./GeminiPersonalContextEdit";
67
73
 
68
-
69
74
  // Task components
70
75
  export { TaskStatusIcon } from "./TaskStatusIcon";
71
76
  export type { TaskStatusIconProps } from "./TaskStatusIcon";
@@ -105,3 +110,64 @@ export type { A2ATaskTimelineProps, TimelineStep } from "./A2ATaskTimeline";
105
110
  // MCP-UI Generative Card
106
111
  export { McpAppCard } from "./McpAppCard";
107
112
  export type { McpAppCardProps } from "./McpAppCard";
113
+
114
+ // Local chat: injectable labels, OpenAI-compatible streaming, conversations, grounding, memory
115
+ export {
116
+ chatLabelsEn,
117
+ chatLabelsFr,
118
+ chatLabelsJa,
119
+ chatLabelsByLang,
120
+ defaultChatLabels,
121
+ resolveChatLabels,
122
+ formatLabel,
123
+ } from "./chatLabels";
124
+ export type { ChatLabels } from "./chatLabels";
125
+ export {
126
+ ChatStreamError,
127
+ accumulateToolCall,
128
+ chatCompletionsUrl,
129
+ chatReducer,
130
+ createSseParser,
131
+ initialChatState,
132
+ isUnavailableStatus,
133
+ newMessageId,
134
+ parseChatCompletionChunk,
135
+ streamChatCompletion,
136
+ toOpenAIMessages,
137
+ } from "./openaiChatStream";
138
+ export type {
139
+ ChatAction,
140
+ ChatMessage,
141
+ ChatRole,
142
+ ChatState,
143
+ ChatStatus,
144
+ ChatStreamErrorKind,
145
+ ChatStreamEvent,
146
+ OpenAIToolCall,
147
+ StreamChatOptions,
148
+ } from "./openaiChatStream";
149
+ export { useOpenAIChat } from "./useOpenAIChat";
150
+ export type { UseOpenAIChatOptions, UseOpenAIChatResult } from "./useOpenAIChat";
151
+ export { ConversationList, filterConversations } from "./ConversationList";
152
+ export type { ConversationListProps, ConversationSummary } from "./ConversationList";
153
+ export { ModelUnavailable, clampProgress } from "./ModelUnavailable";
154
+ export type { ModelAvailabilityPhase, ModelUnavailableProps } from "./ModelUnavailable";
155
+ export {
156
+ CitationChip,
157
+ CitationList,
158
+ CitedMarkdown,
159
+ RefusalNotice,
160
+ citedNumbers,
161
+ linkCitationMarkers,
162
+ } from "./citations";
163
+ export type {
164
+ Citation,
165
+ CitationChipProps,
166
+ CitationListProps,
167
+ CitedMarkdownProps,
168
+ RefusalNoticeProps,
169
+ } from "./citations";
170
+ export { MemoryPanel } from "./MemoryPanel";
171
+ export type { MemoryItem, MemoryPanelProps } from "./MemoryPanel";
172
+ export { ChatIcon, chatIconNames } from "./chatIcons";
173
+ export type { ChatIconName, ChatIconProps } from "./chatIcons";