@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.
- package/dist/index.js +11 -6
- package/package.json +1 -1
- 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 +141 -124
- 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/types.ts +74 -3
- package/templates/chat-app/lib/config-schema.ts +5 -0
- 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/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
|
@@ -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
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
14
|
+
isLoading: boolean;
|
|
15
|
+
isReadonly: boolean;
|
|
16
|
+
messageId: string;
|
|
17
|
+
parentMessageId: string | null;
|
|
17
18
|
}
|
|
18
19
|
|
|
19
20
|
const PureUserMessage = ({
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
21
|
+
messageId,
|
|
22
|
+
isLoading,
|
|
23
|
+
isReadonly,
|
|
24
|
+
parentMessageId,
|
|
24
25
|
}: BaseMessageProps) => {
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
35
|
+
const handleImageClick = (imageUrl: string, imageName?: string) => {
|
|
36
|
+
setImageModal({ isOpen: true, imageUrl, imageName });
|
|
37
|
+
};
|
|
37
38
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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
|
-
|
|
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
|