@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.
Files changed (42) hide show
  1. package/dist/index.js +216 -171
  2. package/package.json +1 -1
  3. package/templates/chat-app/CHANGELOG.md +19 -0
  4. package/templates/chat-app/app/(chat)/actions.ts +9 -9
  5. package/templates/chat-app/app/(chat)/api/chat/prepare/route.ts +94 -0
  6. package/templates/chat-app/app/(chat)/api/chat/route.ts +97 -14
  7. package/templates/chat-app/chat.config.ts +144 -156
  8. package/templates/chat-app/components/chat-sync.tsx +6 -3
  9. package/templates/chat-app/components/feedback-actions.tsx +7 -3
  10. package/templates/chat-app/components/message-editor.tsx +8 -3
  11. package/templates/chat-app/components/message-siblings.tsx +14 -1
  12. package/templates/chat-app/components/model-selector.tsx +669 -407
  13. package/templates/chat-app/components/multimodal-input.tsx +252 -18
  14. package/templates/chat-app/components/parallel-response-cards.tsx +157 -0
  15. package/templates/chat-app/components/part/text-message-part.tsx +9 -5
  16. package/templates/chat-app/components/retry-button.tsx +25 -8
  17. package/templates/chat-app/components/user-message.tsx +136 -125
  18. package/templates/chat-app/hooks/chat-sync-hooks.ts +11 -0
  19. package/templates/chat-app/hooks/use-navigate-to-message.ts +39 -0
  20. package/templates/chat-app/lib/ai/gateway-model-defaults.ts +154 -100
  21. package/templates/chat-app/lib/ai/gateways/openrouter-gateway.ts +2 -2
  22. package/templates/chat-app/lib/ai/tools/generate-image.ts +9 -2
  23. package/templates/chat-app/lib/ai/tools/generate-video.ts +3 -0
  24. package/templates/chat-app/lib/ai/types.ts +74 -3
  25. package/templates/chat-app/lib/config-schema.ts +131 -132
  26. package/templates/chat-app/lib/config.ts +2 -2
  27. package/templates/chat-app/lib/db/migrations/0044_gray_red_shift.sql +5 -0
  28. package/templates/chat-app/lib/db/migrations/meta/0044_snapshot.json +1567 -0
  29. package/templates/chat-app/lib/db/migrations/meta/_journal.json +8 -1
  30. package/templates/chat-app/lib/db/queries.ts +84 -4
  31. package/templates/chat-app/lib/db/schema.ts +4 -1
  32. package/templates/chat-app/lib/message-conversion.ts +14 -2
  33. package/templates/chat-app/lib/stores/hooks-threads.ts +37 -1
  34. package/templates/chat-app/lib/stores/with-threads.test.ts +137 -0
  35. package/templates/chat-app/lib/stores/with-threads.ts +157 -4
  36. package/templates/chat-app/lib/thread-utils.ts +23 -2
  37. package/templates/chat-app/package.json +1 -1
  38. package/templates/chat-app/providers/chat-input-provider.tsx +40 -2
  39. package/templates/chat-app/scripts/db-branch-delete.sh +7 -1
  40. package/templates/chat-app/scripts/db-branch-use.sh +7 -1
  41. package/templates/chat-app/scripts/with-db.sh +7 -1
  42. package/templates/chat-app/vitest.config.ts +2 -0
@@ -41,6 +41,9 @@ async function resolveImageModel(selectedModel?: string): Promise<{
41
41
  }
42
42
 
43
43
  // Fall back to the configured default image model
44
+ if (!config.ai.tools.image.enabled) {
45
+ throw new Error("Image generation is not enabled");
46
+ }
44
47
  const defaultId = config.ai.tools.image.default;
45
48
  try {
46
49
  const model = await getAppModelDefinition(defaultId as AppModelId);
@@ -134,6 +137,10 @@ async function runGenerateImageTraditional({
134
137
  startMs: number;
135
138
  costAccumulator?: CostAccumulator;
136
139
  }): Promise<{ imageUrl: string; prompt: string }> {
140
+ if (!config.ai.tools.image.enabled) {
141
+ throw new Error("Image generation is not enabled");
142
+ }
143
+ const imageDefault = config.ai.tools.image.default;
137
144
  let promptInput:
138
145
  | string
139
146
  | {
@@ -161,7 +168,7 @@ async function runGenerateImageTraditional({
161
168
  }
162
169
 
163
170
  const res = await generateImage({
164
- model: getImageModel(config.ai.tools.image.default),
171
+ model: getImageModel(imageDefault),
165
172
  prompt: promptInput,
166
173
  n: 1,
167
174
  providerOptions: {
@@ -184,7 +191,7 @@ async function runGenerateImageTraditional({
184
191
 
185
192
  if (res.usage) {
186
193
  costAccumulator?.addLLMCost(
187
- config.ai.tools.image.default as AppModelId,
194
+ imageDefault as AppModelId,
188
195
  {
189
196
  inputTokens: res.usage.inputTokens,
190
197
  outputTokens: res.usage.outputTokens,
@@ -48,6 +48,9 @@ async function resolveVideoModel(selectedModel?: string): Promise<string> {
48
48
  // Not in app models registry, fall through
49
49
  }
50
50
  }
51
+ if (!config.ai.tools.video.enabled) {
52
+ throw new Error("Video generation is not enabled");
53
+ }
51
54
  return config.ai.tools.video.default;
52
55
  }
53
56
 
@@ -59,10 +59,83 @@ const frontendToolsSchema = z.enum([
59
59
  const __ = frontendToolsSchema.options satisfies ToolNameInternal[];
60
60
 
61
61
  export type UiToolName = z.infer<typeof frontendToolsSchema>;
62
+
63
+ export type SelectedModelCounts = Partial<Record<AppModelId, number>>;
64
+ export type SelectedModelValue = AppModelId | SelectedModelCounts;
65
+
66
+ export function isSelectedModelCounts(
67
+ value: unknown
68
+ ): value is SelectedModelCounts {
69
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
70
+ return false;
71
+ }
72
+
73
+ if (Object.keys(value).length === 0) {
74
+ return false;
75
+ }
76
+
77
+ return Object.entries(value).every(
78
+ ([modelId, count]) =>
79
+ typeof modelId === "string" &&
80
+ typeof count === "number" &&
81
+ Number.isInteger(count) &&
82
+ count > 0
83
+ );
84
+ }
85
+
86
+ export function isSelectedModelValue(
87
+ value: unknown
88
+ ): value is SelectedModelValue {
89
+ return typeof value === "string" || isSelectedModelCounts(value);
90
+ }
91
+
92
+ export function getPrimarySelectedModelId(
93
+ selectedModel: SelectedModelValue | null | undefined
94
+ ): AppModelId | null {
95
+ if (!selectedModel) {
96
+ return null;
97
+ }
98
+
99
+ if (typeof selectedModel === "string") {
100
+ return selectedModel;
101
+ }
102
+
103
+ const [firstSelectedModelId] = Object.entries(selectedModel).find(
104
+ ([, count]) => typeof count === "number" && count > 0
105
+ ) ?? [null];
106
+
107
+ return firstSelectedModelId as AppModelId | null;
108
+ }
109
+
110
+ export function expandSelectedModelValue(
111
+ selectedModel: SelectedModelValue
112
+ ): AppModelId[] {
113
+ if (typeof selectedModel === "string") {
114
+ return [selectedModel];
115
+ }
116
+
117
+ const expanded: AppModelId[] = [];
118
+
119
+ for (const [modelId, count] of Object.entries(selectedModel)) {
120
+ if (!(typeof count === "number" && Number.isInteger(count) && count > 0)) {
121
+ continue;
122
+ }
123
+
124
+ for (let index = 0; index < count; index += 1) {
125
+ expanded.push(modelId as AppModelId);
126
+ }
127
+ }
128
+
129
+ return expanded;
130
+ }
131
+
62
132
  const messageMetadataSchema = z.object({
63
133
  createdAt: z.date(),
64
134
  parentMessageId: z.string().nullable(),
65
- selectedModel: z.custom<AppModelId>((val) => typeof val === "string"),
135
+ parallelGroupId: z.string().nullable().optional(),
136
+ parallelIndex: z.number().int().nullable().optional(),
137
+ isPrimaryParallel: z.boolean().nullable().optional(),
138
+ selectedModel: z.custom<SelectedModelValue>(isSelectedModelValue),
66
139
  activeStreamId: z.string().nullable(),
67
140
  selectedTool: frontendToolsSchema.optional(),
68
141
  usage: z.custom<LanguageModelUsage | undefined>((_val) => true).optional(),
@@ -101,7 +174,6 @@ type webSearchTool = InferUITool<ReturnType<typeof tavilyWebSearch>>;
101
174
  type codeExecutionTool = InferUITool<ReturnType<typeof codeExecution>>;
102
175
  type retrieveUrlTool = InferUITool<typeof retrieveUrl>;
103
176
 
104
- // biome-ignore lint/style/useConsistentTypeDefinitions: <explanation>
105
177
  export type ChatTools = {
106
178
  codeExecution: codeExecutionTool;
107
179
  createCodeDocument: createCodeDocumentToolType;
@@ -123,7 +195,6 @@ interface FollowupSuggestions {
123
195
  suggestions: string[];
124
196
  }
125
197
 
126
- // biome-ignore lint/style/useConsistentTypeDefinitions: <explanation>
127
198
  export type CustomUIDataTypes = {
128
199
  appendMessage: string;
129
200
  chatConfirmed: {
@@ -5,6 +5,7 @@ import type {
5
5
  GatewayType,
6
6
  GatewayVideoModelIdMap,
7
7
  } from "@/lib/ai/gateways/registry";
8
+ import { GATEWAY_MODEL_DEFAULTS } from "./ai/gateway-model-defaults";
8
9
  import type { ToolName } from "./ai/types";
9
10
 
10
11
  const DEFAULT_GATEWAY = "vercel" as const satisfies GatewayType;
@@ -105,14 +106,26 @@ function createAiSchema<G extends GatewayType>(g: G) {
105
106
  code: z.object({
106
107
  edits: gatewayModelId<G>(),
107
108
  }),
108
- image: z.object({
109
- enabled: z.boolean(),
110
- default: gatewayImageModelId<G>(),
111
- }),
112
- video: z.object({
113
- enabled: z.boolean(),
114
- default: gatewayVideoModelId<G>(),
115
- }),
109
+ image: z.discriminatedUnion("enabled", [
110
+ z.object({
111
+ enabled: z.literal(true),
112
+ default: gatewayImageModelId<G>(),
113
+ }),
114
+ z.object({
115
+ enabled: z.literal(false),
116
+ default: gatewayImageModelId<G>().optional(),
117
+ }),
118
+ ]),
119
+ video: z.discriminatedUnion("enabled", [
120
+ z.object({
121
+ enabled: z.literal(true),
122
+ default: gatewayVideoModelId<G>(),
123
+ }),
124
+ z.object({
125
+ enabled: z.literal(false),
126
+ default: gatewayVideoModelId<G>().optional(),
127
+ }),
128
+ ]),
116
129
  deepResearch: deepResearchToolConfigSchema.extend({
117
130
  enabled: z.boolean(),
118
131
  defaultModel: gatewayModelId<G>(),
@@ -142,77 +155,7 @@ export const aiConfigSchema = z
142
155
  ])
143
156
  .default({
144
157
  gateway: DEFAULT_GATEWAY,
145
- providerOrder: ["openai", "google", "anthropic"],
146
- disabledModels: [],
147
- curatedDefaults: [
148
- // OpenAI
149
- "openai/gpt-5-nano",
150
- "openai/gpt-5-mini",
151
- "openai/gpt-5.2",
152
- "openai/gpt-5.2-chat",
153
-
154
- // Google
155
- "google/gemini-2.5-flash-lite",
156
- "google/gemini-3-flash",
157
- "google/gemini-3-pro-preview",
158
- // Anthropic
159
- "anthropic/claude-sonnet-4.5",
160
- "anthropic/claude-opus-4.5",
161
- // xAI
162
- "xai/grok-4",
163
- ],
164
- anonymousModels: ["google/gemini-2.5-flash-lite", "openai/gpt-5-nano"],
165
- workflows: {
166
- chat: "openai/gpt-5-mini",
167
- title: "openai/gpt-5-nano",
168
- pdf: "openai/gpt-5-mini",
169
- chatImageCompatible: "openai/gpt-4o-mini",
170
- },
171
- tools: {
172
- webSearch: {
173
- enabled: false,
174
- },
175
- urlRetrieval: {
176
- enabled: false,
177
- },
178
- codeExecution: {
179
- enabled: false,
180
- },
181
- mcp: {
182
- enabled: false,
183
- },
184
- followupSuggestions: {
185
- enabled: false,
186
- default: "google/gemini-2.5-flash-lite",
187
- },
188
- text: {
189
- polish: "openai/gpt-5-mini",
190
- },
191
- sheet: {
192
- format: "openai/gpt-5-mini",
193
- analyze: "openai/gpt-5-mini",
194
- },
195
- code: {
196
- edits: "openai/gpt-5-mini",
197
- },
198
- image: {
199
- enabled: false,
200
- default: "google/gemini-3-pro-image",
201
- },
202
- video: {
203
- enabled: false,
204
- default: "xai/grok-imagine-video",
205
- },
206
- deepResearch: {
207
- enabled: false,
208
- defaultModel: "google/gemini-2.5-flash-lite",
209
- finalReportModel: "google/gemini-3-flash",
210
- allowClarification: true,
211
- maxResearcherIterations: 1,
212
- maxConcurrentResearchUnits: 2,
213
- maxSearchQueries: 2,
214
- },
215
- },
158
+ ...GATEWAY_MODEL_DEFAULTS[DEFAULT_GATEWAY],
216
159
  });
217
160
 
218
161
  export const pricingConfigSchema = z.object({
@@ -281,9 +224,14 @@ export const featuresConfigSchema = z
281
224
  attachments: z
282
225
  .boolean()
283
226
  .describe("File attachments (requires BLOB_READ_WRITE_TOKEN)"),
227
+ parallelResponses: z
228
+ .boolean()
229
+ .default(true)
230
+ .describe("Send one message to multiple models simultaneously"),
284
231
  })
285
232
  .default({
286
233
  attachments: false,
234
+ parallelResponses: true,
287
235
  });
288
236
 
289
237
  export const authenticationConfigSchema = z
@@ -400,63 +348,58 @@ type ZodConfigInput = z.input<typeof configSchema>;
400
348
  // Use vercel variant as shape reference (all variants share the same structure)
401
349
  type AiShape = z.input<typeof gatewaySchemaMap.vercel>;
402
350
  type AiToolsShape = AiShape["tools"];
403
- type DeepResearchToolInputFor<G extends GatewayType> = Omit<
404
- AiToolsShape["deepResearch"],
405
- "defaultModel" | "finalReportModel"
406
- > & {
407
- defaultModel: GatewayModelIdMap[G];
408
- finalReportModel: GatewayModelIdMap[G];
409
- };
410
- type ImageToolInputFor<G extends GatewayType> = Omit<
411
- AiToolsShape["image"],
412
- "default"
413
- > & {
414
- default: GatewayImageModelIdMap[G];
415
- };
416
- type VideoToolInputFor<G extends GatewayType> = Omit<
417
- AiToolsShape["video"],
418
- "default"
419
- > & {
420
- default: GatewayVideoModelIdMap[G];
421
- };
422
- type FollowupSuggestionsToolInputFor<G extends GatewayType> = Omit<
423
- AiToolsShape["followupSuggestions"],
424
- "default"
425
- > & {
351
+
352
+ // All helper types are Partial — fields not provided are filled by applyDefaults
353
+ type DeepResearchToolInputFor<G extends GatewayType> = Partial<
354
+ Omit<AiToolsShape["deepResearch"], "defaultModel" | "finalReportModel"> & {
355
+ defaultModel: GatewayModelIdMap[G];
356
+ finalReportModel: GatewayModelIdMap[G];
357
+ }
358
+ >;
359
+ type ImageToolInputFor<G extends GatewayType> = [
360
+ GatewayImageModelIdMap[G],
361
+ ] extends [never]
362
+ ? { enabled?: false }
363
+ :
364
+ | { enabled: true; default: GatewayImageModelIdMap[G] }
365
+ | { enabled?: false; default?: GatewayImageModelIdMap[G] };
366
+ type VideoToolInputFor<G extends GatewayType> = [
367
+ GatewayVideoModelIdMap[G],
368
+ ] extends [never]
369
+ ? { enabled?: false }
370
+ :
371
+ | { enabled: true; default: GatewayVideoModelIdMap[G] }
372
+ | { enabled?: false; default?: GatewayVideoModelIdMap[G] };
373
+ type FollowupSuggestionsToolInputFor<G extends GatewayType> = Partial<{
374
+ enabled: boolean;
426
375
  default: GatewayModelIdMap[G];
427
- };
376
+ }>;
428
377
  interface AiToolsInputFor<G extends GatewayType> {
429
- code: {
430
- [P in keyof AiToolsShape["code"]]: GatewayModelIdMap[G];
431
- };
432
- codeExecution: AiToolsShape["codeExecution"];
433
- deepResearch: DeepResearchToolInputFor<G>;
434
- followupSuggestions: FollowupSuggestionsToolInputFor<G>;
435
- image: ImageToolInputFor<G>;
436
- mcp: AiToolsShape["mcp"];
437
- sheet: {
438
- [P in keyof AiToolsShape["sheet"]]: GatewayModelIdMap[G];
439
- };
440
- text: {
441
- [P in keyof AiToolsShape["text"]]: GatewayModelIdMap[G];
442
- };
443
- urlRetrieval: AiToolsShape["urlRetrieval"];
444
- video: VideoToolInputFor<G>;
445
- webSearch: AiToolsShape["webSearch"];
378
+ code?: Partial<{ [P in keyof AiToolsShape["code"]]: GatewayModelIdMap[G] }>;
379
+ codeExecution?: Partial<AiToolsShape["codeExecution"]>;
380
+ deepResearch?: DeepResearchToolInputFor<G>;
381
+ followupSuggestions?: FollowupSuggestionsToolInputFor<G>;
382
+ image?: ImageToolInputFor<G>;
383
+ mcp?: Partial<AiToolsShape["mcp"]>;
384
+ sheet?: Partial<{ [P in keyof AiToolsShape["sheet"]]: GatewayModelIdMap[G] }>;
385
+ text?: Partial<{ [P in keyof AiToolsShape["text"]]: GatewayModelIdMap[G] }>;
386
+ urlRetrieval?: Partial<AiToolsShape["urlRetrieval"]>;
387
+ video?: VideoToolInputFor<G>;
388
+ webSearch?: Partial<AiToolsShape["webSearch"]>;
446
389
  }
447
390
 
391
+ // Only gateway is required; everything else is an override on top of GATEWAY_MODEL_DEFAULTS
392
+ // biome-ignore lint/style/useConsistentTypeDefinitions: type is used intentionally here
448
393
  type AiInputFor<G extends GatewayType> = {
449
- [K in keyof AiShape]: K extends "gateway"
450
- ? G
451
- : K extends "workflows"
452
- ? {
453
- [W in keyof AiShape["workflows"]]: GatewayModelIdMap[G];
454
- }
455
- : K extends "tools"
456
- ? AiToolsInputFor<G>
457
- : K extends "disabledModels" | "curatedDefaults" | "anonymousModels"
458
- ? GatewayModelIdMap[G][]
459
- : AiShape[K];
394
+ gateway: G;
395
+ providerOrder?: AiShape["providerOrder"];
396
+ disabledModels?: GatewayModelIdMap[G][];
397
+ curatedDefaults?: GatewayModelIdMap[G][];
398
+ anonymousModels?: GatewayModelIdMap[G][];
399
+ workflows?: Partial<{
400
+ [W in keyof AiShape["workflows"]]: GatewayModelIdMap[G];
401
+ }>;
402
+ tools?: AiToolsInputFor<G>;
460
403
  };
461
404
 
462
405
  type ConfigInputForGateway<G extends GatewayType> = Omit<
@@ -470,7 +413,63 @@ export type ConfigInput = {
470
413
  [G in GatewayType]: ConfigInputForGateway<G>;
471
414
  }[GatewayType];
472
415
 
416
+ /**
417
+ * Type-safe config helper. Infers the gateway type from `ai.gateway` so
418
+ * autocomplete and error messages are scoped to the chosen gateway's model IDs.
419
+ * Only `ai.gateway` is required — all other `ai` fields are optional overrides
420
+ * on top of the gateway defaults supplied by `applyDefaults`.
421
+ */
422
+ export function defineConfig<G extends GatewayType>(
423
+ config: ConfigInputForGateway<G>
424
+ ): ConfigInput {
425
+ return config as ConfigInput;
426
+ }
427
+
428
+ function mergeToolsConfig<T extends Record<string, unknown>>(
429
+ defaults: T,
430
+ user: Record<string, unknown> | undefined
431
+ ): T {
432
+ if (!user) {
433
+ return defaults;
434
+ }
435
+ const result: Record<string, unknown> = { ...defaults };
436
+ for (const [key, val] of Object.entries(user)) {
437
+ const defVal = result[key];
438
+ if (
439
+ val !== null &&
440
+ typeof val === "object" &&
441
+ !Array.isArray(val) &&
442
+ defVal !== null &&
443
+ typeof defVal === "object" &&
444
+ !Array.isArray(defVal)
445
+ ) {
446
+ result[key] = { ...defVal, ...(val as object) };
447
+ } else {
448
+ result[key] = val;
449
+ }
450
+ }
451
+ return result as T;
452
+ }
453
+
473
454
  // Apply defaults to partial config
474
455
  export function applyDefaults(input: ConfigInput): Config {
475
- return configSchema.parse(input);
456
+ const gateway = input.ai?.gateway ?? DEFAULT_GATEWAY;
457
+ const gatewayDefaults = GATEWAY_MODEL_DEFAULTS[gateway];
458
+ const aiInput = input.ai as Record<string, unknown> | undefined;
459
+
460
+ const mergedAi = {
461
+ gateway,
462
+ ...gatewayDefaults,
463
+ ...aiInput,
464
+ workflows: {
465
+ ...gatewayDefaults.workflows,
466
+ ...(aiInput?.workflows as Record<string, unknown> | undefined),
467
+ },
468
+ tools: mergeToolsConfig(
469
+ gatewayDefaults.tools,
470
+ aiInput?.tools as Record<string, unknown> | undefined
471
+ ),
472
+ };
473
+
474
+ return configSchema.parse({ ...input, ai: mergedAi });
476
475
  }
@@ -1,6 +1,6 @@
1
1
  import userConfig from "@/chat.config";
2
2
  import type { ActiveGatewayType } from "./ai/app-model-id";
3
- import { type AiConfig, type Config, configSchema } from "./config-schema";
3
+ import { type AiConfig, applyDefaults, type Config } from "./config-schema";
4
4
 
5
5
  type ActiveAiConfig = Extract<AiConfig, { gateway: ActiveGatewayType }>;
6
6
 
@@ -15,6 +15,6 @@ type ActiveConfig = Omit<Config, "ai"> & { ai: ActiveAiConfig };
15
15
  * import { config } from "@/lib/config";
16
16
  * console.log(config.appName);
17
17
  */
18
- export const config = configSchema.parse(userConfig) as ActiveConfig;
18
+ export const config = applyDefaults(userConfig) as ActiveConfig;
19
19
 
20
20
  export type { Config } from "./config-schema";
@@ -0,0 +1,5 @@
1
+ ALTER TABLE "Message" ALTER COLUMN "selectedModel" DROP DEFAULT;--> statement-breakpoint
2
+ ALTER TABLE "Message" ALTER COLUMN "selectedModel" SET DATA TYPE json USING to_json("selectedModel");--> statement-breakpoint
3
+ ALTER TABLE "Message" ADD COLUMN "parallelGroupId" uuid;--> statement-breakpoint
4
+ ALTER TABLE "Message" ADD COLUMN "parallelIndex" integer;--> statement-breakpoint
5
+ ALTER TABLE "Message" ADD COLUMN "isPrimaryParallel" boolean;--> statement-breakpoint