@morlay/dsh-client-ui-primitives 0.0.7 → 0.0.9
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 +32 -9
- package/dist/client-DYp99CgT.mjs +3730 -0
- package/dist/client-uyVkC7mu.cjs +17197 -0
- package/dist/client.cjs +54 -0
- package/dist/client.d.cts +4 -0
- package/dist/client.d.mts +3 -11680
- package/dist/client.mjs +3 -4575
- package/dist/index-AMHtcyMt.d.mts +647 -0
- package/dist/index.cjs +54 -0
- package/dist/index.d.mts +4 -5
- package/dist/index.mjs +4 -5
- package/dist/reference-hchCWm3z.cjs +482 -0
- package/dist/reference.cjs +4 -0
- package/dist/reference.d.mts +3 -0
- package/dist/reference.mjs +1 -0
- package/package.json +31 -35
- package/src/client/controls/Bubble.module.css +17 -0
- package/src/client/controls/Bubble.tsx +17 -0
- package/src/client/controls/Button.module.css +10 -0
- package/src/client/controls/Button.tsx +29 -0
- package/src/client/controls/DisclosureRow.module.css +22 -0
- package/src/client/controls/DisclosureRow.tsx +20 -0
- package/src/client/controls/IconButton.module.css +50 -0
- package/src/client/controls/IconButton.tsx +47 -0
- package/src/client/controls/ModelRouteList.module.css +100 -0
- package/src/client/controls/ModelRouteList.tsx +118 -0
- package/src/client/controls/MultilineField.module.css +26 -0
- package/src/client/controls/MultilineField.tsx +19 -0
- package/src/client/controls/SearchSelect.module.css +34 -0
- package/src/client/controls/SearchSelect.tsx +138 -0
- package/src/client/controls/SeatButton.module.css +53 -0
- package/src/client/controls/SeatButton.tsx +39 -0
- package/src/client/controls/Spinner.module.css +18 -0
- package/src/client/controls/Spinner.tsx +14 -0
- package/src/client/controls/TagInput.module.css +105 -0
- package/src/client/controls/TagInput.tsx +152 -0
- package/src/client/controls/TimeLabel.module.css +16 -0
- package/src/client/controls/TimeLabel.tsx +15 -0
- package/src/client/controls/VisuallyHidden.module.css +9 -0
- package/src/client/controls/VisuallyHidden.tsx +16 -0
- package/src/client/controls/classes.ts +5 -0
- package/src/client/controls/index.ts +24 -0
- package/src/client/controls/tags.ts +24 -0
- package/src/client/index.ts +16 -2
- package/src/client/layout/Row.tsx +89 -0
- package/src/client/layout/Stack.tsx +82 -0
- package/src/client/layout/Text.tsx +61 -0
- package/src/client/layout/index.ts +16 -0
- package/src/client/layout/layout.module.css +217 -0
- package/src/client/layout/scales.ts +91 -0
- package/src/client/schema-form/Editor.tsx +65 -65
- package/src/client/schema-form/SchemaForm.tsx +9 -4
- package/src/client/schema-form/controller.ts +5 -2
- package/src/client/schema-form/geometry.ts +3 -0
- package/src/client/schema-form/index.ts +5 -0
- package/src/client/schema-form/schema-form.module.css +335 -0
- package/src/client/schema-form/testing/fake-scope.ts +0 -1
- package/src/client/schema-form/value.tsx +15 -9
- package/src/client/settings-form/SettingsFieldRow.module.css +93 -0
- package/src/client/settings-form/SettingsFieldRow.tsx +105 -0
- package/src/client/settings-form/index.ts +5 -18
- package/src/css-modules.d.ts +6 -0
- package/src/index.ts +6 -2
- package/src/reference-markdown.tsx +2 -6
- package/dist/rolldown-runtime-EAsCxrDo.mjs +0 -14
- package/locale/en.json +0 -6
- package/locale/zh.json +0 -6
- package/src/client/schema-form/styles.ts +0 -321
- package/src/client/settings-form/SettingsForm.styles.ts +0 -58
- package/src/client/settings-form/SettingsForm.tsx +0 -68
- package/src/client/settings-form/fields.styles.ts +0 -158
- package/src/client/settings-form/fields.tsx +0 -184
- package/src/client/settings-form/form-model.ts +0 -339
- package/src/client/styling/css.ts +0 -27
- package/src/client/styling/focus.ts +0 -9
- package/src/client/styling/index.ts +0 -6
- package/src/client/styling/styled.tsx +0 -54
- package/src/client/styling/styling.ts +0 -142
- package/src/client/styling/token.ts +0 -275
- package/src/client/styling/toolkit.ts +0 -37
- package/src/client/theme.generated.ts +0 -846
- package/src/client/theme.ts +0 -5
- /package/src/{client/markdown-text.ts → markdown-text.ts} +0 -0
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/* 对话气泡:照上游 `ui-chat/chat/MessageItem.module.css` 的 `.bubble` 逐字(含那两条决定换行行为的声明——
|
|
2
|
+
投影出来的用户文本是 inline runs,气泡自己负责折行,装饰过的单行消息因此仍是一行)。 */
|
|
3
|
+
.bubble {
|
|
4
|
+
/* 上游以宽度轴的比例表达 Figma 那个 525px 上限(525/748 ≈ 0.702):气泡跟着可拖拽/自适应的列一起变宽,
|
|
5
|
+
百分比让窄窗口仍然成立。 */
|
|
6
|
+
max-width: 100%;
|
|
7
|
+
background: var(--dsw-specific-bubble);
|
|
8
|
+
border-radius: var(--dsw-radius-xl);
|
|
9
|
+
/* 默认字号下单行气泡 42px:22 行高 + 上下各 10 内距;字号偏好经 body 发布的轴同时移动字号与行高。 */
|
|
10
|
+
padding: 10px 16px;
|
|
11
|
+
font-size: var(--dsh-content-font-size, 14px);
|
|
12
|
+
line-height: calc(22px + var(--dsh-content-font-delta, 0px));
|
|
13
|
+
color: var(--dsw-alias-label-primary);
|
|
14
|
+
/* 折行归气泡:不加这两条,投影出来的 runs 会按 `white-space: normal` 折叠空白,与上游对不上。 */
|
|
15
|
+
white-space: pre-wrap;
|
|
16
|
+
word-break: break-word;
|
|
17
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// 对话气泡:一条消息的内容块(底色 + 大圆角 + 内距 + 自己的最大宽度)。
|
|
2
|
+
//
|
|
3
|
+
// 宽度上限(`min(525px, 82%)`)归它:消息列多宽是对话面的口径,不该让每个调用方各写一遍。
|
|
4
|
+
|
|
5
|
+
import type { HTMLAttributes, ReactNode } from "react";
|
|
6
|
+
import { classes } from "./classes.ts";
|
|
7
|
+
import css from "./Bubble.module.css";
|
|
8
|
+
|
|
9
|
+
export type BubbleProps = HTMLAttributes<HTMLDivElement>;
|
|
10
|
+
|
|
11
|
+
export function Bubble({ className, children, ...rest }: BubbleProps): ReactNode {
|
|
12
|
+
return (
|
|
13
|
+
<div className={classes(css.bubble, className)} {...rest}>
|
|
14
|
+
{children}
|
|
15
|
+
</div>
|
|
16
|
+
);
|
|
17
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// 按钮:官方 `Button` + 两条本包统一的修饰——**文字不换行**(按钮宽度由内容定,短词被折成两行只是没必要的
|
|
2
|
+
// 换行)、`tone="danger"` 时把填充换成错误色(危险动作的配色只在这里出现,入口保持中性)。
|
|
3
|
+
// 业务从本包取到的就是这个包装版(`../index.ts` 里显式导出它,覆盖官方同名的那份)。
|
|
4
|
+
|
|
5
|
+
import { forwardRef, type ComponentProps, type ReactNode } from "react";
|
|
6
|
+
import { Button as OfficialButton } from "@deepseek-ai/dsh-client-ui-primitives";
|
|
7
|
+
import { classes } from "./classes.ts";
|
|
8
|
+
import css from "./Button.module.css";
|
|
9
|
+
|
|
10
|
+
export type ButtonTone = "danger";
|
|
11
|
+
|
|
12
|
+
export type ButtonProps = ComponentProps<typeof OfficialButton> & {
|
|
13
|
+
// 配色档:缺省用官方那套;`danger` 换成错误色。
|
|
14
|
+
tone?: ButtonTone;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button(
|
|
18
|
+
{ className, tone, ...rest },
|
|
19
|
+
ref,
|
|
20
|
+
): ReactNode {
|
|
21
|
+
return (
|
|
22
|
+
<OfficialButton
|
|
23
|
+
ref={ref}
|
|
24
|
+
data-role="button"
|
|
25
|
+
className={classes(className, css.label, tone === "danger" ? css.danger : undefined)}
|
|
26
|
+
{...rest}
|
|
27
|
+
/>
|
|
28
|
+
);
|
|
29
|
+
});
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/* 折叠行的两个覆盖类——官方 `DisclosureRow` 的 `contentClassName` / `contentLayoutClassName` 名字容易读错,
|
|
2
|
+
它们不是"内容区布局",而是**标题那一块**的类:
|
|
3
|
+
|
|
4
|
+
- `contentClassName` 落在 `TextShimmer` 的 `.root` 上(上游是 `display: inline-grid; width: max-content;
|
|
5
|
+
max-width: 100%`)。这里只加 flex 属性把它撑开(`flex: 1 1 auto` 不会覆盖 `display`),让右侧那一组
|
|
6
|
+
(摘要 / 删除入口)能用 `margin-left: auto` 贴到行尾。
|
|
7
|
+
- `contentLayoutClassName` 落在内层 `.content` 上(上游默认就是 `display: flex; align-items: center;
|
|
8
|
+
min-width: 0`):补上标题与右侧内容之间的间距,其余保持上游口径。
|
|
9
|
+
|
|
10
|
+
注意别往这两条里塞"卡片头布局"——它们只作用于标题文字那一层。 */
|
|
11
|
+
.content {
|
|
12
|
+
flex: 1 1 auto;
|
|
13
|
+
min-width: 0;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
.contentLayout {
|
|
17
|
+
display: flex;
|
|
18
|
+
align-items: center;
|
|
19
|
+
gap: 8px;
|
|
20
|
+
width: 100%;
|
|
21
|
+
min-width: 0;
|
|
22
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// 折叠行:官方 `DisclosureRow` + 两条本包固定的布局类(内容区撑开并允许收缩、标题行左右分列)。
|
|
2
|
+
//
|
|
3
|
+
// 官方那份把这两个类留成 `contentClassName` / `contentLayoutClassName` 供消费方覆盖——设置面上每次都要同样
|
|
4
|
+
// 两条,所以在这里定下来:业务只传标题、摘要与展开状态,不再传类。
|
|
5
|
+
|
|
6
|
+
import type { ComponentProps, ReactNode } from "react";
|
|
7
|
+
import { DisclosureRow as OfficialDisclosureRow } from "@deepseek-ai/dsh-client-ui-primitives";
|
|
8
|
+
import css from "./DisclosureRow.module.css";
|
|
9
|
+
|
|
10
|
+
export type DisclosureRowProps = ComponentProps<typeof OfficialDisclosureRow>;
|
|
11
|
+
|
|
12
|
+
export function DisclosureRow(props: DisclosureRowProps): ReactNode {
|
|
13
|
+
return (
|
|
14
|
+
<OfficialDisclosureRow
|
|
15
|
+
contentClassName={css.content}
|
|
16
|
+
contentLayoutClassName={css.contentLayout}
|
|
17
|
+
{...props}
|
|
18
|
+
/>
|
|
19
|
+
);
|
|
20
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/* 方形图标按钮:没有文字、没有边框,hover 才上底色(官方侧边栏那种 28×28 几何)。 */
|
|
2
|
+
.iconButton {
|
|
3
|
+
box-sizing: border-box;
|
|
4
|
+
display: inline-flex;
|
|
5
|
+
align-items: center;
|
|
6
|
+
justify-content: center;
|
|
7
|
+
flex: none;
|
|
8
|
+
width: 28px;
|
|
9
|
+
height: 28px;
|
|
10
|
+
padding: 0;
|
|
11
|
+
color: var(--dsw-alias-label-secondary);
|
|
12
|
+
background: transparent;
|
|
13
|
+
border: none;
|
|
14
|
+
border-radius: var(--dsw-radius-sm);
|
|
15
|
+
cursor: pointer;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
.iconButton:hover:not(:disabled) {
|
|
19
|
+
background: var(--dsw-alias-interactive-bg-hover);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
.iconButton:disabled {
|
|
23
|
+
cursor: not-allowed;
|
|
24
|
+
opacity: 0.4;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
.iconButton:focus-visible {
|
|
28
|
+
outline: var(--dsw-focus-ring-width) solid
|
|
29
|
+
var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary));
|
|
30
|
+
outline-offset: -2px;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/* 行内小档:24px 高的行(上游折叠行的 `.row`)里放 28px 会顶出这一行。 */
|
|
34
|
+
.sm {
|
|
35
|
+
width: 20px;
|
|
36
|
+
height: 20px;
|
|
37
|
+
padding: 0;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/* 圆形那一档:对话动作行的 28×28 圆键(hover 才上底色,颜色走三级文本色)。 */
|
|
41
|
+
.circle {
|
|
42
|
+
padding: 6px;
|
|
43
|
+
border-radius: 28px;
|
|
44
|
+
color: var(--dsw-alias-label-tertiary);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
.circle:hover:not(:disabled) {
|
|
48
|
+
background: var(--dsw-alias-interactive-bg-hover);
|
|
49
|
+
color: var(--dsw-alias-label-secondary);
|
|
50
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// 图标按钮:没有文字、没有边框的方形按钮(官方侧边栏那种几何)。`label` 是它唯一的无障碍名。
|
|
2
|
+
|
|
3
|
+
import type { ButtonHTMLAttributes, ReactNode } from "react";
|
|
4
|
+
import { classes } from "./classes.ts";
|
|
5
|
+
import css from "./IconButton.module.css";
|
|
6
|
+
|
|
7
|
+
export interface IconButtonProps extends Omit<
|
|
8
|
+
ButtonHTMLAttributes<HTMLButtonElement>,
|
|
9
|
+
"children" | "aria-label"
|
|
10
|
+
> {
|
|
11
|
+
// 无障碍名(图标没有文字,这个名字是它唯一的标识)。
|
|
12
|
+
label: string;
|
|
13
|
+
// 图标。
|
|
14
|
+
children: ReactNode;
|
|
15
|
+
// 形状档:缺省是设置面那种方形圆角;`circle` 是对话动作行那种圆形。
|
|
16
|
+
shape?: "square" | "circle";
|
|
17
|
+
// 尺寸档:`md`(缺省)28×28 是独立控件的尺度;`sm` 20×20 放进 24px 高的行里
|
|
18
|
+
// (上游折叠行 `.row` 的高度就是 24px,放 28px 会被顶出来)。
|
|
19
|
+
size?: "md" | "sm";
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function IconButton({
|
|
23
|
+
label,
|
|
24
|
+
children,
|
|
25
|
+
type = "button",
|
|
26
|
+
shape = "square",
|
|
27
|
+
size = "md",
|
|
28
|
+
className,
|
|
29
|
+
...rest
|
|
30
|
+
}: IconButtonProps): ReactNode {
|
|
31
|
+
return (
|
|
32
|
+
<button
|
|
33
|
+
type={type}
|
|
34
|
+
data-role="icon-button"
|
|
35
|
+
aria-label={label}
|
|
36
|
+
className={classes(
|
|
37
|
+
css.iconButton,
|
|
38
|
+
size === "sm" ? css.sm : undefined,
|
|
39
|
+
shape === "circle" ? css.circle : undefined,
|
|
40
|
+
className,
|
|
41
|
+
)}
|
|
42
|
+
{...rest}
|
|
43
|
+
>
|
|
44
|
+
{children}
|
|
45
|
+
</button>
|
|
46
|
+
);
|
|
47
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/* 模型路由清单(`ModelRouteList`):分组铺开、只选一条。数值照官方「子智能体」那张卡的模型清单。 */
|
|
2
|
+
.list {
|
|
3
|
+
display: grid;
|
|
4
|
+
gap: 6px;
|
|
5
|
+
min-width: 0;
|
|
6
|
+
max-height: 280px;
|
|
7
|
+
margin: 0;
|
|
8
|
+
padding: 10px;
|
|
9
|
+
overflow: auto;
|
|
10
|
+
border: 0.5px solid var(--dsw-alias-border-l4);
|
|
11
|
+
border-radius: var(--dsw-radius-lg);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
.group {
|
|
15
|
+
display: grid;
|
|
16
|
+
gap: 6px;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
.group + .group {
|
|
20
|
+
margin-top: 4px;
|
|
21
|
+
padding-top: 10px;
|
|
22
|
+
border-top: 0.5px solid var(--dsw-alias-border-l3);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
.provider {
|
|
26
|
+
padding: 0 6px;
|
|
27
|
+
font-size: 11px;
|
|
28
|
+
font-weight: 500;
|
|
29
|
+
color: var(--dsw-alias-label-tertiary);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
.item {
|
|
33
|
+
display: grid;
|
|
34
|
+
grid-template-columns: auto minmax(0, 1fr);
|
|
35
|
+
align-items: center;
|
|
36
|
+
gap: 8px;
|
|
37
|
+
min-width: 0;
|
|
38
|
+
padding: 6px;
|
|
39
|
+
border-radius: var(--dsw-radius-md);
|
|
40
|
+
cursor: pointer;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
.item:hover {
|
|
44
|
+
/* 上游那张卡的 hover 底色引用了 `--dsw-alias-bg-layer-4`:它不在官方 token 集里,照抄引用而不是换一个近义 token。 */
|
|
45
|
+
background: var(--dsw-alias-bg-layer-4);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
.item:focus-visible {
|
|
49
|
+
outline: var(--dsw-focus-ring-width) solid
|
|
50
|
+
var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary));
|
|
51
|
+
outline-offset: -2px;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
.item[aria-disabled="true"] {
|
|
55
|
+
cursor: not-allowed;
|
|
56
|
+
opacity: 0.4;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/* 选中的那一条:底色 + 勾(勾由组件画)。 */
|
|
60
|
+
.picked {
|
|
61
|
+
background: var(--dsw-alias-interactive-bg-hover);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
.text {
|
|
65
|
+
min-width: 0;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
.name {
|
|
69
|
+
display: block;
|
|
70
|
+
overflow: hidden;
|
|
71
|
+
text-overflow: ellipsis;
|
|
72
|
+
white-space: nowrap;
|
|
73
|
+
font-size: 13px;
|
|
74
|
+
color: var(--dsw-alias-label-primary);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
.id {
|
|
78
|
+
display: block;
|
|
79
|
+
margin-top: 2px;
|
|
80
|
+
overflow: hidden;
|
|
81
|
+
text-overflow: ellipsis;
|
|
82
|
+
white-space: nowrap;
|
|
83
|
+
font-size: 11px;
|
|
84
|
+
color: var(--dsw-alias-label-tertiary);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/* 清单的状态行(读目录中 / 失败 / 一条都没有)。 */
|
|
88
|
+
.notice {
|
|
89
|
+
margin: 0;
|
|
90
|
+
font-size: 12px;
|
|
91
|
+
line-height: 1.5;
|
|
92
|
+
color: var(--dsw-alias-label-tertiary);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
.error {
|
|
96
|
+
margin: 0;
|
|
97
|
+
font-size: 12px;
|
|
98
|
+
line-height: 1.5;
|
|
99
|
+
color: var(--dsw-alias-state-error-primary);
|
|
100
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
// 模型路由清单(`ModelRouteList`):把"已设置的模型"按 provider 分组铺开,**只选一条**——一条路由就是
|
|
2
|
+
// provider + model 这一对,所以选它等于把两者一起定下来。
|
|
3
|
+
//
|
|
4
|
+
// 形态照官方"子智能体"那张卡的模型清单(分组标题 + 两行的项:模型名 / `provider · provider/model`),
|
|
5
|
+
// 区别只在这里是单选。
|
|
6
|
+
|
|
7
|
+
import type { ReactNode } from "react";
|
|
8
|
+
import { IconCheckOutlineRegular } from "@deepseek-ai/dsh-client-ui-primitives";
|
|
9
|
+
import { classes } from "./classes.ts";
|
|
10
|
+
import css from "./ModelRouteList.module.css";
|
|
11
|
+
|
|
12
|
+
// 一条可选路由:`key` 是稳定身份(调用方只用来比对,不解析它)。
|
|
13
|
+
export interface ModelRouteCandidate {
|
|
14
|
+
readonly key: string;
|
|
15
|
+
readonly provider: string;
|
|
16
|
+
readonly providerName: string;
|
|
17
|
+
readonly model: string;
|
|
18
|
+
readonly modelName: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface ModelRouteListProps {
|
|
22
|
+
// 清单的无障碍名(`role="radiogroup"`)。
|
|
23
|
+
label: string;
|
|
24
|
+
candidates: readonly ModelRouteCandidate[];
|
|
25
|
+
// 当前选中的那条(没选 = `undefined`)。
|
|
26
|
+
selectedKey: string | undefined;
|
|
27
|
+
disabled?: boolean;
|
|
28
|
+
// 读目录的状态:读的时候说一句、失败的时候说另一句。
|
|
29
|
+
status: "loading" | "ready" | "error";
|
|
30
|
+
loadingLabel: string;
|
|
31
|
+
errorLabel: string;
|
|
32
|
+
emptyLabel: string;
|
|
33
|
+
onSelect: (candidate: ModelRouteCandidate) => void;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function ModelRouteList({
|
|
37
|
+
label,
|
|
38
|
+
candidates,
|
|
39
|
+
selectedKey,
|
|
40
|
+
disabled = false,
|
|
41
|
+
status,
|
|
42
|
+
loadingLabel,
|
|
43
|
+
errorLabel,
|
|
44
|
+
emptyLabel,
|
|
45
|
+
onSelect,
|
|
46
|
+
}: ModelRouteListProps): ReactNode {
|
|
47
|
+
if (status === "loading") {
|
|
48
|
+
return (
|
|
49
|
+
<p className={css.notice} role="status">
|
|
50
|
+
{loadingLabel}
|
|
51
|
+
</p>
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
if (status === "error") {
|
|
55
|
+
return (
|
|
56
|
+
<p className={css.error} role="alert">
|
|
57
|
+
{errorLabel}
|
|
58
|
+
</p>
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
if (candidates.length === 0) {
|
|
62
|
+
return <p className={css.notice}>{emptyLabel}</p>;
|
|
63
|
+
}
|
|
64
|
+
// 按 provider 分组,组的顺序就是候选来的顺序。
|
|
65
|
+
const groups = new Map<string, { name: string; items: ModelRouteCandidate[] }>();
|
|
66
|
+
for (const candidate of candidates) {
|
|
67
|
+
const group = groups.get(candidate.provider);
|
|
68
|
+
if (group === undefined) {
|
|
69
|
+
groups.set(candidate.provider, { name: candidate.providerName, items: [candidate] });
|
|
70
|
+
} else group.items.push(candidate);
|
|
71
|
+
}
|
|
72
|
+
return (
|
|
73
|
+
<div
|
|
74
|
+
className={css.list}
|
|
75
|
+
data-role="model-route-list"
|
|
76
|
+
role="radiogroup"
|
|
77
|
+
aria-label={label}
|
|
78
|
+
data-control="model-routes"
|
|
79
|
+
>
|
|
80
|
+
{[...groups].map(([provider, group]) => (
|
|
81
|
+
<div key={provider} className={css.group} data-route-group={provider}>
|
|
82
|
+
<span className={css.provider}>{group.name}</span>
|
|
83
|
+
{group.items.map((candidate) => {
|
|
84
|
+
const picked = candidate.key === selectedKey;
|
|
85
|
+
return (
|
|
86
|
+
<div
|
|
87
|
+
key={candidate.key}
|
|
88
|
+
className={classes(css.item, picked ? css.picked : undefined)}
|
|
89
|
+
role="radio"
|
|
90
|
+
aria-checked={picked}
|
|
91
|
+
aria-label={`${candidate.modelName} ${candidate.provider}/${candidate.model}`}
|
|
92
|
+
aria-disabled={disabled ? true : undefined}
|
|
93
|
+
tabIndex={disabled ? -1 : 0}
|
|
94
|
+
data-route={candidate.key}
|
|
95
|
+
onClick={() => {
|
|
96
|
+
if (!disabled) onSelect(candidate);
|
|
97
|
+
}}
|
|
98
|
+
onKeyDown={(event) => {
|
|
99
|
+
if (event.key !== "Enter" && event.key !== " ") return;
|
|
100
|
+
event.preventDefault();
|
|
101
|
+
if (!disabled) onSelect(candidate);
|
|
102
|
+
}}
|
|
103
|
+
>
|
|
104
|
+
{picked ? <IconCheckOutlineRegular /> : <span aria-hidden="true" />}
|
|
105
|
+
<span className={css.text}>
|
|
106
|
+
<span className={css.name}>{candidate.modelName}</span>
|
|
107
|
+
<span className={css.id}>
|
|
108
|
+
{`${candidate.providerName} · ${candidate.provider}/${candidate.model}`}
|
|
109
|
+
</span>
|
|
110
|
+
</span>
|
|
111
|
+
</div>
|
|
112
|
+
);
|
|
113
|
+
})}
|
|
114
|
+
</div>
|
|
115
|
+
))}
|
|
116
|
+
</div>
|
|
117
|
+
);
|
|
118
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/* 多行文本:官方通用表单只有单行控件,这个按官方输入框的数值画(边框 / 圆角 / 字号 / 内距)。 */
|
|
2
|
+
.multiline {
|
|
3
|
+
box-sizing: border-box;
|
|
4
|
+
width: 100%;
|
|
5
|
+
min-height: 64px;
|
|
6
|
+
padding: 6px 12px;
|
|
7
|
+
font-family: inherit;
|
|
8
|
+
font-size: 13px;
|
|
9
|
+
line-height: 1.5;
|
|
10
|
+
color: var(--dsw-alias-label-primary);
|
|
11
|
+
background: var(--dsw-alias-bg-layer-3);
|
|
12
|
+
border: 0.5px solid var(--dsw-alias-border-l4);
|
|
13
|
+
border-radius: var(--dsw-radius-md);
|
|
14
|
+
resize: vertical;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
.multiline:disabled {
|
|
18
|
+
cursor: not-allowed;
|
|
19
|
+
opacity: 0.4;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
.multiline:focus-visible {
|
|
23
|
+
outline: var(--dsw-focus-ring-width) solid
|
|
24
|
+
var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary));
|
|
25
|
+
outline-offset: -2px;
|
|
26
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// 多行文本:官方通用表单只有单行控件,这个按官方输入框的数值画(边框 / 圆角 / 字号 / 内距),
|
|
2
|
+
// 标签与说明由外面的字段行给(见 `settings-form` 的 `SettingsFieldRow`)。
|
|
3
|
+
|
|
4
|
+
import type { TextareaHTMLAttributes } from "react";
|
|
5
|
+
import { classes } from "./classes.ts";
|
|
6
|
+
import css from "./MultilineField.module.css";
|
|
7
|
+
|
|
8
|
+
export type MultilineFieldProps = TextareaHTMLAttributes<HTMLTextAreaElement>;
|
|
9
|
+
|
|
10
|
+
export function MultilineField({ rows = 3, className, ...rest }: MultilineFieldProps) {
|
|
11
|
+
return (
|
|
12
|
+
<textarea
|
|
13
|
+
rows={rows}
|
|
14
|
+
data-role="multiline-field"
|
|
15
|
+
className={classes(css.multiline, className)}
|
|
16
|
+
{...rest}
|
|
17
|
+
/>
|
|
18
|
+
);
|
|
19
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/* 与官方输入框同高(`Input.module.css` 的 32px):同行摆的控件高度必须一致。 */
|
|
2
|
+
.trigger {
|
|
3
|
+
height: 32px;
|
|
4
|
+
white-space: nowrap;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
/* 菜单顶部那一行搜索框:与候选行同一个内距尺度。 */
|
|
8
|
+
.searchRow {
|
|
9
|
+
display: flex;
|
|
10
|
+
align-items: center;
|
|
11
|
+
padding: 4px 8px 8px;
|
|
12
|
+
border-bottom: 0.5px solid var(--dsw-alias-border-l2);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
.searchInput {
|
|
16
|
+
width: 100%;
|
|
17
|
+
padding: 2px 0;
|
|
18
|
+
font-family: inherit;
|
|
19
|
+
font-size: 13px;
|
|
20
|
+
line-height: 1.5;
|
|
21
|
+
color: var(--dsw-alias-label-primary);
|
|
22
|
+
background: transparent;
|
|
23
|
+
border: none;
|
|
24
|
+
outline: none;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/* 一个候选都没匹配上:在候选区里说一句,而不是给一张空菜单。 */
|
|
28
|
+
.empty {
|
|
29
|
+
margin: 0;
|
|
30
|
+
padding: 8px;
|
|
31
|
+
font-size: 12px;
|
|
32
|
+
line-height: 1.5;
|
|
33
|
+
color: var(--dsw-alias-label-tertiary);
|
|
34
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
// 可搜索选择器(searchable):候选一多,"滚着找"就不够用了——打开后先给一行搜索框,按名字模糊过滤
|
|
2
|
+
// (官方 `rankByName`:前缀命中优先,其次对齐分数,最后保持来源顺序)。
|
|
3
|
+
//
|
|
4
|
+
// 它与标签输入(multi-input)共用同一套"搜索 + 候选"交互:TagInput 的「从候选里选」就是它的单值形态。
|
|
5
|
+
|
|
6
|
+
import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
|
7
|
+
import {
|
|
8
|
+
Button,
|
|
9
|
+
IconCheckOutlineRegular,
|
|
10
|
+
Menu,
|
|
11
|
+
MenuItemButton,
|
|
12
|
+
rankByName,
|
|
13
|
+
} from "@deepseek-ai/dsh-client-ui-primitives";
|
|
14
|
+
import { classes } from "./classes.ts";
|
|
15
|
+
import css from "./SearchSelect.module.css";
|
|
16
|
+
|
|
17
|
+
// 一项候选:值是写进配置的那份,label 是给人看的(也是搜索的第二个键)。
|
|
18
|
+
export interface SearchSelectOption {
|
|
19
|
+
readonly value: string;
|
|
20
|
+
readonly label?: string | undefined;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface SearchSelectProps {
|
|
24
|
+
// 无障碍名(触发按钮的名字)。
|
|
25
|
+
label: string;
|
|
26
|
+
// 当前值;空串表示这一项没写(触发按钮显示 `emptyLabel`)。
|
|
27
|
+
value: string;
|
|
28
|
+
// 没写时触发按钮显示的那句话(例如"不写"),它也是候选里的第一项(选它 = 清掉)。
|
|
29
|
+
emptyLabel: string;
|
|
30
|
+
options: readonly SearchSelectOption[];
|
|
31
|
+
disabled?: boolean;
|
|
32
|
+
// 搜索框的占位与无障碍名。
|
|
33
|
+
searchLabel: string;
|
|
34
|
+
// 一个候选都没匹配上时的那句话。
|
|
35
|
+
noMatchLabel: string;
|
|
36
|
+
// 选中一项;空串 = 清掉这一项。
|
|
37
|
+
onSelect: (next: string) => void;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function SearchSelect({
|
|
41
|
+
label,
|
|
42
|
+
value,
|
|
43
|
+
emptyLabel,
|
|
44
|
+
options,
|
|
45
|
+
disabled = false,
|
|
46
|
+
searchLabel,
|
|
47
|
+
noMatchLabel,
|
|
48
|
+
onSelect,
|
|
49
|
+
}: SearchSelectProps): ReactNode {
|
|
50
|
+
const [open, setOpen] = useState(false);
|
|
51
|
+
const [query, setQuery] = useState("");
|
|
52
|
+
const searchRef = useRef<HTMLInputElement | null>(null);
|
|
53
|
+
// 打开就落到搜索框上:候选多的时候,键盘直接开始打字。
|
|
54
|
+
useEffect(() => {
|
|
55
|
+
if (open) searchRef.current?.focus();
|
|
56
|
+
}, [open]);
|
|
57
|
+
const matches = useMemo(
|
|
58
|
+
() =>
|
|
59
|
+
rankByName(
|
|
60
|
+
// 搜索键是显示名(`name`)与它本身(`label`,缺省时就是值):`rankByName` 拿 name 与 label 双键匹配。
|
|
61
|
+
options.map((option) => ({
|
|
62
|
+
value: option.value,
|
|
63
|
+
name: option.label ?? option.value,
|
|
64
|
+
label: option.value,
|
|
65
|
+
})),
|
|
66
|
+
query,
|
|
67
|
+
),
|
|
68
|
+
[options, query],
|
|
69
|
+
);
|
|
70
|
+
const current = options.find((option) => option.value === value);
|
|
71
|
+
const close = (): void => {
|
|
72
|
+
setOpen(false);
|
|
73
|
+
setQuery("");
|
|
74
|
+
};
|
|
75
|
+
const pick = (next: string): void => {
|
|
76
|
+
close();
|
|
77
|
+
onSelect(next);
|
|
78
|
+
};
|
|
79
|
+
return (
|
|
80
|
+
<Menu
|
|
81
|
+
open={open}
|
|
82
|
+
anchor={
|
|
83
|
+
<Button
|
|
84
|
+
variant="outline"
|
|
85
|
+
size="sm"
|
|
86
|
+
data-role="search-select"
|
|
87
|
+
className={classes(css.trigger)}
|
|
88
|
+
data-action="pick"
|
|
89
|
+
disabled={disabled}
|
|
90
|
+
aria-haspopup="menu"
|
|
91
|
+
aria-label={label}
|
|
92
|
+
onClick={() => {
|
|
93
|
+
if (open) close();
|
|
94
|
+
else setOpen(true);
|
|
95
|
+
}}
|
|
96
|
+
>
|
|
97
|
+
{value === "" ? emptyLabel : (current?.label ?? value)}
|
|
98
|
+
</Button>
|
|
99
|
+
}
|
|
100
|
+
selectedId={value}
|
|
101
|
+
onSelect={pick}
|
|
102
|
+
onClose={close}
|
|
103
|
+
>
|
|
104
|
+
<div className={css.searchRow}>
|
|
105
|
+
<input
|
|
106
|
+
className={css.searchInput}
|
|
107
|
+
ref={searchRef}
|
|
108
|
+
value={query}
|
|
109
|
+
aria-label={searchLabel}
|
|
110
|
+
placeholder={searchLabel}
|
|
111
|
+
onChange={(event) => {
|
|
112
|
+
setQuery(event.currentTarget.value);
|
|
113
|
+
}}
|
|
114
|
+
/>
|
|
115
|
+
</div>
|
|
116
|
+
<MenuItemButton
|
|
117
|
+
icon={value === "" ? <IconCheckOutlineRegular /> : undefined}
|
|
118
|
+
onSelect={() => {
|
|
119
|
+
pick("");
|
|
120
|
+
}}
|
|
121
|
+
>
|
|
122
|
+
{emptyLabel}
|
|
123
|
+
</MenuItemButton>
|
|
124
|
+
{matches.map((option) => (
|
|
125
|
+
<MenuItemButton
|
|
126
|
+
key={option.value}
|
|
127
|
+
icon={option.value === value ? <IconCheckOutlineRegular /> : undefined}
|
|
128
|
+
onSelect={() => {
|
|
129
|
+
pick(option.value);
|
|
130
|
+
}}
|
|
131
|
+
>
|
|
132
|
+
{option.name}
|
|
133
|
+
</MenuItemButton>
|
|
134
|
+
))}
|
|
135
|
+
{matches.length === 0 ? <p className={css.empty}>{noMatchLabel}</p> : null}
|
|
136
|
+
</Menu>
|
|
137
|
+
);
|
|
138
|
+
}
|