Skip to content

Commit 9eebcbd

Browse files
GCWingGCWing
andauthored
fix(web-ui): refine model pool configuration and model selection (#3467)
Co-authored-by: GCWing <gcwing@foxmail.com>
1 parent ae49283 commit 9eebcbd

16 files changed

Lines changed: 622 additions & 80 deletions
1.04 MB
Loading

‎src/web-ui/src/app/scenes/settings/AGENTS.md‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,16 @@ pnpm --dir src/web-ui run test:run src/infrastructure/config/components/common/S
4343
```
4444

4545
Follow the parent guide for `check:web`, i18n, and Appearance registration checks.
46+
For model-pool strategy selection, member search/multi-selection, nested-dialog
47+
dismissal, preserved records and device-switch isolation, run:
48+
49+
```bash
50+
pnpm --dir src/web-ui run test:run src/app/scenes/settings/pages/ai/ModelPoolsSection.test.tsx src/app/scenes/settings/pages/ai/ModelDiscoveryPicker.test.tsx src/infrastructure/config/components/common/SettingsControlSizing.test.ts
51+
```
52+
53+
These DOM fixtures simulate device activation and unsupported peer capabilities;
54+
they do not establish live remote behavior or visual fidelity.
55+
4656
When changing destination migrations or catalog source evidence, also run
4757
`pnpm run capabilities:generate`, `pnpm run capabilities:check`, and
4858
`pnpm run capabilities:test`; retain historical wire destinations for older clients.

‎src/web-ui/src/app/scenes/settings/pages/ai/ModelDiscoveryPicker.test.tsx‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,13 +76,15 @@ describe('model discovery capsules', () => {
7676
expect(popup()!.textContent).toContain(copy('providerSelection.fetchedModels', { count: '3' }));
7777
expect(capsules().map(button => button.textContent)).toEqual(['alpha', 'beta', 'gamma']);
7878
expect(capsules()[0].getAttribute('aria-pressed')).toBe('true');
79+
expect(capsules()[0].getAttribute('data-openbitfun-variant')).toBe('fill');
7980
click(capsules()[2]);
8081
expect(changes.at(-1)).toEqual(['custom-model', 'ALPHA', 'gamma']);
8182
expect(popup()).not.toBeNull();
8283
expect(capsules()[2].getAttribute('aria-pressed')).toBe('true');
8384
click(capsules()[0]);
8485
expect(changes.at(-1)).toEqual(['custom-model', 'gamma']);
8586
expect(capsules()).toHaveLength(3);
87+
expect(capsules()[0].getAttribute('data-openbitfun-variant')).toBe('outline');
8688

8789
act(() => {
8890
search().focus();

‎src/web-ui/src/app/scenes/settings/pages/ai/ModelDiscoveryPicker.tsx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -137,7 +137,7 @@ export function ModelDiscoveryPicker({
137137
return (
138138
<Button
139139
key={modelName}
140-
variant="outline"
140+
variant={isSelected ? 'fill' : 'outline'}
141141
size="xs"
142142
aria-pressed={isSelected}
143143
title={option.description}
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import { useEffect, useId, useRef, useState } from 'react';
2+
import {
3+
Button, Dialog, DialogBody, DialogClose, DialogFooter, DialogHeader, DialogHeading,
4+
DialogTitle, Icon, ScrollArea, SearchField,
5+
} from '@openbitfun/ui';
6+
import { useI18n } from '@/infrastructure/i18n';
7+
import type { AIModelConfig } from '@/infrastructure/config/types';
8+
import { useModelSelectPresentation } from '@/infrastructure/config/components/ModelSelectPresentation';
9+
import { filterSelectableTextChatModels } from '@/infrastructure/config/services/modelCategory';
10+
11+
export function ModelPoolMemberPicker({ models, value, disabled, onValueChange }: {
12+
models: readonly AIModelConfig[];
13+
value: readonly string[];
14+
disabled: boolean;
15+
onValueChange: (value: string[]) => void;
16+
}) {
17+
const { t } = useI18n('settings/models');
18+
const { buildModelOption } = useModelSelectPresentation();
19+
const [open, setOpen] = useState(false);
20+
const [query, setQuery] = useState('');
21+
const searchRef = useRef<HTMLInputElement>(null);
22+
const id = useId();
23+
useEffect(() => {
24+
if (!open) setQuery('');
25+
}, [open]);
26+
27+
const options = filterSelectableTextChatModels(models).map(buildModelOption);
28+
const normalizedQuery = query.trim().toLowerCase();
29+
const visibleOptions = options.filter(option => (
30+
`${option.label} ${option.value} ${typeof option.description === 'string' ? option.description : ''}`
31+
.toLowerCase().includes(normalizedQuery)
32+
));
33+
const selected = new Set(value);
34+
35+
return <>
36+
<Button variant="primary" size="xs" disabled={disabled}
37+
data-testid="settings-model-pool-add" aria-haspopup="dialog" aria-expanded={open}
38+
aria-controls={open ? id : undefined} onClick={() => setOpen(true)}>
39+
{t('pools.add')}
40+
</Button>
41+
<Dialog id={id} open={open} onOpenChange={setOpen} size="sm" initialFocusRef={searchRef}
42+
className="openbitfun-model-pools__picker" data-testid="settings-model-pool-member-dialog">
43+
<DialogHeader>
44+
<DialogHeading><DialogTitle>{t('pools.add')}</DialogTitle></DialogHeading>
45+
<DialogClose />
46+
</DialogHeader>
47+
<DialogBody className="openbitfun-model-pools__picker-body">
48+
<SearchField ref={searchRef} value={query} onChange={event => setQuery(event.target.value)}
49+
aria-label={t('pool.search')} placeholder={t('pool.search')} size="sm" />
50+
<ScrollArea className="openbitfun-model-pools__picker-results">
51+
<div className="openbitfun-model-pools__picker-options"
52+
data-openbitfun-component="model-settings" data-openbitfun-part="poolPickerOptions">
53+
{visibleOptions.map(option => {
54+
const modelId = String(option.value);
55+
const isSelected = selected.has(modelId);
56+
return <Button key={modelId} variant={isSelected ? 'fill' : 'outline'} size="xs" disabled={disabled}
57+
aria-pressed={isSelected} title={typeof option.description === 'string' ? option.description : undefined}
58+
data-testid="settings-model-pool-member-option" data-model-id={modelId}
59+
leadingIcon={<Icon name={isSelected ? 'check-line' : 'plus'} size="xs" tone={isSelected ? 'success' : undefined} />}
60+
onClick={() => onValueChange(isSelected
61+
? value.filter(member => member !== modelId) : [...value, modelId])}>
62+
{option.label}
63+
</Button>;
64+
})}
65+
{visibleOptions.length === 0 && <p role="status">{t(options.length > 0 ? 'pool.noMatches' : 'pool.empty')}</p>}
66+
</div>
67+
</ScrollArea>
68+
</DialogBody>
69+
<DialogFooter appearance="floating">
70+
<Button variant="primary" size="sm" onClick={() => setOpen(false)}>{t('actions.done')}</Button>
71+
</DialogFooter>
72+
</Dialog>
73+
</>;
74+
}

‎src/web-ui/src/app/scenes/settings/pages/ai/ModelPoolsSection.scss‎

Lines changed: 150 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,7 @@
2121
flex-shrink: 0;
2222
}
2323

24-
.openbitfun-model-pools__actions,
25-
.openbitfun-model-pools__member-actions {
24+
.openbitfun-model-pools__actions {
2625
display: flex;
2726
align-items: center;
2827
justify-content: flex-end;
@@ -31,49 +30,177 @@
3130

3231
// Keep the row geometry stable and the controls reachable by keyboard or touch.
3332
@media (hover: hover) and (pointer: fine) {
34-
.openbitfun-model-pools__actions,
35-
.openbitfun-model-pools__member-actions {
33+
.openbitfun-model-pools__actions {
3634
opacity: 0;
3735
pointer-events: none;
3836
}
3937

4038
.openbitfun-model-pools__pool:hover .openbitfun-model-pools__actions,
41-
.openbitfun-model-pools__pool:focus-within .openbitfun-model-pools__actions,
42-
.openbitfun-model-pools__member:hover .openbitfun-model-pools__member-actions,
43-
.openbitfun-model-pools__member:focus-within .openbitfun-model-pools__member-actions {
39+
.openbitfun-model-pools__pool:focus-within .openbitfun-model-pools__actions {
4440
opacity: 1;
4541
pointer-events: auto;
4642
}
4743
}
4844

4945
.openbitfun-model-pools__members {
50-
list-style: none;
51-
margin: var(--openbitfun-space-2) 0 0;
52-
padding: var(--openbitfun-space-1);
53-
border: 1px solid var(--openbitfun-color-border-subtle);
54-
border-radius: var(--openbitfun-radius-base);
55-
background: var(--openbitfun-color-surface-subtle);
46+
display: flex;
47+
flex-wrap: wrap;
48+
align-items: flex-start;
49+
gap: var(--openbitfun-space-2);
50+
margin-block-start: var(--openbitfun-space-2);
51+
padding: var(--openbitfun-space-1) var(--openbitfun-space-5);
5652
}
5753

5854
.openbitfun-model-pools__member {
55+
display: inline-flex;
56+
min-inline-size: 0;
57+
max-inline-size: 100%;
58+
}
59+
60+
.openbitfun-model-pools__member-summary {
5961
display: flex;
62+
flex-direction: column;
63+
gap: var(--openbitfun-space-1);
64+
}
65+
66+
.openbitfun-model-pools__member-button {
67+
padding-inline-start: var(--openbitfun-space-1);
68+
69+
[data-openbitfun-part='leading-icon'] {
70+
inline-size: var(--openbitfun-space-4);
71+
block-size: var(--openbitfun-space-4);
72+
}
73+
}
74+
75+
.openbitfun-model-pools__member-order {
76+
display: inline-flex;
6077
align-items: center;
61-
gap: var(--openbitfun-space-2);
62-
min-block-size: var(--openbitfun-control-height-sm);
63-
padding: var(--openbitfun-space-2) var(--openbitfun-space-3);
64-
color: var(--openbitfun-color-content-primary);
65-
font-size: var(--openbitfun-type-body-sm-font-size);
66-
line-height: var(--openbitfun-type-body-sm-line-height);
78+
justify-content: center;
79+
inline-size: 100%;
80+
block-size: 100%;
81+
border-radius: var(--openbitfun-radius-pill);
82+
background: var(--openbitfun-color-surface-panel);
83+
color: var(--openbitfun-color-content-secondary);
84+
font-family: var(--openbitfun-type-meta-font-family);
85+
font-size: var(--openbitfun-type-meta-font-size);
86+
font-weight: var(--openbitfun-type-meta-font-weight);
87+
line-height: var(--openbitfun-type-meta-line-height);
88+
font-variant-numeric: tabular-nums;
89+
}
90+
91+
.openbitfun-model-pools__editor {
92+
block-size: min(640px, calc(100vh - 2 * var(--openbitfun-overlay-dialog-viewport-gutter)));
93+
}
94+
95+
.openbitfun-model-pools__strategies {
96+
display: grid;
97+
inline-size: 100%;
98+
grid-template-columns: repeat(2, minmax(0, 1fr));
99+
gap: var(--openbitfun-space-3);
100+
}
101+
102+
.openbitfun-model-pools__editor-row.openbitfun-config-page-row {
103+
border: 0;
104+
105+
&:hover {
106+
background: transparent;
107+
}
67108
}
68109

69-
.openbitfun-model-pools__member + .openbitfun-model-pools__member {
70-
border-block-start: 1px solid var(--openbitfun-color-border-subtle);
110+
.openbitfun-model-pools__name-row.openbitfun-config-page-row {
111+
grid-template-columns: max-content minmax(0, 1fr);
112+
column-gap: var(--openbitfun-space-3);
113+
114+
@media (max-width: 520px) {
115+
grid-template-columns: minmax(0, 1fr);
116+
row-gap: var(--openbitfun-space-2);
117+
}
71118
}
72119

73-
.openbitfun-model-pools__member > .openbitfun-model-pools__summary {
120+
.openbitfun-model-pools__strategy-card {
121+
position: relative;
122+
min-inline-size: 0;
123+
border: 0;
124+
cursor: pointer;
125+
126+
&[data-disabled='true'] {
127+
cursor: default;
128+
}
129+
}
130+
131+
.openbitfun-model-pools__strategy-header {
132+
position: absolute;
133+
inset-block-start: var(--openbitfun-space-2);
134+
inset-inline-end: var(--openbitfun-space-2);
135+
}
136+
137+
.openbitfun-model-pools__strategy-content {
74138
flex: 1;
139+
justify-content: center;
140+
gap: var(--openbitfun-space-2);
141+
padding: var(--openbitfun-space-4);
142+
text-align: center;
143+
}
144+
145+
.openbitfun-model-pools__strategy-title {
146+
color: var(--openbitfun-color-content-primary);
147+
font-family: var(--openbitfun-type-label-md-font-family);
148+
font-size: var(--openbitfun-type-label-md-font-size);
149+
font-weight: var(--openbitfun-type-label-md-font-weight);
150+
line-height: var(--openbitfun-type-label-md-line-height);
75151
}
76152

77-
.openbitfun-model-pools__member-actions {
153+
.openbitfun-model-pools__strategy-preview {
154+
display: block;
155+
inline-size: 100%;
78156
flex-shrink: 0;
157+
aspect-ratio: 3 / 2;
158+
overflow: hidden;
159+
160+
> img {
161+
display: block;
162+
inline-size: 200%;
163+
max-inline-size: none;
164+
block-size: 100%;
165+
}
166+
167+
&[data-strategy='balanced'] > img {
168+
transform: translateX(-50%);
169+
}
170+
}
171+
172+
.openbitfun-model-pools__picker {
173+
block-size: min(420px, calc(100vh - 2 * var(--openbitfun-overlay-dialog-viewport-gutter)));
174+
}
175+
176+
.openbitfun-model-pools__picker-body {
177+
gap: var(--openbitfun-space-3);
178+
overflow: hidden;
179+
180+
> [data-openbitfun-component='search-field'] {
181+
flex-shrink: 0;
182+
}
183+
}
184+
185+
.openbitfun-model-pools__picker-results {
186+
flex: 1 1 auto;
187+
padding: var(--openbitfun-space-1);
188+
}
189+
190+
.openbitfun-model-pools__picker-options {
191+
display: flex;
192+
flex-wrap: wrap;
193+
align-items: flex-start;
194+
gap: var(--openbitfun-space-2);
195+
196+
> [data-openbitfun-component='button'] {
197+
max-inline-size: 100%;
198+
}
199+
200+
> p {
201+
margin: 0;
202+
color: var(--openbitfun-color-content-secondary);
203+
font-size: var(--openbitfun-type-body-sm-font-size);
204+
line-height: var(--openbitfun-type-body-sm-line-height);
205+
}
79206
}

0 commit comments

Comments
 (0)