@chat-js/cli 0.2.1 → 0.4.0
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/dist/index.js +216 -171
- package/package.json +1 -1
- package/templates/chat-app/CHANGELOG.md +19 -0
- package/templates/chat-app/app/(chat)/actions.ts +9 -9
- package/templates/chat-app/app/(chat)/api/chat/prepare/route.ts +94 -0
- package/templates/chat-app/app/(chat)/api/chat/route.ts +97 -14
- package/templates/chat-app/chat.config.ts +144 -156
- package/templates/chat-app/components/chat-sync.tsx +6 -3
- package/templates/chat-app/components/feedback-actions.tsx +7 -3
- package/templates/chat-app/components/message-editor.tsx +8 -3
- package/templates/chat-app/components/message-siblings.tsx +14 -1
- package/templates/chat-app/components/model-selector.tsx +669 -407
- package/templates/chat-app/components/multimodal-input.tsx +252 -18
- package/templates/chat-app/components/parallel-response-cards.tsx +157 -0
- package/templates/chat-app/components/part/text-message-part.tsx +9 -5
- package/templates/chat-app/components/retry-button.tsx +25 -8
- package/templates/chat-app/components/user-message.tsx +136 -125
- package/templates/chat-app/hooks/chat-sync-hooks.ts +11 -0
- package/templates/chat-app/hooks/use-navigate-to-message.ts +39 -0
- package/templates/chat-app/lib/ai/gateway-model-defaults.ts +154 -100
- package/templates/chat-app/lib/ai/gateways/openrouter-gateway.ts +2 -2
- package/templates/chat-app/lib/ai/tools/generate-image.ts +9 -2
- package/templates/chat-app/lib/ai/tools/generate-video.ts +3 -0
- package/templates/chat-app/lib/ai/types.ts +74 -3
- package/templates/chat-app/lib/config-schema.ts +131 -132
- package/templates/chat-app/lib/config.ts +2 -2
- package/templates/chat-app/lib/db/migrations/0044_gray_red_shift.sql +5 -0
- package/templates/chat-app/lib/db/migrations/meta/0044_snapshot.json +1567 -0
- package/templates/chat-app/lib/db/migrations/meta/_journal.json +8 -1
- package/templates/chat-app/lib/db/queries.ts +84 -4
- package/templates/chat-app/lib/db/schema.ts +4 -1
- package/templates/chat-app/lib/message-conversion.ts +14 -2
- package/templates/chat-app/lib/stores/hooks-threads.ts +37 -1
- package/templates/chat-app/lib/stores/with-threads.test.ts +137 -0
- package/templates/chat-app/lib/stores/with-threads.ts +157 -4
- package/templates/chat-app/lib/thread-utils.ts +23 -2
- package/templates/chat-app/package.json +1 -1
- package/templates/chat-app/providers/chat-input-provider.tsx +40 -2
- package/templates/chat-app/scripts/db-branch-delete.sh +7 -1
- package/templates/chat-app/scripts/db-branch-use.sh +7 -1
- package/templates/chat-app/scripts/with-db.sh +7 -1
- package/templates/chat-app/vitest.config.ts +2 -0
|
@@ -1,34 +1,50 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import { ChevronRightIcon, ChevronUpIcon, FilterIcon } from "lucide-react";
|
|
3
|
+
import { CheckIcon, ChevronDownIcon, ChevronRightIcon, ChevronUpIcon, FilterIcon } from "lucide-react";
|
|
4
4
|
import Link from "next/link";
|
|
5
5
|
import {
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
6
|
+
memo,
|
|
7
|
+
type ReactNode,
|
|
8
|
+
startTransition,
|
|
9
|
+
useCallback,
|
|
10
|
+
useEffect,
|
|
11
|
+
useMemo,
|
|
12
|
+
useOptimistic,
|
|
13
|
+
useRef,
|
|
14
|
+
useState,
|
|
12
15
|
} from "react";
|
|
13
16
|
import { Badge } from "@/components/ui/badge";
|
|
14
17
|
import { Button } from "@/components/ui/button";
|
|
15
18
|
import { Checkbox } from "@/components/ui/checkbox";
|
|
16
19
|
import {
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
20
|
+
Command,
|
|
21
|
+
CommandEmpty,
|
|
22
|
+
CommandGroup,
|
|
23
|
+
CommandInput,
|
|
24
|
+
CommandList,
|
|
25
|
+
CommandItem as UICommandItem,
|
|
23
26
|
} from "@/components/ui/command";
|
|
27
|
+
import {
|
|
28
|
+
DropdownMenu,
|
|
29
|
+
DropdownMenuContent,
|
|
30
|
+
DropdownMenuItem,
|
|
31
|
+
DropdownMenuTrigger,
|
|
32
|
+
} from "@/components/ui/dropdown-menu";
|
|
24
33
|
import { Label } from "@/components/ui/label";
|
|
25
34
|
import {
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
35
|
+
Popover,
|
|
36
|
+
PopoverContent,
|
|
37
|
+
PopoverTrigger,
|
|
29
38
|
} from "@/components/ui/popover";
|
|
39
|
+
import { Switch } from "@/components/ui/switch";
|
|
30
40
|
import { LoginCtaBanner } from "@/components/upgrade-cta/login-cta-banner";
|
|
31
41
|
import type { AppModelDefinition, AppModelId } from "@/lib/ai/app-models";
|
|
42
|
+
import {
|
|
43
|
+
getPrimarySelectedModelId,
|
|
44
|
+
isSelectedModelCounts,
|
|
45
|
+
type SelectedModelValue,
|
|
46
|
+
} from "@/lib/ai/types";
|
|
47
|
+
import { config } from "@/lib/config";
|
|
32
48
|
import { getEnabledFeatures } from "@/lib/features-config";
|
|
33
49
|
import { ANONYMOUS_LIMITS } from "@/lib/types/anonymous";
|
|
34
50
|
import { cn } from "@/lib/utils";
|
|
@@ -40,414 +56,660 @@ type FeatureFilter = Record<string, boolean>;
|
|
|
40
56
|
|
|
41
57
|
const enabledFeatures = getEnabledFeatures();
|
|
42
58
|
const initialFilters = enabledFeatures.reduce<FeatureFilter>((acc, feature) => {
|
|
43
|
-
|
|
44
|
-
|
|
59
|
+
acc[feature.key] = false;
|
|
60
|
+
return acc;
|
|
45
61
|
}, {});
|
|
46
62
|
|
|
47
63
|
function getFeatureIcons(model: AppModelDefinition) {
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
64
|
+
const icons: React.ReactNode[] = [];
|
|
65
|
+
const enabled = getEnabledFeatures();
|
|
66
|
+
|
|
67
|
+
const featureIconMap = [
|
|
68
|
+
{
|
|
69
|
+
key: "functionCalling",
|
|
70
|
+
condition: model.toolCall,
|
|
71
|
+
config: enabled.find((f) => f.key === "functionCalling"),
|
|
72
|
+
},
|
|
73
|
+
{
|
|
74
|
+
key: "imageInput",
|
|
75
|
+
condition: model.input?.image,
|
|
76
|
+
config: enabled.find((f) => f.key === "imageInput"),
|
|
77
|
+
},
|
|
78
|
+
{
|
|
79
|
+
key: "pdfInput",
|
|
80
|
+
condition: model.input?.pdf,
|
|
81
|
+
config: enabled.find((f) => f.key === "pdfInput"),
|
|
82
|
+
},
|
|
83
|
+
];
|
|
84
|
+
|
|
85
|
+
for (const { condition, config } of featureIconMap) {
|
|
86
|
+
if (condition && config) {
|
|
87
|
+
const IconComponent = config.icon;
|
|
88
|
+
icons.push(
|
|
89
|
+
<div
|
|
90
|
+
className="flex items-center"
|
|
91
|
+
key={config.key}
|
|
92
|
+
title={config.description}
|
|
93
|
+
>
|
|
94
|
+
<IconComponent className="h-3 w-3 text-muted-foreground" />
|
|
95
|
+
</div>,
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return icons;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function buildMultiModelSelection(
|
|
104
|
+
modelIds: AppModelId[],
|
|
105
|
+
): Record<AppModelId, number> {
|
|
106
|
+
return modelIds.reduce<Record<AppModelId, number>>(
|
|
107
|
+
(acc, modelId) => {
|
|
108
|
+
acc[modelId] = 1;
|
|
109
|
+
return acc;
|
|
110
|
+
},
|
|
111
|
+
{} as Record<AppModelId, number>,
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function getSelectionCount(selection: SelectedModelValue): number {
|
|
116
|
+
if (typeof selection === "string") {
|
|
117
|
+
return 1;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return Object.values(selection).reduce((count, value) => count + value, 0);
|
|
85
121
|
}
|
|
86
122
|
|
|
87
123
|
function PureCommandItem({
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
124
|
+
model,
|
|
125
|
+
disabled,
|
|
126
|
+
isSelected,
|
|
127
|
+
count,
|
|
128
|
+
selectionControl,
|
|
129
|
+
onSelect,
|
|
130
|
+
onCountChange,
|
|
92
131
|
}: {
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
132
|
+
model: AppModelDefinition;
|
|
133
|
+
disabled?: boolean;
|
|
134
|
+
isSelected: boolean;
|
|
135
|
+
count?: number;
|
|
136
|
+
selectionControl?: ReactNode;
|
|
137
|
+
onSelect: () => void;
|
|
138
|
+
onCountChange?: (delta: number) => void;
|
|
97
139
|
}) {
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
140
|
+
const featureIcons = useMemo(() => getFeatureIcons(model), [model]);
|
|
141
|
+
const searchValue = useMemo(
|
|
142
|
+
() =>
|
|
143
|
+
`${model.name} ${model.reasoning ? "reasoning" : ""} ${model.owned_by} `.toLowerCase(),
|
|
144
|
+
[model],
|
|
145
|
+
);
|
|
146
|
+
|
|
147
|
+
const reasoningConfig = useMemo(
|
|
148
|
+
() => getEnabledFeatures().find((f) => f.key === "reasoning"),
|
|
149
|
+
[],
|
|
150
|
+
);
|
|
151
|
+
|
|
152
|
+
return (
|
|
153
|
+
<UICommandItem
|
|
154
|
+
className={cn(
|
|
155
|
+
"flex h-9 w-full cursor-pointer items-center justify-between px-3 py-1.5 transition-all",
|
|
156
|
+
isSelected && "border-l-2 border-l-primary bg-primary/10",
|
|
157
|
+
disabled && "cursor-not-allowed opacity-50",
|
|
158
|
+
)}
|
|
159
|
+
onSelect={() => !disabled && onSelect()}
|
|
160
|
+
value={searchValue}
|
|
161
|
+
>
|
|
162
|
+
<div className="flex min-w-0 flex-1 items-center gap-2.5">
|
|
163
|
+
{selectionControl}
|
|
164
|
+
<div className="shrink-0">
|
|
165
|
+
<ModelSelectorLogo modelId={model.id} />
|
|
166
|
+
</div>
|
|
167
|
+
<span className="flex items-center gap-1.5 truncate font-medium text-sm">
|
|
168
|
+
{model.name}
|
|
169
|
+
{model.reasoning && reasoningConfig && (
|
|
170
|
+
<span
|
|
171
|
+
className="inline-flex shrink-0 items-center gap-1"
|
|
172
|
+
title={reasoningConfig.description}
|
|
173
|
+
>
|
|
174
|
+
<reasoningConfig.icon className="h-3 w-3 text-muted-foreground" />
|
|
175
|
+
</span>
|
|
176
|
+
)}
|
|
177
|
+
</span>
|
|
178
|
+
</div>
|
|
179
|
+
<div className="flex shrink-0 items-center gap-1">
|
|
180
|
+
{featureIcons}
|
|
181
|
+
{isSelected && onCountChange && count !== undefined && (
|
|
182
|
+
<DropdownMenu>
|
|
183
|
+
<DropdownMenuTrigger
|
|
184
|
+
asChild
|
|
185
|
+
onClick={(e) => e.stopPropagation()}
|
|
186
|
+
onMouseDown={(e) => e.stopPropagation()}
|
|
187
|
+
>
|
|
188
|
+
<button
|
|
189
|
+
className="flex items-center gap-0.5 rounded-full bg-primary/15 px-1.5 py-0.5 font-semibold text-foreground text-xs tabular-nums hover:bg-primary/25"
|
|
190
|
+
type="button"
|
|
191
|
+
>
|
|
192
|
+
{count}×
|
|
193
|
+
<ChevronDownIcon className="h-2.5 w-2.5" />
|
|
194
|
+
</button>
|
|
195
|
+
</DropdownMenuTrigger>
|
|
196
|
+
<DropdownMenuContent
|
|
197
|
+
align="end"
|
|
198
|
+
onKeyDown={(e) => e.stopPropagation()}
|
|
199
|
+
>
|
|
200
|
+
{[1, 2, 3, 4].map((n) => (
|
|
201
|
+
<DropdownMenuItem
|
|
202
|
+
key={n}
|
|
203
|
+
onClick={(e) => {
|
|
204
|
+
e.stopPropagation();
|
|
205
|
+
onCountChange(n - count);
|
|
206
|
+
}}
|
|
207
|
+
>
|
|
208
|
+
{n}x
|
|
209
|
+
{n === count && (
|
|
210
|
+
<CheckIcon className="ml-auto h-3 w-3" />
|
|
211
|
+
)}
|
|
212
|
+
</DropdownMenuItem>
|
|
213
|
+
))}
|
|
214
|
+
</DropdownMenuContent>
|
|
215
|
+
</DropdownMenu>
|
|
216
|
+
)}
|
|
217
|
+
</div>
|
|
218
|
+
</UICommandItem>
|
|
219
|
+
);
|
|
141
220
|
}
|
|
142
221
|
|
|
143
222
|
const CommandItem = memo(
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
223
|
+
PureCommandItem,
|
|
224
|
+
(prev, next) =>
|
|
225
|
+
prev.model.id === next.model.id &&
|
|
226
|
+
prev.disabled === next.disabled &&
|
|
227
|
+
prev.isSelected === next.isSelected &&
|
|
228
|
+
prev.count === next.count &&
|
|
229
|
+
(prev.onCountChange !== undefined) === (next.onCountChange !== undefined),
|
|
149
230
|
);
|
|
150
231
|
|
|
151
232
|
function PureModelSelector({
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
233
|
+
selectedModelId,
|
|
234
|
+
selectedModelSelection,
|
|
235
|
+
className,
|
|
236
|
+
onModelSelectionChangeAction,
|
|
155
237
|
}: {
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
238
|
+
selectedModelId: AppModelId;
|
|
239
|
+
selectedModelSelection: SelectedModelValue;
|
|
240
|
+
onModelSelectionChangeAction?: (selection: SelectedModelValue) => void;
|
|
241
|
+
className?: string;
|
|
159
242
|
}) {
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
243
|
+
const { data: session } = useSession();
|
|
244
|
+
const isAnonymous = !session?.user;
|
|
245
|
+
const { models: chatModels, allModels } = useChatModels();
|
|
246
|
+
|
|
247
|
+
const [open, setOpen] = useState(false);
|
|
248
|
+
const [filterOpen, setFilterOpen] = useState(false);
|
|
249
|
+
const [optimisticSelection, setOptimisticSelection] = useOptimistic(
|
|
250
|
+
selectedModelSelection,
|
|
251
|
+
);
|
|
252
|
+
// Ref so callbacks don't capture stale optimisticSelection in their closure
|
|
253
|
+
const optimisticSelectionRef = useRef(optimisticSelection);
|
|
254
|
+
optimisticSelectionRef.current = optimisticSelection;
|
|
255
|
+
const [featureFilters, setFeatureFilters] =
|
|
256
|
+
useState<FeatureFilter>(initialFilters);
|
|
257
|
+
const [useMultipleModels, setUseMultipleModels] = useState(
|
|
258
|
+
isSelectedModelCounts(selectedModelSelection),
|
|
259
|
+
);
|
|
260
|
+
|
|
261
|
+
interface ModelItem {
|
|
262
|
+
disabled: boolean;
|
|
263
|
+
model: AppModelDefinition;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
useEffect(() => {
|
|
267
|
+
setUseMultipleModels(isSelectedModelCounts(selectedModelSelection));
|
|
268
|
+
}, [selectedModelSelection]);
|
|
269
|
+
|
|
270
|
+
const optimisticModelId = useMemo(
|
|
271
|
+
() => getPrimarySelectedModelId(optimisticSelection) ?? selectedModelId,
|
|
272
|
+
[optimisticSelection, selectedModelId],
|
|
273
|
+
);
|
|
274
|
+
|
|
275
|
+
const selectedModelIds = useMemo(() => {
|
|
276
|
+
if (typeof optimisticSelection === "string") {
|
|
277
|
+
return new Set<AppModelId>([optimisticSelection]);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
return new Set<AppModelId>(
|
|
281
|
+
Object.entries(optimisticSelection)
|
|
282
|
+
.filter(([, count]) => count > 0)
|
|
283
|
+
.map(([modelId]) => modelId as AppModelId),
|
|
284
|
+
);
|
|
285
|
+
}, [optimisticSelection]);
|
|
286
|
+
|
|
287
|
+
const models = useMemo<ModelItem[]>(
|
|
288
|
+
() =>
|
|
289
|
+
chatModels.map((m) => ({
|
|
290
|
+
model: m,
|
|
291
|
+
disabled:
|
|
292
|
+
isAnonymous &&
|
|
293
|
+
!(
|
|
294
|
+
ANONYMOUS_LIMITS.AVAILABLE_MODELS as readonly AppModelId[]
|
|
295
|
+
).includes(m.id),
|
|
296
|
+
})),
|
|
297
|
+
[isAnonymous, chatModels],
|
|
298
|
+
);
|
|
299
|
+
|
|
300
|
+
const hasDisabledModels = useMemo(
|
|
301
|
+
() => models.some((m) => m.disabled),
|
|
302
|
+
[models],
|
|
303
|
+
);
|
|
304
|
+
|
|
305
|
+
const filteredModels = useMemo(() => {
|
|
306
|
+
const hasActiveFilters = Object.values(featureFilters).some(Boolean);
|
|
307
|
+
if (!hasActiveFilters) {
|
|
308
|
+
return models;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
return models.filter(({ model }) =>
|
|
312
|
+
Object.entries(featureFilters).every(([key, isActive]) => {
|
|
313
|
+
if (!isActive) {
|
|
314
|
+
return true;
|
|
315
|
+
}
|
|
316
|
+
switch (key) {
|
|
317
|
+
case "reasoning":
|
|
318
|
+
return model.reasoning;
|
|
319
|
+
case "functionCalling":
|
|
320
|
+
return model.toolCall;
|
|
321
|
+
case "imageInput":
|
|
322
|
+
return model.input?.image;
|
|
323
|
+
case "pdfInput":
|
|
324
|
+
return model.input?.pdf;
|
|
325
|
+
case "audioInput":
|
|
326
|
+
return model.input?.audio;
|
|
327
|
+
case "imageOutput":
|
|
328
|
+
return model.output?.image;
|
|
329
|
+
case "audioOutput":
|
|
330
|
+
return model.output?.audio;
|
|
331
|
+
default:
|
|
332
|
+
return true;
|
|
333
|
+
}
|
|
334
|
+
}),
|
|
335
|
+
);
|
|
336
|
+
}, [models, featureFilters]);
|
|
337
|
+
|
|
338
|
+
const selectedItem = useMemo<ModelItem | null>(() => {
|
|
339
|
+
// First try to find in filtered models (user's enabled models)
|
|
340
|
+
const found = models.find((m) => m.model.id === optimisticModelId);
|
|
341
|
+
if (found) {
|
|
342
|
+
return found;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// Fallback: look in all models to at least display the model name
|
|
346
|
+
// This handles cases where preferences are loading or model was disabled
|
|
347
|
+
const fallbackModel = allModels.find((m) => m.id === optimisticModelId);
|
|
348
|
+
if (fallbackModel) {
|
|
349
|
+
return {
|
|
350
|
+
model: fallbackModel,
|
|
351
|
+
disabled:
|
|
352
|
+
isAnonymous &&
|
|
353
|
+
!(
|
|
354
|
+
ANONYMOUS_LIMITS.AVAILABLE_MODELS as readonly AppModelId[]
|
|
355
|
+
).includes(fallbackModel.id),
|
|
356
|
+
} satisfies ModelItem;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
return null;
|
|
360
|
+
}, [models, allModels, optimisticModelId, isAnonymous]);
|
|
361
|
+
const reasoningConfig = useMemo(
|
|
362
|
+
() => getEnabledFeatures().find((f) => f.key === "reasoning"),
|
|
363
|
+
[],
|
|
364
|
+
);
|
|
365
|
+
const activeFilterCount = useMemo(
|
|
366
|
+
() => Object.values(featureFilters).filter(Boolean).length,
|
|
367
|
+
[featureFilters],
|
|
368
|
+
);
|
|
369
|
+
const selectedModelCount = useMemo(
|
|
370
|
+
() => getSelectionCount(optimisticSelection),
|
|
371
|
+
[optimisticSelection],
|
|
372
|
+
);
|
|
373
|
+
const triggerLabel = useMemo(() => {
|
|
374
|
+
if (useMultipleModels && selectedModelCount > 1) {
|
|
375
|
+
return `${selectedItem?.model.name || "Selected model"} +${selectedModelCount - 1}`;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
return selectedItem?.model.name || "Select model";
|
|
379
|
+
}, [selectedItem?.model.name, selectedModelCount, useMultipleModels]);
|
|
380
|
+
|
|
381
|
+
const selectSingleModel = useCallback(
|
|
382
|
+
(id: AppModelId) => {
|
|
383
|
+
startTransition(() => {
|
|
384
|
+
setOptimisticSelection(id);
|
|
385
|
+
onModelSelectionChangeAction?.(id);
|
|
386
|
+
setOpen(false);
|
|
387
|
+
});
|
|
388
|
+
},
|
|
389
|
+
[onModelSelectionChangeAction, setOptimisticSelection],
|
|
390
|
+
);
|
|
391
|
+
|
|
392
|
+
const toggleMultiModel = useCallback(
|
|
393
|
+
(id: AppModelId) => {
|
|
394
|
+
startTransition(() => {
|
|
395
|
+
const current = optimisticSelectionRef.current;
|
|
396
|
+
const currentCounts: Record<AppModelId, number> =
|
|
397
|
+
typeof current === "string"
|
|
398
|
+
? ({ [current]: 1 } as Record<AppModelId, number>)
|
|
399
|
+
: (current as Record<AppModelId, number>);
|
|
400
|
+
|
|
401
|
+
const isAlreadySelected = (currentCounts[id] ?? 0) > 0;
|
|
402
|
+
|
|
403
|
+
let nextSelection: Record<AppModelId, number>;
|
|
404
|
+
if (isAlreadySelected) {
|
|
405
|
+
const remaining = Object.entries(currentCounts).filter(
|
|
406
|
+
([k, v]) => k !== id && v > 0,
|
|
407
|
+
);
|
|
408
|
+
if (remaining.length === 0) return;
|
|
409
|
+
nextSelection = Object.fromEntries(remaining) as Record<
|
|
410
|
+
AppModelId,
|
|
411
|
+
number
|
|
412
|
+
>;
|
|
413
|
+
} else {
|
|
414
|
+
nextSelection = { ...currentCounts, [id]: 1 } as Record<
|
|
415
|
+
AppModelId,
|
|
416
|
+
number
|
|
417
|
+
>;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
setOptimisticSelection(nextSelection);
|
|
421
|
+
onModelSelectionChangeAction?.(nextSelection);
|
|
422
|
+
});
|
|
423
|
+
},
|
|
424
|
+
[onModelSelectionChangeAction, setOptimisticSelection],
|
|
425
|
+
);
|
|
426
|
+
|
|
427
|
+
const handleCountChange = useCallback(
|
|
428
|
+
(id: AppModelId, delta: number) => {
|
|
429
|
+
startTransition(() => {
|
|
430
|
+
const current = optimisticSelectionRef.current;
|
|
431
|
+
const currentCounts: Record<AppModelId, number> =
|
|
432
|
+
typeof current === "string"
|
|
433
|
+
? ({ [current]: 1 } as Record<AppModelId, number>)
|
|
434
|
+
: (current as Record<AppModelId, number>);
|
|
435
|
+
|
|
436
|
+
const newCount = (currentCounts[id] ?? 0) + delta;
|
|
437
|
+
let nextSelection: SelectedModelValue;
|
|
438
|
+
|
|
439
|
+
if (newCount <= 0) {
|
|
440
|
+
const remaining = Object.entries(currentCounts).filter(
|
|
441
|
+
([k, v]) => k !== id && v > 0,
|
|
442
|
+
);
|
|
443
|
+
if (remaining.length === 0) return;
|
|
444
|
+
nextSelection = Object.fromEntries(remaining) as Record<
|
|
445
|
+
AppModelId,
|
|
446
|
+
number
|
|
447
|
+
>;
|
|
448
|
+
} else {
|
|
449
|
+
nextSelection = { ...currentCounts, [id]: newCount } as Record<
|
|
450
|
+
AppModelId,
|
|
451
|
+
number
|
|
452
|
+
>;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
setOptimisticSelection(nextSelection);
|
|
456
|
+
onModelSelectionChangeAction?.(nextSelection);
|
|
457
|
+
});
|
|
458
|
+
},
|
|
459
|
+
[onModelSelectionChangeAction, setOptimisticSelection],
|
|
460
|
+
);
|
|
461
|
+
|
|
462
|
+
const handleMultipleModelsToggle = useCallback(
|
|
463
|
+
(checked: boolean) => {
|
|
464
|
+
setUseMultipleModels(checked);
|
|
465
|
+
|
|
466
|
+
if (checked) {
|
|
467
|
+
const nextSelection = buildMultiModelSelection([optimisticModelId]);
|
|
468
|
+
startTransition(() => {
|
|
469
|
+
setOptimisticSelection(nextSelection);
|
|
470
|
+
onModelSelectionChangeAction?.(nextSelection);
|
|
471
|
+
});
|
|
472
|
+
return;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
startTransition(() => {
|
|
476
|
+
setOptimisticSelection(optimisticModelId);
|
|
477
|
+
onModelSelectionChangeAction?.(optimisticModelId);
|
|
478
|
+
});
|
|
479
|
+
},
|
|
480
|
+
[onModelSelectionChangeAction, optimisticModelId, setOptimisticSelection],
|
|
481
|
+
);
|
|
482
|
+
|
|
483
|
+
return (
|
|
484
|
+
<Popover onOpenChange={setOpen} open={open}>
|
|
485
|
+
<PopoverTrigger asChild>
|
|
486
|
+
<Button
|
|
487
|
+
aria-expanded={open}
|
|
488
|
+
className={cn("flex w-fit justify-between gap-2 md:px-2", className)}
|
|
489
|
+
data-testid="model-selector"
|
|
490
|
+
role="combobox"
|
|
491
|
+
variant="ghost"
|
|
492
|
+
>
|
|
493
|
+
<div className="flex items-center gap-2">
|
|
494
|
+
{selectedItem && (
|
|
495
|
+
<div className="shrink-0">
|
|
496
|
+
<ModelSelectorLogo modelId={selectedItem.model.id} />
|
|
497
|
+
</div>
|
|
498
|
+
)}
|
|
499
|
+
<p className="inline-flex items-center gap-1.5 truncate">
|
|
500
|
+
{triggerLabel}
|
|
501
|
+
{selectedItem?.model.reasoning && reasoningConfig && (
|
|
502
|
+
<span
|
|
503
|
+
className="inline-flex shrink-0 items-center gap-1"
|
|
504
|
+
title={reasoningConfig.description}
|
|
505
|
+
>
|
|
506
|
+
<reasoningConfig.icon className="h-3 w-3 text-muted-foreground" />
|
|
507
|
+
</span>
|
|
508
|
+
)}
|
|
509
|
+
</p>
|
|
510
|
+
</div>
|
|
511
|
+
<ChevronUpIcon
|
|
512
|
+
className={cn(
|
|
513
|
+
"h-4 w-4 shrink-0 opacity-50 transition-transform",
|
|
514
|
+
open && "rotate-180",
|
|
515
|
+
)}
|
|
516
|
+
/>
|
|
517
|
+
</Button>
|
|
518
|
+
</PopoverTrigger>
|
|
519
|
+
<PopoverContent
|
|
520
|
+
align="start"
|
|
521
|
+
className="w-[350px] p-0"
|
|
522
|
+
onFocusOutside={(e) => e.preventDefault()}
|
|
523
|
+
onInteractOutside={(e) => {
|
|
524
|
+
// Prevent closing when interacting with nested popovers rendered in portals
|
|
525
|
+
if (
|
|
526
|
+
(e.target as HTMLElement).closest(
|
|
527
|
+
"[data-radix-popper-content-wrapper]",
|
|
528
|
+
)
|
|
529
|
+
) {
|
|
530
|
+
e.preventDefault();
|
|
531
|
+
}
|
|
532
|
+
}}
|
|
533
|
+
>
|
|
534
|
+
{open && (
|
|
535
|
+
<Command>
|
|
536
|
+
<div className="flex items-center border-b">
|
|
537
|
+
<CommandInput
|
|
538
|
+
className="px-3"
|
|
539
|
+
containerClassName="w-full border-0 h-11"
|
|
540
|
+
onClick={(e) => {
|
|
541
|
+
// Prevent closing when interacting with nested filter popover
|
|
542
|
+
e.stopPropagation();
|
|
543
|
+
}}
|
|
544
|
+
placeholder="Search models..."
|
|
545
|
+
/>
|
|
546
|
+
<Popover onOpenChange={setFilterOpen} open={filterOpen}>
|
|
547
|
+
<PopoverTrigger asChild>
|
|
548
|
+
<Button
|
|
549
|
+
className={cn(
|
|
550
|
+
"relative mr-3 h-8 w-8 p-0",
|
|
551
|
+
activeFilterCount > 0 && "text-primary",
|
|
552
|
+
)}
|
|
553
|
+
size="sm"
|
|
554
|
+
variant="ghost"
|
|
555
|
+
>
|
|
556
|
+
<FilterIcon className="h-4 w-4" />
|
|
557
|
+
{activeFilterCount > 0 && (
|
|
558
|
+
<Badge
|
|
559
|
+
className="absolute -top-1 -right-1 flex h-4 min-w-[16px] items-center justify-center p-0 text-xs"
|
|
560
|
+
variant="secondary"
|
|
561
|
+
>
|
|
562
|
+
{activeFilterCount}
|
|
563
|
+
</Badge>
|
|
564
|
+
)}
|
|
565
|
+
</Button>
|
|
566
|
+
</PopoverTrigger>
|
|
567
|
+
<PopoverContent align="end" className="p-0">
|
|
568
|
+
<div className="p-4">
|
|
569
|
+
<div className="mb-3 flex h-7 items-center justify-between">
|
|
570
|
+
<div className="font-medium text-sm">Filter by Tools</div>
|
|
571
|
+
{activeFilterCount > 0 && (
|
|
572
|
+
<Button
|
|
573
|
+
className="h-6 text-xs"
|
|
574
|
+
onClick={() => setFeatureFilters(initialFilters)}
|
|
575
|
+
size="sm"
|
|
576
|
+
variant="ghost"
|
|
577
|
+
>
|
|
578
|
+
Clear filters
|
|
579
|
+
</Button>
|
|
580
|
+
)}
|
|
581
|
+
</div>
|
|
582
|
+
<div className="grid grid-cols-1 gap-2">
|
|
583
|
+
{enabledFeatures.map((feature) => {
|
|
584
|
+
const IconComponent = feature.icon;
|
|
585
|
+
return (
|
|
586
|
+
<div
|
|
587
|
+
className="flex items-center space-x-2"
|
|
588
|
+
key={feature.key}
|
|
589
|
+
>
|
|
590
|
+
<Checkbox
|
|
591
|
+
checked={featureFilters[feature.key]}
|
|
592
|
+
id={feature.key}
|
|
593
|
+
onCheckedChange={(checked) =>
|
|
594
|
+
setFeatureFilters((prev) => ({
|
|
595
|
+
...prev,
|
|
596
|
+
[feature.key]: Boolean(checked),
|
|
597
|
+
}))
|
|
598
|
+
}
|
|
599
|
+
/>
|
|
600
|
+
<Label
|
|
601
|
+
className="flex items-center gap-1.5 text-sm"
|
|
602
|
+
htmlFor={feature.key}
|
|
603
|
+
>
|
|
604
|
+
<IconComponent className="h-3.5 w-3.5" />
|
|
605
|
+
{feature.name}
|
|
606
|
+
</Label>
|
|
607
|
+
</div>
|
|
608
|
+
);
|
|
609
|
+
})}
|
|
610
|
+
</div>
|
|
611
|
+
</div>
|
|
612
|
+
</PopoverContent>
|
|
613
|
+
</Popover>
|
|
614
|
+
</div>
|
|
615
|
+
{!isAnonymous && config.features.parallelResponses && (
|
|
616
|
+
<div className="flex items-center justify-between border-b px-3 py-2">
|
|
617
|
+
<Label
|
|
618
|
+
className="cursor-pointer text-sm"
|
|
619
|
+
htmlFor="use-multiple-models"
|
|
620
|
+
>
|
|
621
|
+
Use Multiple Models
|
|
622
|
+
</Label>
|
|
623
|
+
<Switch
|
|
624
|
+
checked={useMultipleModels}
|
|
625
|
+
id="use-multiple-models"
|
|
626
|
+
onCheckedChange={handleMultipleModelsToggle}
|
|
627
|
+
/>
|
|
628
|
+
</div>
|
|
629
|
+
)}
|
|
630
|
+
{hasDisabledModels && (
|
|
631
|
+
<div className="p-3">
|
|
632
|
+
<LoginCtaBanner
|
|
633
|
+
compact
|
|
634
|
+
message="Sign in to unlock all models."
|
|
635
|
+
variant="default"
|
|
636
|
+
/>
|
|
637
|
+
</div>
|
|
638
|
+
)}
|
|
639
|
+
<CommandList
|
|
640
|
+
className="max-h-[min(25dvh,400px)]"
|
|
641
|
+
onMouseDown={(e) => e.stopPropagation()}
|
|
642
|
+
>
|
|
643
|
+
<CommandEmpty>No model found.</CommandEmpty>
|
|
644
|
+
<CommandGroup>
|
|
645
|
+
{filteredModels.map(({ model, disabled }) => {
|
|
646
|
+
const isSelected = useMultipleModels
|
|
647
|
+
? selectedModelIds.has(model.id)
|
|
648
|
+
: model.id === optimisticModelId;
|
|
649
|
+
const count =
|
|
650
|
+
useMultipleModels &&
|
|
651
|
+
typeof optimisticSelection !== "string"
|
|
652
|
+
? (optimisticSelection as Record<AppModelId, number>)[
|
|
653
|
+
model.id
|
|
654
|
+
] ?? 0
|
|
655
|
+
: undefined;
|
|
656
|
+
return (
|
|
657
|
+
<CommandItem
|
|
658
|
+
count={isSelected ? count : undefined}
|
|
659
|
+
disabled={disabled}
|
|
660
|
+
isSelected={isSelected}
|
|
661
|
+
key={model.id}
|
|
662
|
+
model={model}
|
|
663
|
+
onCountChange={
|
|
664
|
+
useMultipleModels
|
|
665
|
+
? (delta) => handleCountChange(model.id, delta)
|
|
666
|
+
: undefined
|
|
667
|
+
}
|
|
668
|
+
onSelect={() =>
|
|
669
|
+
useMultipleModels
|
|
670
|
+
? toggleMultiModel(model.id)
|
|
671
|
+
: selectSingleModel(model.id)
|
|
672
|
+
}
|
|
673
|
+
selectionControl={
|
|
674
|
+
useMultipleModels ? (
|
|
675
|
+
<Checkbox
|
|
676
|
+
checked={isSelected}
|
|
677
|
+
className="pointer-events-none"
|
|
678
|
+
/>
|
|
679
|
+
) : null
|
|
680
|
+
}
|
|
681
|
+
/>
|
|
682
|
+
);
|
|
683
|
+
})}
|
|
684
|
+
</CommandGroup>
|
|
685
|
+
</CommandList>
|
|
686
|
+
{!isAnonymous && (
|
|
687
|
+
<div className="border-t p-2">
|
|
688
|
+
<Button
|
|
689
|
+
asChild
|
|
690
|
+
className="w-full justify-between"
|
|
691
|
+
size="sm"
|
|
692
|
+
variant="ghost"
|
|
693
|
+
>
|
|
694
|
+
<Link aria-label="Add Models" href="/settings/models">
|
|
695
|
+
Add Models
|
|
696
|
+
<ChevronRightIcon className="h-4 w-4" />
|
|
697
|
+
</Link>
|
|
698
|
+
</Button>
|
|
699
|
+
</div>
|
|
700
|
+
)}
|
|
701
|
+
</Command>
|
|
702
|
+
)}
|
|
703
|
+
</PopoverContent>
|
|
704
|
+
</Popover>
|
|
705
|
+
);
|
|
445
706
|
}
|
|
446
707
|
|
|
447
708
|
export const ModelSelector = memo(
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
709
|
+
PureModelSelector,
|
|
710
|
+
(prev, next) =>
|
|
711
|
+
prev.selectedModelId === next.selectedModelId &&
|
|
712
|
+
prev.selectedModelSelection === next.selectedModelSelection &&
|
|
713
|
+
prev.className === next.className &&
|
|
714
|
+
prev.onModelSelectionChangeAction === next.onModelSelectionChangeAction,
|
|
453
715
|
);
|