@nextclaw/ui 0.11.11 → 0.11.13
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/CHANGELOG.md +19 -0
- package/dist/assets/{ChannelsList-c6t0mn89.js → ChannelsList-BlQD1VuM.js} +4 -4
- package/dist/assets/ChatPage-DBvm558n.js +37 -0
- package/dist/assets/{DocBrowser-DNArT9C7.js → DocBrowser-DTww3NZc.js} +1 -1
- package/dist/assets/{LogoBadge-Bg6qKIGM.js → LogoBadge-D0ogG1ut.js} +1 -1
- package/dist/assets/{MarketplacePage-Bdg8GqQ6.js → MarketplacePage-DTHw6n0X.js} +1 -1
- package/dist/assets/{McpMarketplacePage-CU6gr58O.js → McpMarketplacePage-BikE0mBl.js} +2 -2
- package/dist/assets/{ModelConfig-CHlpmjUg.js → ModelConfig-CvM__Pz1.js} +1 -1
- package/dist/assets/{ProvidersList-CwTZF2yz.js → ProvidersList-DtZWZlL0.js} +1 -1
- package/dist/assets/RemoteAccessPage-E5fT1pem.js +1 -0
- package/dist/assets/RuntimeConfig-DyZNiqYT.js +1 -0
- package/dist/assets/{SearchConfig-COmMqF50.js → SearchConfig-C1bhOCNX.js} +1 -1
- package/dist/assets/{SecretsConfig-u9OrM8fR.js → SecretsConfig-CYmy1Sqy.js} +2 -2
- package/dist/assets/{SessionsConfig-Cu8ou527.js → SessionsConfig-DSlhPpIE.js} +2 -2
- package/dist/assets/{chat-session-display-cb3oTJlV.js → chat-session-display-D9YuDGe3.js} +1 -1
- package/dist/assets/index-BBz4mi7g.js +8 -0
- package/dist/assets/{index-Bro-iRcb.css → index-CfVmBgkf.css} +1 -1
- package/dist/assets/{label-DkFojDz9.js → label-C7Xd_hqz.js} +1 -1
- package/dist/assets/{page-layout-DJ1cEM0C.js → page-layout-VxCaUcrD.js} +1 -1
- package/dist/assets/{popover-OLgPYzWf.js → popover-CC4znqAM.js} +1 -1
- package/dist/assets/security-config-7eVxJq8b.js +1 -0
- package/dist/assets/skeleton-DhZRDdHm.js +1 -0
- package/dist/assets/{status-dot-Cf8rkmc5.js → status-dot-Bi7Ze-LS.js} +1 -1
- package/dist/assets/{switch-CF529ZId.js → switch-COBEivEX.js} +1 -1
- package/dist/assets/{tabs-custom-BweiG3H2.js → tabs-custom-B9j40wuu.js} +1 -1
- package/dist/assets/{useConfirmDialog-_kYcW1mi.js → useConfirmDialog-N8nuxOq-.js} +1 -1
- package/dist/assets/{vendor-BEQcLDx6.js → vendor-MCpnpiKt.js} +35 -35
- package/dist/index.html +3 -3
- package/package.json +6 -6
- package/src/components/chat/adapters/chat-input-bar.adapter.test.ts +68 -1
- package/src/components/chat/adapters/chat-input-bar.adapter.ts +37 -6
- package/src/components/chat/chat-recent-models.manager.ts +8 -0
- package/src/components/chat/containers/chat-input-bar.container.tsx +16 -1
- package/src/components/chat/ncp/NcpChatPage.tsx +37 -0
- package/src/components/chat/ncp/ncp-chat-input.manager.ts +2 -0
- package/src/lib/i18n.ts +2 -0
- package/src/lib/recent-selection.manager.test.ts +68 -0
- package/src/lib/recent-selection.manager.ts +105 -0
- package/dist/assets/ChatPage-2A8MjFld.js +0 -37
- package/dist/assets/RemoteAccessPage-bBI52qCV.js +0 -1
- package/dist/assets/RuntimeConfig-BvifZdub.js +0 -1
- package/dist/assets/index-BRfdTpro.js +0 -8
- package/dist/assets/security-config-C1kqOE-O.js +0 -1
- package/dist/assets/skeleton-CZp_aCj4.js +0 -1
package/dist/index.html
CHANGED
|
@@ -6,9 +6,9 @@
|
|
|
6
6
|
<link rel="icon" type="image/svg+xml" href="/logo.svg" />
|
|
7
7
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
8
8
|
<title>NextClaw</title>
|
|
9
|
-
<script type="module" crossorigin src="/assets/index-
|
|
10
|
-
<link rel="modulepreload" crossorigin href="/assets/vendor-
|
|
11
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
9
|
+
<script type="module" crossorigin src="/assets/index-BBz4mi7g.js"></script>
|
|
10
|
+
<link rel="modulepreload" crossorigin href="/assets/vendor-MCpnpiKt.js">
|
|
11
|
+
<link rel="stylesheet" crossorigin href="/assets/index-CfVmBgkf.css">
|
|
12
12
|
</head>
|
|
13
13
|
|
|
14
14
|
<body>
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nextclaw/ui",
|
|
3
|
-
"version": "0.11.
|
|
3
|
+
"version": "0.11.13",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -28,11 +28,11 @@
|
|
|
28
28
|
"tailwind-merge": "^2.5.4",
|
|
29
29
|
"zod": "^3.23.8",
|
|
30
30
|
"zustand": "^5.0.2",
|
|
31
|
-
"@nextclaw/
|
|
32
|
-
"@nextclaw/
|
|
33
|
-
"@nextclaw/ncp-
|
|
34
|
-
"@nextclaw/agent-chat
|
|
35
|
-
"@nextclaw/ncp-
|
|
31
|
+
"@nextclaw/ncp": "0.4.1",
|
|
32
|
+
"@nextclaw/agent-chat-ui": "0.2.14",
|
|
33
|
+
"@nextclaw/ncp-react": "0.4.5",
|
|
34
|
+
"@nextclaw/agent-chat": "0.1.4",
|
|
35
|
+
"@nextclaw/ncp-http-agent-client": "0.3.5"
|
|
36
36
|
},
|
|
37
37
|
"devDependencies": {
|
|
38
38
|
"@testing-library/react": "^16.3.0",
|
|
@@ -95,17 +95,84 @@ describe('buildModelToolbarSelect', () => {
|
|
|
95
95
|
providerLabel: 'MiniMax'
|
|
96
96
|
}
|
|
97
97
|
],
|
|
98
|
+
recentModelValues: [],
|
|
98
99
|
selectedModel: 'dashscope/qwen3-coder-next',
|
|
99
100
|
isModelOptionsLoading: false,
|
|
100
101
|
hasModelOptions: true,
|
|
101
102
|
onValueChange,
|
|
102
103
|
texts: {
|
|
103
104
|
modelSelectPlaceholder: 'Select model',
|
|
104
|
-
modelNoOptionsLabel: 'No models'
|
|
105
|
+
modelNoOptionsLabel: 'No models',
|
|
106
|
+
recentModelsLabel: 'Recent',
|
|
107
|
+
allModelsLabel: 'All models'
|
|
105
108
|
}
|
|
106
109
|
});
|
|
107
110
|
|
|
108
111
|
expect(select.value).toBe('minimax/MiniMax-M2.7');
|
|
109
112
|
expect(select.selectedLabel).toBe('MiniMax/MiniMax-M2.7');
|
|
113
|
+
expect(select.options[0]).toEqual({
|
|
114
|
+
value: 'minimax/MiniMax-M2.7',
|
|
115
|
+
label: 'MiniMax/MiniMax-M2.7'
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('groups recent models ahead of the remaining catalog', () => {
|
|
120
|
+
const select = buildModelToolbarSelect({
|
|
121
|
+
modelOptions: [
|
|
122
|
+
{
|
|
123
|
+
value: 'openai/gpt-5',
|
|
124
|
+
modelLabel: 'gpt-5',
|
|
125
|
+
providerLabel: 'OpenAI'
|
|
126
|
+
},
|
|
127
|
+
{
|
|
128
|
+
value: 'anthropic/claude-sonnet-4',
|
|
129
|
+
modelLabel: 'claude-sonnet-4',
|
|
130
|
+
providerLabel: 'Anthropic'
|
|
131
|
+
},
|
|
132
|
+
{
|
|
133
|
+
value: 'minimax/MiniMax-M2.7',
|
|
134
|
+
modelLabel: 'MiniMax-M2.7',
|
|
135
|
+
providerLabel: 'MiniMax'
|
|
136
|
+
}
|
|
137
|
+
],
|
|
138
|
+
recentModelValues: ['anthropic/claude-sonnet-4', 'missing/model'],
|
|
139
|
+
selectedModel: 'openai/gpt-5',
|
|
140
|
+
isModelOptionsLoading: false,
|
|
141
|
+
hasModelOptions: true,
|
|
142
|
+
onValueChange: vi.fn(),
|
|
143
|
+
texts: {
|
|
144
|
+
modelSelectPlaceholder: 'Select model',
|
|
145
|
+
modelNoOptionsLabel: 'No models',
|
|
146
|
+
recentModelsLabel: 'Recent',
|
|
147
|
+
allModelsLabel: 'All models'
|
|
148
|
+
}
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
expect(select.groups).toEqual([
|
|
152
|
+
{
|
|
153
|
+
key: 'recent-models',
|
|
154
|
+
label: 'Recent',
|
|
155
|
+
options: [
|
|
156
|
+
{
|
|
157
|
+
value: 'anthropic/claude-sonnet-4',
|
|
158
|
+
label: 'Anthropic/claude-sonnet-4'
|
|
159
|
+
}
|
|
160
|
+
]
|
|
161
|
+
},
|
|
162
|
+
{
|
|
163
|
+
key: 'all-models',
|
|
164
|
+
label: 'All models',
|
|
165
|
+
options: [
|
|
166
|
+
{
|
|
167
|
+
value: 'openai/gpt-5',
|
|
168
|
+
label: 'OpenAI/gpt-5'
|
|
169
|
+
},
|
|
170
|
+
{
|
|
171
|
+
value: 'minimax/MiniMax-M2.7',
|
|
172
|
+
label: 'MiniMax/MiniMax-M2.7'
|
|
173
|
+
}
|
|
174
|
+
]
|
|
175
|
+
}
|
|
176
|
+
]);
|
|
110
177
|
});
|
|
111
178
|
});
|
|
@@ -46,12 +46,20 @@ export type ChatInputBarAdapterTexts = {
|
|
|
46
46
|
noSkillDescription: string;
|
|
47
47
|
modelSelectPlaceholder: string;
|
|
48
48
|
modelNoOptionsLabel: string;
|
|
49
|
+
recentModelsLabel: string;
|
|
50
|
+
allModelsLabel: string;
|
|
49
51
|
sessionTypePlaceholder: string;
|
|
50
52
|
thinkingLabels: Record<ChatThinkingLevel, string>;
|
|
51
53
|
noModelOptionsLabel: string;
|
|
52
54
|
configureProviderLabel: string;
|
|
53
55
|
};
|
|
54
56
|
|
|
57
|
+
function formatModelOptionLabel(option: ChatModelRecord): string {
|
|
58
|
+
const modelLabel = option.modelLabel.trim();
|
|
59
|
+
const providerLabel = option.providerLabel.trim();
|
|
60
|
+
return providerLabel ? `${providerLabel}/${modelLabel}` : modelLabel;
|
|
61
|
+
}
|
|
62
|
+
|
|
55
63
|
export function resolveSlashQuery(draft: string): string | null {
|
|
56
64
|
const match = /^\/([^\s]*)$/.exec(draft);
|
|
57
65
|
if (!match) {
|
|
@@ -231,30 +239,53 @@ export function buildModelStateHint(params: {
|
|
|
231
239
|
|
|
232
240
|
export function buildModelToolbarSelect(params: {
|
|
233
241
|
modelOptions: ChatModelRecord[];
|
|
242
|
+
recentModelValues?: string[];
|
|
234
243
|
selectedModel: string;
|
|
235
244
|
isModelOptionsLoading: boolean;
|
|
236
245
|
hasModelOptions: boolean;
|
|
237
246
|
onValueChange: (value: string) => void;
|
|
238
|
-
texts: Pick<ChatInputBarAdapterTexts, 'modelSelectPlaceholder' | 'modelNoOptionsLabel'>;
|
|
247
|
+
texts: Pick<ChatInputBarAdapterTexts, 'modelSelectPlaceholder' | 'modelNoOptionsLabel' | 'recentModelsLabel' | 'allModelsLabel'>;
|
|
239
248
|
}): ChatToolbarSelect {
|
|
240
249
|
const selectedModelOption = params.modelOptions.find((option) => option.value === params.selectedModel);
|
|
241
250
|
const fallbackModelOption = params.modelOptions[0];
|
|
242
251
|
const resolvedModelOption = selectedModelOption ?? fallbackModelOption;
|
|
243
252
|
const resolvedValue = params.hasModelOptions ? resolvedModelOption?.value : undefined;
|
|
253
|
+
const recentValueSet = new Set(params.recentModelValues ?? []);
|
|
254
|
+
const recentOptions = params.modelOptions.filter((option) => recentValueSet.has(option.value));
|
|
255
|
+
const remainingOptions = params.modelOptions.filter((option) => !recentValueSet.has(option.value));
|
|
256
|
+
const optionGroups =
|
|
257
|
+
recentOptions.length > 0
|
|
258
|
+
? [
|
|
259
|
+
{
|
|
260
|
+
key: 'recent-models',
|
|
261
|
+
label: params.texts.recentModelsLabel,
|
|
262
|
+
options: recentOptions.map((option) => ({
|
|
263
|
+
value: option.value,
|
|
264
|
+
label: formatModelOptionLabel(option)
|
|
265
|
+
}))
|
|
266
|
+
},
|
|
267
|
+
{
|
|
268
|
+
key: 'all-models',
|
|
269
|
+
label: params.texts.allModelsLabel,
|
|
270
|
+
options: remainingOptions.map((option) => ({
|
|
271
|
+
value: option.value,
|
|
272
|
+
label: formatModelOptionLabel(option)
|
|
273
|
+
}))
|
|
274
|
+
}
|
|
275
|
+
].filter((group) => group.options.length > 0)
|
|
276
|
+
: undefined;
|
|
244
277
|
|
|
245
278
|
return {
|
|
246
279
|
key: 'model',
|
|
247
280
|
value: resolvedValue,
|
|
248
281
|
placeholder: params.texts.modelSelectPlaceholder,
|
|
249
|
-
selectedLabel: resolvedModelOption
|
|
250
|
-
? `${resolvedModelOption.providerLabel}/${resolvedModelOption.modelLabel}`
|
|
251
|
-
: undefined,
|
|
282
|
+
selectedLabel: resolvedModelOption ? formatModelOptionLabel(resolvedModelOption) : undefined,
|
|
252
283
|
icon: 'sparkles',
|
|
253
284
|
options: params.modelOptions.map((option) => ({
|
|
254
285
|
value: option.value,
|
|
255
|
-
label: option
|
|
256
|
-
description: option.providerLabel
|
|
286
|
+
label: formatModelOptionLabel(option)
|
|
257
287
|
})),
|
|
288
|
+
groups: optionGroups,
|
|
258
289
|
disabled: !params.hasModelOptions,
|
|
259
290
|
loading: params.isModelOptionsLoading,
|
|
260
291
|
emptyLabel: params.texts.modelNoOptionsLabel,
|
|
@@ -16,6 +16,10 @@ import {
|
|
|
16
16
|
type ChatThinkingLevel
|
|
17
17
|
} from '@/components/chat/adapters/chat-input-bar.adapter';
|
|
18
18
|
import { usePresenter } from '@/components/chat/presenter/chat-presenter-context';
|
|
19
|
+
import {
|
|
20
|
+
CHAT_RECENT_MODELS_MIN_OPTIONS,
|
|
21
|
+
chatRecentModelsManager
|
|
22
|
+
} from '@/components/chat/chat-recent-models.manager';
|
|
19
23
|
import { useI18n } from '@/components/providers/I18nProvider';
|
|
20
24
|
import { useChatInputStore } from '@/components/chat/stores/chat-input.store';
|
|
21
25
|
import { t } from '@/lib/i18n';
|
|
@@ -104,6 +108,14 @@ export function ChatInputBarContainer() {
|
|
|
104
108
|
[snapshot.skillRecords, officialSkillBadgeLabel]
|
|
105
109
|
);
|
|
106
110
|
const modelRecords = useMemo(() => toModelRecords(snapshot.modelOptions), [snapshot.modelOptions]);
|
|
111
|
+
const recentModelValues = useMemo(
|
|
112
|
+
() =>
|
|
113
|
+
chatRecentModelsManager.resolveVisible({
|
|
114
|
+
availableValues: modelRecords.map((option) => option.value),
|
|
115
|
+
minAvailableCount: CHAT_RECENT_MODELS_MIN_OPTIONS
|
|
116
|
+
}),
|
|
117
|
+
[modelRecords, snapshot.selectedModel]
|
|
118
|
+
);
|
|
107
119
|
|
|
108
120
|
const hasModelOptions = modelRecords.length > 0;
|
|
109
121
|
const isModelOptionsLoading = !snapshot.isProviderStateResolved && !hasModelOptions;
|
|
@@ -171,13 +183,16 @@ export function ChatInputBarContainer() {
|
|
|
171
183
|
const toolbarSelects = [
|
|
172
184
|
buildModelToolbarSelect({
|
|
173
185
|
modelOptions: modelRecords,
|
|
186
|
+
recentModelValues,
|
|
174
187
|
selectedModel: snapshot.selectedModel,
|
|
175
188
|
isModelOptionsLoading,
|
|
176
189
|
hasModelOptions,
|
|
177
190
|
onValueChange: presenter.chatInputManager.selectModel,
|
|
178
191
|
texts: {
|
|
179
192
|
modelSelectPlaceholder: t('chatSelectModel'),
|
|
180
|
-
modelNoOptionsLabel: t('chatModelNoOptions')
|
|
193
|
+
modelNoOptionsLabel: t('chatModelNoOptions'),
|
|
194
|
+
recentModelsLabel: t('chatRecentModels'),
|
|
195
|
+
allModelsLabel: t('chatAllModels')
|
|
181
196
|
}
|
|
182
197
|
}),
|
|
183
198
|
buildThinkingToolbarSelect({
|
|
@@ -22,6 +22,7 @@ import { useChatSessionListStore } from '@/components/chat/stores/chat-session-l
|
|
|
22
22
|
import { resolveSessionTypeLabel } from '@/components/chat/useChatSessionTypeState';
|
|
23
23
|
import { useConfirmDialog } from '@/hooks/useConfirmDialog';
|
|
24
24
|
import { normalizeRequestedSkills } from '@/lib/chat-runtime-utils';
|
|
25
|
+
import { appClient } from '@/transport';
|
|
25
26
|
|
|
26
27
|
function buildNcpSendMetadata(payload: {
|
|
27
28
|
model?: string;
|
|
@@ -69,6 +70,7 @@ export function NcpChatPage({ view }: ChatPageProps) {
|
|
|
69
70
|
const { sessionId: routeSessionIdParam } = useParams<{ sessionId?: string }>();
|
|
70
71
|
const threadRef = useRef<HTMLDivElement | null>(null);
|
|
71
72
|
const selectedSessionKeyRef = useRef<string | null>(selectedSessionKey);
|
|
73
|
+
const pendingRealtimeReloadRef = useRef(false);
|
|
72
74
|
const routeSessionKey = useMemo(
|
|
73
75
|
() => parseSessionKeyFromRoute(routeSessionIdParam),
|
|
74
76
|
[routeSessionIdParam]
|
|
@@ -154,6 +156,41 @@ export function NcpChatPage({ view }: ChatPageProps) {
|
|
|
154
156
|
const stopDisabledReason = agent.isRunning ? null : '__preparing__';
|
|
155
157
|
const lastSendError = agent.hydrateError?.message ?? agent.snapshot.error?.message ?? null;
|
|
156
158
|
|
|
159
|
+
useEffect(() => {
|
|
160
|
+
const flushRealtimeReload = () => {
|
|
161
|
+
if (agent.isHydrating || agent.isRunning || agent.isSending) {
|
|
162
|
+
pendingRealtimeReloadRef.current = true;
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
pendingRealtimeReloadRef.current = false;
|
|
166
|
+
void agent.reloadSeed();
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
return appClient.subscribe((event) => {
|
|
170
|
+
if (event.type === 'session.summary.upsert') {
|
|
171
|
+
if (event.payload.summary.sessionId !== activeSessionId) {
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
flushRealtimeReload();
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
if (event.type === 'session.updated' && event.payload.sessionKey === activeSessionId) {
|
|
178
|
+
flushRealtimeReload();
|
|
179
|
+
}
|
|
180
|
+
});
|
|
181
|
+
}, [activeSessionId, agent.isHydrating, agent.isRunning, agent.isSending, agent.reloadSeed]);
|
|
182
|
+
|
|
183
|
+
useEffect(() => {
|
|
184
|
+
if (!pendingRealtimeReloadRef.current) {
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
if (agent.isHydrating || agent.isRunning || agent.isSending) {
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
pendingRealtimeReloadRef.current = false;
|
|
191
|
+
void agent.reloadSeed();
|
|
192
|
+
}, [agent.isHydrating, agent.isRunning, agent.isSending, agent.reloadSeed]);
|
|
193
|
+
|
|
157
194
|
useEffect(() => {
|
|
158
195
|
presenter.chatStreamActionsManager.bind({
|
|
159
196
|
sendMessage: async (payload) => {
|
|
@@ -19,6 +19,7 @@ import type { ChatInputSnapshot } from '@/components/chat/stores/chat-input.stor
|
|
|
19
19
|
import type { ChatStreamActionsManager } from '@/components/chat/managers/chat-stream-actions.manager';
|
|
20
20
|
import type { ChatUiManager } from '@/components/chat/managers/chat-ui.manager';
|
|
21
21
|
import { ChatSessionPreferenceSync } from '@/components/chat/chat-session-preference-sync';
|
|
22
|
+
import { chatRecentModelsManager } from '@/components/chat/chat-recent-models.manager';
|
|
22
23
|
import type { ChatModelOption } from '@/components/chat/chat-input.types';
|
|
23
24
|
import { normalizeSessionType } from '@/components/chat/useChatSessionTypeState';
|
|
24
25
|
|
|
@@ -242,6 +243,7 @@ export class NcpChatInputManager {
|
|
|
242
243
|
|
|
243
244
|
selectModel = (value: string) => {
|
|
244
245
|
this.setSelectedModel(value);
|
|
246
|
+
chatRecentModelsManager.remember(value);
|
|
245
247
|
this.sessionPreferenceSync.syncSelectedSessionPreferences();
|
|
246
248
|
};
|
|
247
249
|
|
package/src/lib/i18n.ts
CHANGED
|
@@ -185,6 +185,8 @@ export const LABELS: Record<string, { zh: string; en: string }> = {
|
|
|
185
185
|
zh: 'Agent 默认模型标识,使用带 provider 前缀的格式。例如:openai/gpt-5.1、anthropic/claude-opus-4-1、deepseek/deepseek-chat、minimax/MiniMax-M2.5、openrouter/openai/gpt-5.3-codex。',
|
|
186
186
|
en: 'Default model identifier used by the agent. Use provider-prefixed format. Examples: openai/gpt-5.1 · anthropic/claude-opus-4-1 · deepseek/deepseek-chat · minimax/MiniMax-M2.5 · openrouter/openai/gpt-5.3-codex.'
|
|
187
187
|
},
|
|
188
|
+
chatRecentModels: { zh: '最近选择', en: 'Recent' },
|
|
189
|
+
chatAllModels: { zh: '全部模型', en: 'All models' },
|
|
188
190
|
maxToolIterations: { zh: '最大工具迭代次数', en: 'Max Tool Iterations' },
|
|
189
191
|
saveChanges: { zh: '保存变更', en: 'Save Changes' },
|
|
190
192
|
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { RecentSelectionManager } from '@/lib/recent-selection.manager';
|
|
2
|
+
|
|
3
|
+
describe('RecentSelectionManager', () => {
|
|
4
|
+
const storageKey = 'test.recent-selection-manager';
|
|
5
|
+
let storageState: Record<string, string>;
|
|
6
|
+
let storage: Pick<Storage, 'getItem' | 'setItem'>;
|
|
7
|
+
|
|
8
|
+
beforeEach(() => {
|
|
9
|
+
storageState = {};
|
|
10
|
+
storage = {
|
|
11
|
+
getItem: (key) => storageState[key] ?? null,
|
|
12
|
+
setItem: (key, value) => {
|
|
13
|
+
storageState[key] = value;
|
|
14
|
+
}
|
|
15
|
+
};
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('stores recent values in LRU order and respects the size limit', () => {
|
|
19
|
+
const manager = new RecentSelectionManager({ storageKey, limit: 3, storage });
|
|
20
|
+
|
|
21
|
+
manager.remember('openai/gpt-5');
|
|
22
|
+
manager.remember('anthropic/claude-sonnet-4');
|
|
23
|
+
manager.remember('minimax/MiniMax-M2.7');
|
|
24
|
+
manager.remember('openai/gpt-5');
|
|
25
|
+
|
|
26
|
+
expect(manager.read()).toEqual([
|
|
27
|
+
'openai/gpt-5',
|
|
28
|
+
'minimax/MiniMax-M2.7',
|
|
29
|
+
'anthropic/claude-sonnet-4'
|
|
30
|
+
]);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('filters recent values by the currently available list and threshold', () => {
|
|
34
|
+
const manager = new RecentSelectionManager({ storageKey, limit: 4, storage });
|
|
35
|
+
manager.remember('openai/gpt-5');
|
|
36
|
+
manager.remember('anthropic/claude-sonnet-4');
|
|
37
|
+
manager.remember('missing/model');
|
|
38
|
+
|
|
39
|
+
expect(
|
|
40
|
+
manager.resolveVisible({
|
|
41
|
+
availableValues: [
|
|
42
|
+
'openai/gpt-5',
|
|
43
|
+
'anthropic/claude-sonnet-4',
|
|
44
|
+
'minimax/MiniMax-M2.7',
|
|
45
|
+
'deepseek/deepseek-chat',
|
|
46
|
+
'openrouter/openai/gpt-4.1',
|
|
47
|
+
'gemini/gemini-2.5-pro'
|
|
48
|
+
],
|
|
49
|
+
minAvailableCount: 5,
|
|
50
|
+
limit: 2
|
|
51
|
+
})
|
|
52
|
+
).toEqual(['anthropic/claude-sonnet-4', 'openai/gpt-5']);
|
|
53
|
+
|
|
54
|
+
expect(
|
|
55
|
+
manager.resolveVisible({
|
|
56
|
+
availableValues: ['openai/gpt-5', 'anthropic/claude-sonnet-4', 'minimax/MiniMax-M2.7'],
|
|
57
|
+
minAvailableCount: 5
|
|
58
|
+
})
|
|
59
|
+
).toEqual([]);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('returns an empty list when storage content is malformed', () => {
|
|
63
|
+
storageState[storageKey] = '{broken-json';
|
|
64
|
+
const manager = new RecentSelectionManager({ storageKey, limit: 3, storage });
|
|
65
|
+
|
|
66
|
+
expect(manager.read()).toEqual([]);
|
|
67
|
+
});
|
|
68
|
+
});
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
type RecentSelectionManagerOptions = {
|
|
2
|
+
storageKey: string;
|
|
3
|
+
limit: number;
|
|
4
|
+
storage?: Pick<Storage, 'getItem' | 'setItem'> | null;
|
|
5
|
+
};
|
|
6
|
+
|
|
7
|
+
type VisibleRecentSelectionParams = {
|
|
8
|
+
availableValues: string[];
|
|
9
|
+
minAvailableCount: number;
|
|
10
|
+
limit?: number;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export class RecentSelectionManager {
|
|
14
|
+
constructor(private readonly options: RecentSelectionManagerOptions) {}
|
|
15
|
+
|
|
16
|
+
read(): string[] {
|
|
17
|
+
const storage = this.getStorage();
|
|
18
|
+
if (!storage) {
|
|
19
|
+
return [];
|
|
20
|
+
}
|
|
21
|
+
try {
|
|
22
|
+
return this.normalizeList(JSON.parse(storage.getItem(this.options.storageKey) ?? '[]'));
|
|
23
|
+
} catch {
|
|
24
|
+
return [];
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
remember(value: string): string[] {
|
|
29
|
+
const normalizedValue = this.normalizeValue(value);
|
|
30
|
+
if (!normalizedValue) {
|
|
31
|
+
return this.read();
|
|
32
|
+
}
|
|
33
|
+
const next = [normalizedValue, ...this.read().filter((item) => item !== normalizedValue)].slice(0, this.options.limit);
|
|
34
|
+
this.write(next);
|
|
35
|
+
return next;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
resolveVisible(params: VisibleRecentSelectionParams): string[] {
|
|
39
|
+
const availableValues = this.normalizeList(params.availableValues, Number.POSITIVE_INFINITY);
|
|
40
|
+
if (availableValues.length <= params.minAvailableCount) {
|
|
41
|
+
return [];
|
|
42
|
+
}
|
|
43
|
+
const availableSet = new Set(availableValues);
|
|
44
|
+
const visible: string[] = [];
|
|
45
|
+
const maxVisibleItems = Math.max(1, params.limit ?? this.options.limit);
|
|
46
|
+
for (const value of this.read()) {
|
|
47
|
+
if (!availableSet.has(value) || visible.includes(value)) {
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
visible.push(value);
|
|
51
|
+
if (visible.length >= maxVisibleItems) {
|
|
52
|
+
break;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return visible;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
private write(values: string[]): void {
|
|
59
|
+
const storage = this.getStorage();
|
|
60
|
+
if (!storage) {
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
try {
|
|
64
|
+
storage.setItem(this.options.storageKey, JSON.stringify(this.normalizeList(values)));
|
|
65
|
+
} catch {
|
|
66
|
+
// Ignore storage write failures and keep the runtime behavior deterministic.
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
private getStorage(): Storage | null {
|
|
71
|
+
if (Object.prototype.hasOwnProperty.call(this.options, 'storage')) {
|
|
72
|
+
return (this.options.storage as Storage | null | undefined) ?? null;
|
|
73
|
+
}
|
|
74
|
+
if (typeof window === 'undefined') {
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
return window.localStorage;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
private normalizeList(values: unknown, limit = this.options.limit): string[] {
|
|
81
|
+
if (!Array.isArray(values)) {
|
|
82
|
+
return [];
|
|
83
|
+
}
|
|
84
|
+
const deduped: string[] = [];
|
|
85
|
+
for (const value of values) {
|
|
86
|
+
const normalized = this.normalizeValue(value);
|
|
87
|
+
if (!normalized || deduped.includes(normalized)) {
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
deduped.push(normalized);
|
|
91
|
+
if (deduped.length >= limit) {
|
|
92
|
+
break;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return deduped;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
private normalizeValue(value: unknown): string | null {
|
|
99
|
+
if (typeof value !== 'string') {
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
const normalized = value.trim();
|
|
103
|
+
return normalized.length > 0 ? normalized : null;
|
|
104
|
+
}
|
|
105
|
+
}
|