@chat-js/cli 0.3.0 → 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 (34) hide show
  1. package/dist/index.js +11 -6
  2. package/package.json +1 -1
  3. package/templates/chat-app/app/(chat)/api/chat/prepare/route.ts +94 -0
  4. package/templates/chat-app/app/(chat)/api/chat/route.ts +97 -14
  5. package/templates/chat-app/chat.config.ts +141 -124
  6. package/templates/chat-app/components/chat-sync.tsx +6 -3
  7. package/templates/chat-app/components/feedback-actions.tsx +7 -3
  8. package/templates/chat-app/components/message-editor.tsx +8 -3
  9. package/templates/chat-app/components/message-siblings.tsx +14 -1
  10. package/templates/chat-app/components/model-selector.tsx +669 -407
  11. package/templates/chat-app/components/multimodal-input.tsx +252 -18
  12. package/templates/chat-app/components/parallel-response-cards.tsx +157 -0
  13. package/templates/chat-app/components/part/text-message-part.tsx +9 -5
  14. package/templates/chat-app/components/retry-button.tsx +25 -8
  15. package/templates/chat-app/components/user-message.tsx +136 -125
  16. package/templates/chat-app/hooks/chat-sync-hooks.ts +11 -0
  17. package/templates/chat-app/hooks/use-navigate-to-message.ts +39 -0
  18. package/templates/chat-app/lib/ai/types.ts +74 -3
  19. package/templates/chat-app/lib/config-schema.ts +5 -0
  20. package/templates/chat-app/lib/db/migrations/0044_gray_red_shift.sql +5 -0
  21. package/templates/chat-app/lib/db/migrations/meta/0044_snapshot.json +1567 -0
  22. package/templates/chat-app/lib/db/migrations/meta/_journal.json +8 -1
  23. package/templates/chat-app/lib/db/queries.ts +84 -4
  24. package/templates/chat-app/lib/db/schema.ts +4 -1
  25. package/templates/chat-app/lib/message-conversion.ts +14 -2
  26. package/templates/chat-app/lib/stores/hooks-threads.ts +37 -1
  27. package/templates/chat-app/lib/stores/with-threads.test.ts +137 -0
  28. package/templates/chat-app/lib/stores/with-threads.ts +157 -4
  29. package/templates/chat-app/lib/thread-utils.ts +23 -2
  30. package/templates/chat-app/providers/chat-input-provider.tsx +40 -2
  31. package/templates/chat-app/scripts/db-branch-delete.sh +7 -1
  32. package/templates/chat-app/scripts/db-branch-use.sh +7 -1
  33. package/templates/chat-app/scripts/with-db.sh +7 -1
  34. package/templates/chat-app/vitest.config.ts +2 -0
@@ -8,142 +8,153 @@ import { AttachmentList } from "./attachment-list";
8
8
  import { ImageModal } from "./image-modal";
9
9
  import { MessageActions } from "./message-actions";
10
10
  import { MessageEditor } from "./message-editor";
11
+ import { ParallelResponseCards } from "./parallel-response-cards";
11
12
 
12
13
  export interface BaseMessageProps {
13
- isLoading: boolean;
14
- isReadonly: boolean;
15
- messageId: string;
16
- parentMessageId: string | null;
14
+ isLoading: boolean;
15
+ isReadonly: boolean;
16
+ messageId: string;
17
+ parentMessageId: string | null;
17
18
  }
18
19
 
19
20
  const PureUserMessage = ({
20
- messageId,
21
- isLoading,
22
- isReadonly,
23
- parentMessageId,
21
+ messageId,
22
+ isLoading,
23
+ isReadonly,
24
+ parentMessageId,
24
25
  }: BaseMessageProps) => {
25
- const chatId = useChatId();
26
- const message = useMessageById<ChatMessage>(messageId);
27
- const [mode, setMode] = useState<"view" | "edit">("view");
28
- const [imageModal, setImageModal] = useState<{
29
- isOpen: boolean;
30
- imageUrl: string;
31
- imageName?: string;
32
- }>({ isOpen: false, imageUrl: "" });
26
+ const chatId = useChatId();
27
+ const message = useMessageById<ChatMessage>(messageId);
28
+ const [mode, setMode] = useState<"view" | "edit">("view");
29
+ const [imageModal, setImageModal] = useState<{
30
+ isOpen: boolean;
31
+ imageUrl: string;
32
+ imageName?: string;
33
+ }>({ isOpen: false, imageUrl: "" });
33
34
 
34
- const handleImageClick = (imageUrl: string, imageName?: string) => {
35
- setImageModal({ isOpen: true, imageUrl, imageName });
36
- };
35
+ const handleImageClick = (imageUrl: string, imageName?: string) => {
36
+ setImageModal({ isOpen: true, imageUrl, imageName });
37
+ };
37
38
 
38
- if (!message) {
39
- return null;
40
- }
41
- const textPart = message.parts.find((part) => part.type === "text");
42
- if (!(textPart && chatId)) {
43
- return null;
44
- }
39
+ if (!message) {
40
+ return null;
41
+ }
42
+ const textPart = message.parts.find((part) => part.type === "text");
43
+ if (!(textPart && chatId)) {
44
+ return null;
45
+ }
45
46
 
46
- return (
47
- <>
48
- <Message
49
- className={cn(
50
- // TODO: Consider not using this max-w class override when editing is cohesive with displaying the message
51
- mode === "edit" ? "max-w-full [&>div]:max-w-full" : undefined,
52
- "py-1"
53
- )}
54
- from="user"
55
- >
56
- <div
57
- className={cn(
58
- "flex w-full flex-col gap-2",
59
- message.role === "user" && mode !== "edit" && "items-end"
60
- )}
61
- >
62
- {mode === "view" && isReadonly && (
63
- <MessageContent
64
- className="text-left group-[.is-user]:bg-card"
65
- data-testid="message-content"
66
- >
67
- <AttachmentList
68
- attachments={getAttachmentsFromMessage(message)}
69
- onImageClick={handleImageClick}
70
- testId="message-attachments"
71
- />
72
- <pre className="whitespace-pre-wrap font-sans">
73
- {textPart.text}
74
- </pre>
75
- </MessageContent>
76
- )}
77
- {mode === "view" && !isReadonly && (
78
- <button
79
- className="block cursor-pointer text-left transition-opacity hover:opacity-80"
80
- data-testid="message-content"
81
- onClick={() => setMode("edit")}
82
- type="button"
83
- >
84
- <MessageContent
85
- className="text-left group-[.is-user]:max-w-none group-[.is-user]:bg-card"
86
- data-testid="message-content"
87
- >
88
- <AttachmentList
89
- attachments={getAttachmentsFromMessage(message)}
90
- onImageClick={handleImageClick}
91
- testId="message-attachments"
92
- />
93
- <pre className="whitespace-pre-wrap font-sans">
94
- {textPart.text}
95
- </pre>
96
- </MessageContent>
97
- </button>
98
- )}
99
- {mode !== "view" && (
100
- <div className="flex flex-row items-start gap-2">
101
- <MessageEditor
102
- chatId={chatId}
103
- key={message.id}
104
- message={message}
105
- parentMessageId={parentMessageId}
106
- setMode={setMode}
107
- />
108
- </div>
109
- )}
47
+ return (
48
+ <>
49
+ <Message
50
+ className={cn(
51
+ // TODO: Consider not using this max-w class override when editing is cohesive with displaying the message
52
+ mode === "edit" ? "max-w-full [&>div]:max-w-full" : undefined,
53
+ "py-1",
54
+ )}
55
+ from="user"
56
+ >
57
+ <div
58
+ className={cn(
59
+ "flex w-full flex-col gap-2",
60
+ message.role === "user" && mode !== "edit" && "items-end",
61
+ )}
62
+ >
63
+ {mode === "view" && <ParallelResponseCards messageId={message.id} />}
110
64
 
111
- <div className="self-end">
112
- <MessageActions
113
- chatId={chatId}
114
- isEditing={mode === "edit"}
115
- isLoading={isLoading}
116
- isReadOnly={isReadonly}
117
- key={`action-${message.id}`}
118
- messageId={message.id}
119
- onCancelEdit={() => setMode("view")}
120
- onStartEdit={() => setMode("edit")}
121
- />
122
- </div>
123
- </div>
124
- </Message>
125
- <ImageModal
126
- imageName={imageModal.imageName}
127
- imageUrl={imageModal.imageUrl}
128
- isOpen={imageModal.isOpen}
129
- onClose={() => setImageModal({ isOpen: false, imageUrl: "" })}
130
- />
131
- </>
132
- );
65
+ {mode === "view" && isReadonly && (
66
+ <MessageContent
67
+ className="text-left group-[.is-user]:bg-card"
68
+ data-testid="message-content"
69
+ >
70
+ <AttachmentList
71
+ attachments={getAttachmentsFromMessage(message)}
72
+ onImageClick={handleImageClick}
73
+ testId="message-attachments"
74
+ />
75
+ <pre className="whitespace-pre-wrap font-sans">
76
+ {textPart.text}
77
+ </pre>
78
+ </MessageContent>
79
+ )}
80
+ {mode === "view" && !isReadonly && (
81
+ <button
82
+ className="block cursor-pointer select-text text-left transition-opacity hover:opacity-80"
83
+ data-testid="message-content"
84
+ onClick={(e) => {
85
+ const selection = window.getSelection();
86
+ if (
87
+ selection?.toString() &&
88
+ e.currentTarget.contains(selection.anchorNode)
89
+ )
90
+ return;
91
+ setMode("edit");
92
+ }}
93
+ type="button"
94
+ >
95
+ <MessageContent
96
+ className="text-left group-[.is-user]:max-w-none group-[.is-user]:bg-card"
97
+ data-testid="message-content"
98
+ >
99
+ <AttachmentList
100
+ attachments={getAttachmentsFromMessage(message)}
101
+ onImageClick={handleImageClick}
102
+ testId="message-attachments"
103
+ />
104
+ <pre className="whitespace-pre-wrap font-sans">
105
+ {textPart.text}
106
+ </pre>
107
+ </MessageContent>
108
+ </button>
109
+ )}
110
+ {mode !== "view" && (
111
+ <div className="flex flex-row items-start gap-2">
112
+ <MessageEditor
113
+ chatId={chatId}
114
+ key={message.id}
115
+ message={message}
116
+ parentMessageId={parentMessageId}
117
+ setMode={setMode}
118
+ />
119
+ </div>
120
+ )}
121
+
122
+ <div className="self-end">
123
+ <MessageActions
124
+ chatId={chatId}
125
+ isEditing={mode === "edit"}
126
+ isLoading={isLoading}
127
+ isReadOnly={isReadonly}
128
+ key={`action-${message.id}`}
129
+ messageId={message.id}
130
+ onCancelEdit={() => setMode("view")}
131
+ onStartEdit={() => setMode("edit")}
132
+ />
133
+ </div>
134
+ </div>
135
+ </Message>
136
+ <ImageModal
137
+ imageName={imageModal.imageName}
138
+ imageUrl={imageModal.imageUrl}
139
+ isOpen={imageModal.isOpen}
140
+ onClose={() => setImageModal({ isOpen: false, imageUrl: "" })}
141
+ />
142
+ </>
143
+ );
133
144
  };
134
145
 
135
146
  export const UserMessage = memo(PureUserMessage, (prevProps, nextProps) => {
136
- if (prevProps.messageId !== nextProps.messageId) {
137
- return false;
138
- }
139
- if (prevProps.isReadonly !== nextProps.isReadonly) {
140
- return false;
141
- }
142
- if (prevProps.parentMessageId !== nextProps.parentMessageId) {
143
- return false;
144
- }
145
- if (prevProps.isLoading !== nextProps.isLoading) {
146
- return false;
147
- }
148
- return true;
147
+ if (prevProps.messageId !== nextProps.messageId) {
148
+ return false;
149
+ }
150
+ if (prevProps.isReadonly !== nextProps.isReadonly) {
151
+ return false;
152
+ }
153
+ if (prevProps.parentMessageId !== nextProps.parentMessageId) {
154
+ return false;
155
+ }
156
+ if (prevProps.isLoading !== nextProps.isLoading) {
157
+ return false;
158
+ }
159
+ return true;
149
160
  });
@@ -331,6 +331,17 @@ export function useSaveMessageMutation() {
331
331
  }
332
332
  }
333
333
  },
334
+ onSettled: (_data, _error, { message, chatId }) => {
335
+ if (message.role === "assistant" && isAuthenticated) {
336
+ // Sync the full message tree after the mutation settles so parallel
337
+ // response siblings get their updated activeStreamId from the server.
338
+ // Placed in onSettled (not onSuccess) so this runs after the real
339
+ // backend write when the mutationFn is eventually made server-side.
340
+ qc.invalidateQueries({
341
+ queryKey: trpc.chat.getChatMessages.queryKey({ chatId }),
342
+ });
343
+ }
344
+ },
334
345
  });
335
346
  }
336
347
 
@@ -0,0 +1,39 @@
1
+ import { useChatActions } from "@ai-sdk-tools/store";
2
+ import { useCallback } from "react";
3
+ import { useDataStream } from "@/components/data-stream-provider";
4
+ import { useArtifact } from "@/hooks/use-artifact";
5
+ import type { ChatMessage } from "@/lib/ai/types";
6
+ import { useSwitchToMessage } from "@/lib/stores/hooks-threads";
7
+
8
+ export function useNavigateToMessage() {
9
+ const { stop } = useChatActions<ChatMessage>();
10
+ const { setDataStream } = useDataStream();
11
+ const { artifact, closeArtifact } = useArtifact();
12
+ const switchToMessage = useSwitchToMessage();
13
+
14
+ return useCallback(
15
+ (messageId: string) => {
16
+ stop?.();
17
+ setDataStream([]);
18
+
19
+ const newThread = switchToMessage(messageId);
20
+
21
+ if (
22
+ newThread &&
23
+ artifact.isVisible &&
24
+ artifact.messageId &&
25
+ !newThread.some((message) => message.id === artifact.messageId)
26
+ ) {
27
+ closeArtifact();
28
+ }
29
+ },
30
+ [
31
+ artifact.isVisible,
32
+ artifact.messageId,
33
+ closeArtifact,
34
+ setDataStream,
35
+ stop,
36
+ switchToMessage,
37
+ ]
38
+ );
39
+ }
@@ -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: using type for mapped type compatibility
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: using type for mapped type compatibility
127
198
  export type CustomUIDataTypes = {
128
199
  appendMessage: string;
129
200
  chatConfirmed: {
@@ -224,9 +224,14 @@ export const featuresConfigSchema = z
224
224
  attachments: z
225
225
  .boolean()
226
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"),
227
231
  })
228
232
  .default({
229
233
  attachments: false,
234
+ parallelResponses: true,
230
235
  });
231
236
 
232
237
  export const authenticationConfigSchema = z
@@ -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