@aphrody/m3-ai 3.3.4 → 3.3.6
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 +22 -0
- package/package.json +4 -4
- package/src/A2APeerInspector.tsx +1 -9
- package/src/A2ATaskTimeline.tsx +1 -1
- package/src/AgUiEventStream.ts +9 -5
- package/src/ChatComposer.tsx +41 -19
- package/src/ConversationList.tsx +207 -0
- package/src/Markdown.tsx +89 -21
- package/src/MemoryPanel.tsx +176 -0
- package/src/MessageBubble.tsx +60 -12
- package/src/ModelSelector.tsx +36 -17
- package/src/ModelUnavailable.tsx +116 -0
- package/src/TaskRow.tsx +36 -36
- package/src/ToolCall.tsx +7 -2
- package/src/chatIcons.tsx +105 -0
- package/src/chatLabels.ts +243 -0
- package/src/citations.tsx +286 -0
- package/src/index.ts +72 -6
- package/src/openaiChatStream.ts +400 -0
- package/src/useOpenAIChat.ts +126 -0
package/src/Markdown.tsx
CHANGED
|
@@ -3,13 +3,15 @@
|
|
|
3
3
|
|
|
4
4
|
"use client";
|
|
5
5
|
|
|
6
|
-
import { Icon } from "@aphrody/m3-primitives";
|
|
7
6
|
import { clsx } from "clsx";
|
|
8
7
|
import { forwardRef, isValidElement, useEffect, useRef, useState, type ReactNode } from "react";
|
|
9
8
|
import ReactMarkdown, { type Components } from "react-markdown";
|
|
10
9
|
import rehypeRaw from "rehype-raw";
|
|
11
10
|
import remarkGfm from "remark-gfm";
|
|
12
11
|
|
|
12
|
+
import { type ChatLabels, resolveChatLabels } from "./chatLabels";
|
|
13
|
+
import { ChatIcon } from "./chatIcons";
|
|
14
|
+
|
|
13
15
|
type MarkdownSize = "small" | "medium" | "large";
|
|
14
16
|
|
|
15
17
|
interface MarkdownProps {
|
|
@@ -22,8 +24,41 @@ interface MarkdownProps {
|
|
|
22
24
|
* trusted content.
|
|
23
25
|
*/
|
|
24
26
|
allowHtml?: boolean;
|
|
27
|
+
/** Element overrides merged over the M3 renderers (e.g. citation links). */
|
|
28
|
+
components?: Components;
|
|
29
|
+
/**
|
|
30
|
+
* Called on every link activation. Call `event.preventDefault()` to handle it yourself (e.g.
|
|
31
|
+
* open the system browser from a Tauri webview).
|
|
32
|
+
*/
|
|
33
|
+
onLinkClick?: MarkdownLinkHandler;
|
|
34
|
+
/** Replaces the link element entirely. */
|
|
35
|
+
renderLink?: MarkdownLinkRenderer;
|
|
36
|
+
/** Visible strings of the code blocks; English defaults. */
|
|
37
|
+
labels?: Partial<ChatLabels>;
|
|
25
38
|
}
|
|
26
39
|
|
|
40
|
+
/** Link activation callback of {@link Markdown}. */
|
|
41
|
+
export type MarkdownLinkHandler = (
|
|
42
|
+
href: string,
|
|
43
|
+
event: React.MouseEvent<HTMLAnchorElement>,
|
|
44
|
+
) => void;
|
|
45
|
+
|
|
46
|
+
/** Custom link renderer of {@link Markdown}. */
|
|
47
|
+
export type MarkdownLinkRenderer = (link: {
|
|
48
|
+
href: string;
|
|
49
|
+
children: React.ReactNode;
|
|
50
|
+
external: boolean;
|
|
51
|
+
}) => React.ReactNode;
|
|
52
|
+
|
|
53
|
+
/** Whether `href` leaves the page: an absolute URL with a scheme, or protocol-relative. */
|
|
54
|
+
export function isExternalHref(href: string | undefined): boolean {
|
|
55
|
+
if (!href) return false;
|
|
56
|
+
return /^[a-z][a-z0-9+.-]*:/i.test(href) || href.startsWith("//");
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Safe defaults of an external link: a new browsing context without opener or referrer. */
|
|
60
|
+
export const externalLinkProps = { target: "_blank", rel: "noopener noreferrer" } as const;
|
|
61
|
+
|
|
27
62
|
const bodyClass: Record<MarkdownSize, string> = {
|
|
28
63
|
small: "text-body-small",
|
|
29
64
|
medium: "text-body-medium",
|
|
@@ -123,6 +158,8 @@ export interface CodeBlockProps extends Omit<React.HTMLAttributes<HTMLDivElement
|
|
|
123
158
|
language?: string;
|
|
124
159
|
onCopy?: (code: string) => void;
|
|
125
160
|
children?: ReactNode;
|
|
161
|
+
/** `copy`, `copyCode`, `copied`; English defaults. */
|
|
162
|
+
labels?: Partial<ChatLabels>;
|
|
126
163
|
}
|
|
127
164
|
|
|
128
165
|
export function CodeBlock({
|
|
@@ -131,8 +168,10 @@ export function CodeBlock({
|
|
|
131
168
|
onCopy,
|
|
132
169
|
className,
|
|
133
170
|
children,
|
|
171
|
+
labels,
|
|
134
172
|
...restProps
|
|
135
173
|
}: CodeBlockProps) {
|
|
174
|
+
const l = resolveChatLabels(labels);
|
|
136
175
|
// Strip node passed by ReactMarkdown to avoid rendering node="[object Object]" in DOM
|
|
137
176
|
const { node: _node, ...props } = restProps as Record<string, any>;
|
|
138
177
|
|
|
@@ -202,15 +241,15 @@ export function CodeBlock({
|
|
|
202
241
|
: "text-on-surface-variant hover:bg-on-surface/8 hover:text-on-surface active:bg-on-surface/12",
|
|
203
242
|
"focus-visible:outline-2 focus-visible:outline-primary",
|
|
204
243
|
)}
|
|
205
|
-
aria-label={copied ?
|
|
206
|
-
title={copied ?
|
|
244
|
+
aria-label={copied ? l.copied : l.copyCode}
|
|
245
|
+
title={copied ? l.copied : l.copyCode}
|
|
207
246
|
>
|
|
208
|
-
<
|
|
247
|
+
<ChatIcon
|
|
209
248
|
name={copied ? "check" : "content_copy"}
|
|
210
249
|
size={16}
|
|
211
250
|
className={copied ? "text-success" : undefined}
|
|
212
251
|
/>
|
|
213
|
-
<span>{copied ?
|
|
252
|
+
<span>{copied ? l.copied : l.copy}</span>
|
|
214
253
|
</button>
|
|
215
254
|
</div>
|
|
216
255
|
<pre className="overflow-x-auto p-3 font-code text-body-small text-on-surface bg-surface-container-highest [&_code]:bg-transparent [&_code]:p-0 [&_code]:text-[length:inherit]">
|
|
@@ -508,7 +547,13 @@ export function remarkMathPlugin() {
|
|
|
508
547
|
// Element overrides mapping markdown onto M3 typescale and color roles
|
|
509
548
|
// ---------------------------------------------------------------------------
|
|
510
549
|
|
|
511
|
-
|
|
550
|
+
interface LinkOptions {
|
|
551
|
+
onLinkClick?: MarkdownLinkHandler;
|
|
552
|
+
renderLink?: MarkdownLinkRenderer;
|
|
553
|
+
labels?: Partial<ChatLabels>;
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
function m3Components(size: MarkdownSize, link: LinkOptions = {}): Components {
|
|
512
557
|
const body = bodyClass[size];
|
|
513
558
|
return {
|
|
514
559
|
h1: ({ node: _node, className, children, ...props }) => (
|
|
@@ -562,18 +607,28 @@ function m3Components(size: MarkdownSize): Components {
|
|
|
562
607
|
p: ({ node: _node, className, ...props }) => (
|
|
563
608
|
<p className={clsx("my-2 first:mt-0 last:mb-0", body, className)} {...props} />
|
|
564
609
|
),
|
|
565
|
-
a: ({ node: _node, className, children, ...props }) =>
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
className
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
610
|
+
a: ({ node: _node, className, children, href, onClick, ...props }) => {
|
|
611
|
+
const external = isExternalHref(href);
|
|
612
|
+
if (link.renderLink) return <>{link.renderLink({ href: href ?? "", children, external })}</>;
|
|
613
|
+
return (
|
|
614
|
+
<a
|
|
615
|
+
className={clsx(
|
|
616
|
+
"text-primary underline underline-offset-2 hover:decoration-2",
|
|
617
|
+
"rounded-extra-small outline-none focus-visible:outline-3 focus-visible:outline-secondary focus-visible:outline-offset-2",
|
|
618
|
+
className,
|
|
619
|
+
)}
|
|
620
|
+
href={href}
|
|
621
|
+
{...(external ? externalLinkProps : {})}
|
|
622
|
+
onClick={(event) => {
|
|
623
|
+
onClick?.(event);
|
|
624
|
+
if (href) link.onLinkClick?.(href, event);
|
|
625
|
+
}}
|
|
626
|
+
{...props}
|
|
627
|
+
>
|
|
628
|
+
{children}
|
|
629
|
+
</a>
|
|
630
|
+
);
|
|
631
|
+
},
|
|
577
632
|
strong: ({ node: _node, className, ...props }) => (
|
|
578
633
|
<strong className={clsx("font-semibold text-on-surface", className)} {...props} />
|
|
579
634
|
),
|
|
@@ -611,7 +666,7 @@ function m3Components(size: MarkdownSize): Components {
|
|
|
611
666
|
/>
|
|
612
667
|
),
|
|
613
668
|
pre: ({ node: _node, children, className, style }) => (
|
|
614
|
-
<CodeBlock className={className} style={style}>
|
|
669
|
+
<CodeBlock className={className} style={style} labels={link.labels}>
|
|
615
670
|
{children}
|
|
616
671
|
</CodeBlock>
|
|
617
672
|
),
|
|
@@ -677,7 +732,20 @@ function m3Components(size: MarkdownSize): Components {
|
|
|
677
732
|
}
|
|
678
733
|
|
|
679
734
|
const Markdown = forwardRef<HTMLDivElement, MarkdownProps>(
|
|
680
|
-
(
|
|
735
|
+
(
|
|
736
|
+
{
|
|
737
|
+
content,
|
|
738
|
+
className,
|
|
739
|
+
size = "medium",
|
|
740
|
+
allowHtml = false,
|
|
741
|
+
components,
|
|
742
|
+
onLinkClick,
|
|
743
|
+
renderLink,
|
|
744
|
+
labels,
|
|
745
|
+
},
|
|
746
|
+
ref,
|
|
747
|
+
) => {
|
|
748
|
+
const base = m3Components(size, { onLinkClick, renderLink, labels });
|
|
681
749
|
return (
|
|
682
750
|
<div
|
|
683
751
|
ref={ref}
|
|
@@ -686,7 +754,7 @@ const Markdown = forwardRef<HTMLDivElement, MarkdownProps>(
|
|
|
686
754
|
<ReactMarkdown
|
|
687
755
|
remarkPlugins={[remarkGfm, remarkMathPlugin]}
|
|
688
756
|
rehypePlugins={allowHtml ? [rehypeRaw] : undefined}
|
|
689
|
-
components={
|
|
757
|
+
components={components ? { ...base, ...components } : base}
|
|
690
758
|
>
|
|
691
759
|
{content}
|
|
692
760
|
</ReactMarkdown>
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// Memory panel: the agent's saved memories as an M3 list, with per-item deletion and a
|
|
3
|
+
// "clear all" action guarded by an inline confirmation (or an injected one).
|
|
4
|
+
|
|
5
|
+
"use client";
|
|
6
|
+
|
|
7
|
+
import { CircleButton } from "@aphrody/m3-primitives";
|
|
8
|
+
import { clsx } from "clsx";
|
|
9
|
+
import { useMemo, useState } from "react";
|
|
10
|
+
|
|
11
|
+
import { type ChatLabels, resolveChatLabels } from "./chatLabels";
|
|
12
|
+
import { ChatIcon } from "./chatIcons";
|
|
13
|
+
import { CircularIndicator, focusRing } from "./internal";
|
|
14
|
+
|
|
15
|
+
export interface MemoryItem {
|
|
16
|
+
id: string;
|
|
17
|
+
content: string;
|
|
18
|
+
tags?: string[];
|
|
19
|
+
/** ISO string, epoch milliseconds or Date. */
|
|
20
|
+
createdAt: string | number | Date;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface MemoryPanelProps {
|
|
24
|
+
memories: readonly MemoryItem[];
|
|
25
|
+
loading?: boolean;
|
|
26
|
+
onDelete?: (id: string) => void;
|
|
27
|
+
/** Shows "clear all". */
|
|
28
|
+
onClear?: () => void;
|
|
29
|
+
/**
|
|
30
|
+
* Replaces the built-in inline confirmation of "clear all" (e.g. an app dialog):
|
|
31
|
+
* resolve `true` to clear.
|
|
32
|
+
*/
|
|
33
|
+
confirmClear?: () => boolean | Promise<boolean>;
|
|
34
|
+
labels?: Partial<ChatLabels>;
|
|
35
|
+
locale?: string;
|
|
36
|
+
formatDate?: (createdAt: Date) => string;
|
|
37
|
+
className?: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const textButton = clsx(
|
|
41
|
+
"inline-flex h-10 items-center rounded-full px-3 text-label-large",
|
|
42
|
+
"transition-colors duration-200 ease-standard",
|
|
43
|
+
focusRing,
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
export function MemoryPanel({
|
|
47
|
+
memories,
|
|
48
|
+
loading = false,
|
|
49
|
+
onDelete,
|
|
50
|
+
onClear,
|
|
51
|
+
confirmClear,
|
|
52
|
+
labels,
|
|
53
|
+
locale,
|
|
54
|
+
formatDate,
|
|
55
|
+
className,
|
|
56
|
+
}: MemoryPanelProps) {
|
|
57
|
+
const l = resolveChatLabels(labels);
|
|
58
|
+
const [confirming, setConfirming] = useState(false);
|
|
59
|
+
const dateFormat = useMemo(
|
|
60
|
+
() => new Intl.DateTimeFormat(locale, { dateStyle: "medium" }),
|
|
61
|
+
[locale],
|
|
62
|
+
);
|
|
63
|
+
const format = (value: string | number | Date) => {
|
|
64
|
+
const date = value instanceof Date ? value : new Date(value);
|
|
65
|
+
if (Number.isNaN(date.getTime())) return "";
|
|
66
|
+
return formatDate ? formatDate(date) : dateFormat.format(date);
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
const requestClear = async () => {
|
|
70
|
+
if (!onClear) return;
|
|
71
|
+
if (confirmClear) {
|
|
72
|
+
if (await confirmClear()) onClear();
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
setConfirming(true);
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
return (
|
|
79
|
+
<section
|
|
80
|
+
aria-label={l.memory}
|
|
81
|
+
aria-busy={loading || undefined}
|
|
82
|
+
className={clsx(
|
|
83
|
+
"m3-memory-panel flex min-h-0 flex-col gap-2 rounded-large bg-surface-container-low py-3 text-on-surface",
|
|
84
|
+
className,
|
|
85
|
+
)}
|
|
86
|
+
>
|
|
87
|
+
<header className="flex items-center justify-between gap-2 px-4">
|
|
88
|
+
<h2 className="m-0 text-title-medium">{l.memory}</h2>
|
|
89
|
+
{onClear && memories.length > 0 && !confirming ? (
|
|
90
|
+
<button
|
|
91
|
+
type="button"
|
|
92
|
+
onClick={() => void requestClear()}
|
|
93
|
+
className={clsx(textButton, "text-error hover:bg-error/8")}
|
|
94
|
+
>
|
|
95
|
+
{l.clearMemory}
|
|
96
|
+
</button>
|
|
97
|
+
) : null}
|
|
98
|
+
</header>
|
|
99
|
+
{confirming ? (
|
|
100
|
+
<div
|
|
101
|
+
role="alertdialog"
|
|
102
|
+
aria-label={l.clearMemory}
|
|
103
|
+
className="mx-4 flex flex-col gap-2 rounded-medium bg-error-container px-4 py-3 text-on-error-container"
|
|
104
|
+
>
|
|
105
|
+
<p className="m-0 text-body-medium">{l.clearMemoryConfirm}</p>
|
|
106
|
+
<div className="flex justify-end gap-2">
|
|
107
|
+
<button
|
|
108
|
+
type="button"
|
|
109
|
+
onClick={() => setConfirming(false)}
|
|
110
|
+
className={clsx(textButton, "hover:bg-on-error-container/8")}
|
|
111
|
+
>
|
|
112
|
+
{l.cancel}
|
|
113
|
+
</button>
|
|
114
|
+
<button
|
|
115
|
+
type="button"
|
|
116
|
+
onClick={() => {
|
|
117
|
+
setConfirming(false);
|
|
118
|
+
onClear?.();
|
|
119
|
+
}}
|
|
120
|
+
className={clsx(textButton, "bg-error px-4 text-on-error hover:bg-error/92")}
|
|
121
|
+
>
|
|
122
|
+
{l.confirm}
|
|
123
|
+
</button>
|
|
124
|
+
</div>
|
|
125
|
+
</div>
|
|
126
|
+
) : null}
|
|
127
|
+
{loading ? (
|
|
128
|
+
<div className="flex justify-center py-6">
|
|
129
|
+
<CircularIndicator size={24} label={l.memoryLoading} />
|
|
130
|
+
</div>
|
|
131
|
+
) : memories.length === 0 ? (
|
|
132
|
+
<p className="m-0 px-4 py-6 text-center text-body-medium text-on-surface-variant">
|
|
133
|
+
{l.memoryEmpty}
|
|
134
|
+
</p>
|
|
135
|
+
) : (
|
|
136
|
+
<ul className="m-0 flex min-h-0 list-none flex-col overflow-y-auto p-0">
|
|
137
|
+
{memories.map((memory) => (
|
|
138
|
+
<li
|
|
139
|
+
key={memory.id}
|
|
140
|
+
className="group flex items-start gap-3 px-4 py-2 transition-colors duration-200 ease-standard hover:bg-on-surface/8"
|
|
141
|
+
>
|
|
142
|
+
<div className="min-w-0 flex-1">
|
|
143
|
+
<p className="m-0 text-body-medium break-words whitespace-pre-wrap text-on-surface">
|
|
144
|
+
{memory.content}
|
|
145
|
+
</p>
|
|
146
|
+
<div className="mt-1 flex flex-wrap items-center gap-1.5">
|
|
147
|
+
<time className="text-label-small text-on-surface-variant">
|
|
148
|
+
{format(memory.createdAt)}
|
|
149
|
+
</time>
|
|
150
|
+
{memory.tags?.map((tag) => (
|
|
151
|
+
<span
|
|
152
|
+
key={tag}
|
|
153
|
+
className="inline-flex h-6 items-center rounded-small border border-outline-variant px-2 text-label-small text-on-surface-variant"
|
|
154
|
+
>
|
|
155
|
+
{tag}
|
|
156
|
+
</span>
|
|
157
|
+
))}
|
|
158
|
+
</div>
|
|
159
|
+
</div>
|
|
160
|
+
{onDelete ? (
|
|
161
|
+
<CircleButton
|
|
162
|
+
icon={<ChatIcon name="delete" size={18} />}
|
|
163
|
+
variant="standard"
|
|
164
|
+
size="sm"
|
|
165
|
+
onClick={() => onDelete(memory.id)}
|
|
166
|
+
aria-label={l.deleteMemory}
|
|
167
|
+
title={l.deleteMemory}
|
|
168
|
+
/>
|
|
169
|
+
) : null}
|
|
170
|
+
</li>
|
|
171
|
+
))}
|
|
172
|
+
</ul>
|
|
173
|
+
)}
|
|
174
|
+
</section>
|
|
175
|
+
);
|
|
176
|
+
}
|
package/src/MessageBubble.tsx
CHANGED
|
@@ -6,7 +6,10 @@
|
|
|
6
6
|
import { CircleButton } from "@aphrody/m3-primitives";
|
|
7
7
|
import { clsx } from "clsx";
|
|
8
8
|
|
|
9
|
-
import {
|
|
9
|
+
import { type ChatLabels, resolveChatLabels } from "./chatLabels";
|
|
10
|
+
import { type Citation, CitationList, CitedMarkdown, RefusalNotice } from "./citations";
|
|
11
|
+
import { ChatIcon } from "./chatIcons";
|
|
12
|
+
import { Markdown, type MarkdownLinkHandler } from "./Markdown";
|
|
10
13
|
|
|
11
14
|
export interface MessageBubbleProps {
|
|
12
15
|
content: string;
|
|
@@ -17,6 +20,15 @@ export interface MessageBubbleProps {
|
|
|
17
20
|
onFork?: () => void;
|
|
18
21
|
variant?: "default" | "codex";
|
|
19
22
|
className?: string;
|
|
23
|
+
/** Visible strings (aria-labels, titles); English defaults. */
|
|
24
|
+
labels?: Partial<ChatLabels>;
|
|
25
|
+
/** Grounded answer: `[n]` markers become citation chips and the sources are listed below. */
|
|
26
|
+
citations?: readonly Citation[];
|
|
27
|
+
/** The backend refused to answer: a refusal notice replaces an empty answer. */
|
|
28
|
+
refusal?: boolean;
|
|
29
|
+
onCitationSelect?: (citation: Citation) => void;
|
|
30
|
+
/** Link activation in the answer (e.g. open the system browser from Tauri). */
|
|
31
|
+
onLinkClick?: MarkdownLinkHandler;
|
|
20
32
|
}
|
|
21
33
|
|
|
22
34
|
/**
|
|
@@ -32,12 +44,23 @@ export function MessageBubble({
|
|
|
32
44
|
onFork,
|
|
33
45
|
variant = "default",
|
|
34
46
|
className,
|
|
47
|
+
labels,
|
|
48
|
+
citations,
|
|
49
|
+
refusal = false,
|
|
50
|
+
onCitationSelect,
|
|
51
|
+
onLinkClick,
|
|
35
52
|
}: MessageBubbleProps) {
|
|
36
53
|
const isCodex = variant === "codex";
|
|
54
|
+
const l = resolveChatLabels(labels);
|
|
55
|
+
const cited = !isUser && citations && citations.length > 0 ? citations : null;
|
|
37
56
|
|
|
38
57
|
return (
|
|
39
58
|
<div
|
|
40
|
-
className={clsx(
|
|
59
|
+
className={clsx(
|
|
60
|
+
"group flex flex-col py-1.5 w-full",
|
|
61
|
+
isUser ? "items-end" : "items-start",
|
|
62
|
+
className,
|
|
63
|
+
)}
|
|
41
64
|
>
|
|
42
65
|
<div
|
|
43
66
|
className={clsx(
|
|
@@ -53,39 +76,64 @@ export function MessageBubble({
|
|
|
53
76
|
{isUser ? (
|
|
54
77
|
<div className="whitespace-pre-wrap break-words">{content}</div>
|
|
55
78
|
) : (
|
|
56
|
-
|
|
79
|
+
<>
|
|
80
|
+
{refusal && !content.trim() ? (
|
|
81
|
+
<RefusalNotice labels={labels} />
|
|
82
|
+
) : cited ? (
|
|
83
|
+
<CitedMarkdown
|
|
84
|
+
content={content}
|
|
85
|
+
citations={cited}
|
|
86
|
+
labels={labels}
|
|
87
|
+
onCitationSelect={onCitationSelect}
|
|
88
|
+
onLinkClick={onLinkClick}
|
|
89
|
+
className="break-words font-sans"
|
|
90
|
+
/>
|
|
91
|
+
) : (
|
|
92
|
+
<Markdown
|
|
93
|
+
content={content}
|
|
94
|
+
size="large"
|
|
95
|
+
labels={labels}
|
|
96
|
+
onLinkClick={onLinkClick}
|
|
97
|
+
className="break-words font-sans"
|
|
98
|
+
/>
|
|
99
|
+
)}
|
|
100
|
+
{refusal && content.trim() ? <RefusalNotice labels={labels} className="mt-2" /> : null}
|
|
101
|
+
{cited && !isStreaming ? (
|
|
102
|
+
<CitationList citations={cited} labels={labels} onSelect={onCitationSelect} />
|
|
103
|
+
) : null}
|
|
104
|
+
</>
|
|
57
105
|
)}
|
|
58
106
|
</div>
|
|
59
107
|
{!isUser && (onCopy || onDislike || onFork) ? (
|
|
60
108
|
<div className="mt-1.5 flex items-center gap-1 opacity-0 transition-opacity duration-200 ease-standard group-focus-within:opacity-100 group-hover:opacity-100">
|
|
61
109
|
{onCopy && (
|
|
62
110
|
<CircleButton
|
|
63
|
-
icon="content_copy"
|
|
111
|
+
icon={<ChatIcon name="content_copy" size={18} />}
|
|
64
112
|
variant="standard"
|
|
65
113
|
size="sm"
|
|
66
114
|
onClick={() => onCopy(content)}
|
|
67
|
-
aria-label={isStreaming ?
|
|
68
|
-
title={isStreaming ?
|
|
115
|
+
aria-label={isStreaming ? l.copyStreamingMessage : l.copyMessage}
|
|
116
|
+
title={isStreaming ? l.copyStreamingMessage : l.copyMessage}
|
|
69
117
|
/>
|
|
70
118
|
)}
|
|
71
119
|
{onDislike && (
|
|
72
120
|
<CircleButton
|
|
73
|
-
icon="thumb_down"
|
|
121
|
+
icon={<ChatIcon name="thumb_down" size={18} />}
|
|
74
122
|
variant="standard"
|
|
75
123
|
size="sm"
|
|
76
124
|
onClick={() => onDislike(content)}
|
|
77
|
-
aria-label=
|
|
78
|
-
title=
|
|
125
|
+
aria-label={l.dislike}
|
|
126
|
+
title={l.dislike}
|
|
79
127
|
/>
|
|
80
128
|
)}
|
|
81
129
|
{onFork && (
|
|
82
130
|
<CircleButton
|
|
83
|
-
icon="call_split"
|
|
131
|
+
icon={<ChatIcon name="call_split" size={18} />}
|
|
84
132
|
variant="standard"
|
|
85
133
|
size="sm"
|
|
86
134
|
onClick={onFork}
|
|
87
|
-
aria-label=
|
|
88
|
-
title=
|
|
135
|
+
aria-label={l.fork}
|
|
136
|
+
title={l.fork}
|
|
89
137
|
/>
|
|
90
138
|
)}
|
|
91
139
|
</div>
|
package/src/ModelSelector.tsx
CHANGED
|
@@ -3,16 +3,12 @@
|
|
|
3
3
|
|
|
4
4
|
"use client";
|
|
5
5
|
|
|
6
|
-
import {
|
|
7
|
-
Icon,
|
|
8
|
-
Popover,
|
|
9
|
-
usePopover,
|
|
10
|
-
SelectPill,
|
|
11
|
-
type SelectPillProps,
|
|
12
|
-
} from "@aphrody/m3-primitives";
|
|
6
|
+
import { Popover, usePopover, SelectPill, type SelectPillProps } from "@aphrody/m3-primitives";
|
|
13
7
|
import { clsx } from "clsx";
|
|
14
8
|
import { forwardRef, useEffect, useMemo, useRef, useState } from "react";
|
|
15
9
|
|
|
10
|
+
import { type ChatLabels, formatLabel, resolveChatLabels } from "./chatLabels";
|
|
11
|
+
import { ChatIcon } from "./chatIcons";
|
|
16
12
|
import { focusRing } from "./internal";
|
|
17
13
|
import type { ModelOption } from "./types";
|
|
18
14
|
|
|
@@ -37,6 +33,8 @@ export interface ModelSelectorProps {
|
|
|
37
33
|
className?: string;
|
|
38
34
|
/** Disabled state */
|
|
39
35
|
disabled?: boolean;
|
|
36
|
+
/** Visible strings; `placeholder` / `searchPlaceholder` win over them. English defaults. */
|
|
37
|
+
labels?: Partial<ChatLabels>;
|
|
40
38
|
}
|
|
41
39
|
|
|
42
40
|
/** Group models by provider, keeping the order of first appearance. */
|
|
@@ -67,6 +65,7 @@ export interface ModelOptionListProps {
|
|
|
67
65
|
/** Called when a model is picked */
|
|
68
66
|
onSelect: (value: string) => void;
|
|
69
67
|
className?: string;
|
|
68
|
+
labels?: Partial<ChatLabels>;
|
|
70
69
|
}
|
|
71
70
|
|
|
72
71
|
/**
|
|
@@ -74,7 +73,14 @@ export interface ModelOptionListProps {
|
|
|
74
73
|
* 48 dp two-line items (`text-label-large` + `text-body-small` supporting text), the selected
|
|
75
74
|
* item on `secondary-container`, capabilities as small outlined chips.
|
|
76
75
|
*/
|
|
77
|
-
export function ModelOptionList({
|
|
76
|
+
export function ModelOptionList({
|
|
77
|
+
grouped,
|
|
78
|
+
value,
|
|
79
|
+
onSelect,
|
|
80
|
+
className,
|
|
81
|
+
labels,
|
|
82
|
+
}: ModelOptionListProps) {
|
|
83
|
+
const l = resolveChatLabels(labels);
|
|
78
84
|
const providers = Object.entries(grouped);
|
|
79
85
|
const models = providers.flatMap(([, providerModels]) => providerModels);
|
|
80
86
|
const [focusedId, setFocusedId] = useState(value);
|
|
@@ -85,7 +91,7 @@ export function ModelOptionList({ grouped, value, onSelect, className }: ModelOp
|
|
|
85
91
|
<div
|
|
86
92
|
// oxlint-disable-next-line jsx-a11y/prefer-tag-over-role -- M3 menu whose options are rich rows (headers, supporting text, chips); a native <select>/<datalist> cannot render them.
|
|
87
93
|
role="listbox"
|
|
88
|
-
aria-label=
|
|
94
|
+
aria-label={l.models}
|
|
89
95
|
tabIndex={-1}
|
|
90
96
|
onKeyDown={(event) => {
|
|
91
97
|
if (event.defaultPrevented) return;
|
|
@@ -158,7 +164,9 @@ export function ModelOptionList({ grouped, value, onSelect, className }: ModelOp
|
|
|
158
164
|
selected ? "text-on-secondary-container" : "text-on-surface-variant",
|
|
159
165
|
)}
|
|
160
166
|
>
|
|
161
|
-
{
|
|
167
|
+
{formatLabel(l.contextTokens, {
|
|
168
|
+
count: model.context_window.toLocaleString(),
|
|
169
|
+
})}
|
|
162
170
|
</span>
|
|
163
171
|
) : null}
|
|
164
172
|
{model.capabilities?.map((capability) => (
|
|
@@ -175,7 +183,7 @@ export function ModelOptionList({ grouped, value, onSelect, className }: ModelOp
|
|
|
175
183
|
</div>
|
|
176
184
|
) : null}
|
|
177
185
|
</div>
|
|
178
|
-
{selected && <
|
|
186
|
+
{selected && <ChatIcon name="check" size={20} />}
|
|
179
187
|
</button>
|
|
180
188
|
);
|
|
181
189
|
})}
|
|
@@ -183,7 +191,7 @@ export function ModelOptionList({ grouped, value, onSelect, className }: ModelOp
|
|
|
183
191
|
))}
|
|
184
192
|
{providers.length === 0 && (
|
|
185
193
|
<div className="px-3 py-3 text-center text-body-small text-on-surface-variant">
|
|
186
|
-
|
|
194
|
+
{l.noModels}
|
|
187
195
|
</div>
|
|
188
196
|
)}
|
|
189
197
|
</div>
|
|
@@ -212,7 +220,7 @@ function ModelSearchField({
|
|
|
212
220
|
return (
|
|
213
221
|
<div className="px-2 pb-2">
|
|
214
222
|
<label className="flex h-10 items-center gap-2 rounded-full bg-surface-container-high px-3 text-on-surface-variant focus-within:bg-surface-container-highest">
|
|
215
|
-
<
|
|
223
|
+
<ChatIcon name="search" size={20} />
|
|
216
224
|
<input
|
|
217
225
|
ref={inputRef}
|
|
218
226
|
value={value}
|
|
@@ -232,16 +240,18 @@ export const ModelSelector = forwardRef<HTMLButtonElement, ModelSelectorProps>(
|
|
|
232
240
|
models,
|
|
233
241
|
value,
|
|
234
242
|
onChange,
|
|
235
|
-
placeholder
|
|
236
|
-
searchPlaceholder
|
|
243
|
+
placeholder,
|
|
244
|
+
searchPlaceholder,
|
|
237
245
|
variant,
|
|
238
246
|
size,
|
|
239
247
|
popover: externalPopover,
|
|
240
248
|
className,
|
|
241
249
|
disabled,
|
|
250
|
+
labels,
|
|
242
251
|
},
|
|
243
252
|
ref,
|
|
244
253
|
) => {
|
|
254
|
+
const l = resolveChatLabels(labels);
|
|
245
255
|
const internalPopover = usePopover();
|
|
246
256
|
const popover = externalPopover ?? internalPopover;
|
|
247
257
|
const [search, setSearch] = useState("");
|
|
@@ -265,9 +275,13 @@ export const ModelSelector = forwardRef<HTMLButtonElement, ModelSelectorProps>(
|
|
|
265
275
|
size={size}
|
|
266
276
|
disabled={disabled}
|
|
267
277
|
aria-haspopup="listbox"
|
|
278
|
+
hideCaret
|
|
268
279
|
className={clsx("w-full", className)}
|
|
269
280
|
>
|
|
270
|
-
|
|
281
|
+
<span className="min-w-0 flex-1 truncate">
|
|
282
|
+
{selectedModel?.name ?? placeholder ?? l.selectModel}
|
|
283
|
+
</span>
|
|
284
|
+
<ChatIcon name="expand_more" size={18} />
|
|
271
285
|
</SelectPill>
|
|
272
286
|
</Popover.Trigger>
|
|
273
287
|
<Popover.Content
|
|
@@ -277,13 +291,18 @@ export const ModelSelector = forwardRef<HTMLButtonElement, ModelSelectorProps>(
|
|
|
277
291
|
className="overflow-hidden rounded-extra-small! py-2"
|
|
278
292
|
>
|
|
279
293
|
{/* Search */}
|
|
280
|
-
<ModelSearchField
|
|
294
|
+
<ModelSearchField
|
|
295
|
+
value={search}
|
|
296
|
+
onChange={setSearch}
|
|
297
|
+
placeholder={searchPlaceholder ?? l.searchModels}
|
|
298
|
+
/>
|
|
281
299
|
|
|
282
300
|
{/* Grouped results */}
|
|
283
301
|
<div className="max-h-[320px] overflow-y-auto">
|
|
284
302
|
<ModelOptionList
|
|
285
303
|
grouped={grouped}
|
|
286
304
|
value={value}
|
|
305
|
+
labels={labels}
|
|
287
306
|
onSelect={(id) => {
|
|
288
307
|
onChange(id);
|
|
289
308
|
popover.setOpen(false);
|