@getjack/jack 0.1.34 → 0.1.36
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/README.md +6 -6
- package/package.json +1 -1
- package/src/commands/down.ts +39 -7
- package/src/commands/link.ts +2 -4
- package/src/commands/logs.ts +2 -4
- package/src/commands/mcp.ts +12 -10
- package/src/commands/services.ts +4 -2
- package/src/commands/sync.ts +5 -6
- package/src/commands/update.ts +1 -0
- package/src/index.ts +8 -0
- package/src/lib/auth/client.ts +5 -2
- package/src/lib/binding-validator.ts +39 -3
- package/src/lib/build-helper.ts +18 -19
- package/src/lib/control-plane.ts +45 -0
- package/src/lib/do-config.ts +110 -0
- package/src/lib/do-export-validator.ts +26 -0
- package/src/lib/jsonc-edit.ts +292 -0
- package/src/lib/managed-deploy.ts +36 -1
- package/src/lib/project-link.ts +37 -0
- package/src/lib/project-operations.ts +31 -66
- package/src/lib/resources.ts +4 -5
- package/src/lib/schema.ts +8 -12
- package/src/lib/services/db-create.ts +2 -2
- package/src/lib/services/db-execute.ts +9 -6
- package/src/lib/services/db-list.ts +6 -4
- package/src/lib/services/endpoint-test.ts +275 -0
- package/src/lib/services/project-delete.ts +190 -0
- package/src/lib/services/project-environment.ts +579 -0
- package/src/lib/services/storage-config.ts +7 -309
- package/src/lib/services/storage-create.ts +2 -1
- package/src/lib/services/storage-delete.ts +3 -2
- package/src/lib/services/storage-info.ts +2 -1
- package/src/lib/services/storage-list.ts +6 -3
- package/src/lib/services/vectorize-config.ts +7 -264
- package/src/lib/services/vectorize-create.ts +2 -1
- package/src/lib/services/vectorize-delete.ts +6 -4
- package/src/lib/services/vectorize-list.ts +6 -3
- package/src/lib/storage/index.ts +21 -23
- package/src/lib/telemetry.ts +1 -0
- package/src/lib/wrangler-config.ts +43 -312
- package/src/lib/zip-packager.ts +28 -0
- package/src/mcp/test-utils.ts +31 -0
- package/src/mcp/tools/index.ts +280 -2
- package/src/templates/index.ts +5 -0
- package/src/templates/types.ts +4 -0
- package/templates/AI-BINDINGS.md +34 -76
- package/templates/CLAUDE.md +1 -1
- package/templates/ai-chat/src/index.ts +7 -14
- package/templates/ai-chat/src/jack-ai.ts +0 -6
- package/templates/chat/.jack.json +45 -0
- package/templates/chat/bun.lock +1584 -0
- package/templates/chat/components.json +23 -0
- package/templates/chat/index.html +12 -0
- package/templates/chat/package.json +41 -0
- package/templates/chat/src/chat-agent.ts +63 -0
- package/templates/chat/src/client/app.tsx +189 -0
- package/templates/chat/src/client/chat.tsx +222 -0
- package/templates/chat/src/client/components/prompt-kit/chat-container.tsx +47 -0
- package/templates/chat/src/client/components/prompt-kit/loader.tsx +33 -0
- package/templates/chat/src/client/components/prompt-kit/markdown.tsx +84 -0
- package/templates/chat/src/client/components/prompt-kit/message.tsx +54 -0
- package/templates/chat/src/client/components/prompt-kit/prompt-suggestion.tsx +20 -0
- package/templates/chat/src/client/components/prompt-kit/reasoning.tsx +134 -0
- package/templates/chat/src/client/components/prompt-kit/scroll-button.tsx +28 -0
- package/templates/chat/src/client/components/ui/button.tsx +38 -0
- package/templates/chat/src/client/lib/utils.ts +6 -0
- package/templates/chat/src/client/main.tsx +11 -0
- package/templates/chat/src/client/styles.css +125 -0
- package/templates/chat/src/index.ts +25 -0
- package/templates/chat/src/jack-ai.ts +94 -0
- package/templates/chat/tsconfig.json +18 -0
- package/templates/chat/vite.config.ts +14 -0
- package/templates/chat/wrangler.jsonc +18 -0
- package/templates/cron/.jack.json +18 -28
- package/templates/cron/schema.sql +10 -20
- package/templates/cron/src/admin.ts +321 -0
- package/templates/cron/src/index.ts +151 -81
- package/templates/cron/src/monitor.ts +124 -0
- package/templates/semantic-search/src/index.ts +5 -43
- package/templates/semantic-search/src/jack-ai.ts +0 -6
- package/templates/telegram-bot/.jack.json +56 -0
- package/templates/telegram-bot/bun.lock +41 -0
- package/templates/telegram-bot/package.json +16 -0
- package/templates/telegram-bot/src/index.ts +236 -0
- package/templates/telegram-bot/src/jack-ai.ts +100 -0
- package/templates/telegram-bot/tsconfig.json +11 -0
- package/templates/telegram-bot/wrangler.jsonc +8 -0
- package/templates/cron/src/jobs.ts +0 -139
- package/templates/cron/src/webhooks.ts +0 -95
- package/templates/semantic-search/src/jack-vectorize.ts +0 -169
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://ui.shadcn.com/schema.json",
|
|
3
|
+
"style": "new-york",
|
|
4
|
+
"rsc": false,
|
|
5
|
+
"tsx": true,
|
|
6
|
+
"tailwind": {
|
|
7
|
+
"config": "",
|
|
8
|
+
"css": "src/client/styles.css",
|
|
9
|
+
"baseColor": "neutral",
|
|
10
|
+
"cssVariables": true,
|
|
11
|
+
"prefix": ""
|
|
12
|
+
},
|
|
13
|
+
"iconLibrary": "lucide",
|
|
14
|
+
"rtl": false,
|
|
15
|
+
"aliases": {
|
|
16
|
+
"components": "@/components",
|
|
17
|
+
"utils": "@/lib/utils",
|
|
18
|
+
"ui": "@/components/ui",
|
|
19
|
+
"lib": "@/lib",
|
|
20
|
+
"hooks": "@/hooks"
|
|
21
|
+
},
|
|
22
|
+
"registries": {}
|
|
23
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en" class="dark">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
|
+
<title>jack-template</title>
|
|
7
|
+
</head>
|
|
8
|
+
<body>
|
|
9
|
+
<div id="root"></div>
|
|
10
|
+
<script type="module" src="/src/client/main.tsx"></script>
|
|
11
|
+
</body>
|
|
12
|
+
</html>
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "jack-template",
|
|
3
|
+
"type": "module",
|
|
4
|
+
"scripts": {
|
|
5
|
+
"dev": "vite",
|
|
6
|
+
"build": "vite build",
|
|
7
|
+
"preview": "vite preview"
|
|
8
|
+
},
|
|
9
|
+
"dependencies": {
|
|
10
|
+
"@ai-sdk/react": "^3.0.88",
|
|
11
|
+
"@cloudflare/ai-chat": "^0.1.0",
|
|
12
|
+
"@tailwindcss/typography": "^0.5.19",
|
|
13
|
+
"agents": "^0.5",
|
|
14
|
+
"ai": "^6",
|
|
15
|
+
"class-variance-authority": "^0.7.1",
|
|
16
|
+
"clsx": "^2.1.1",
|
|
17
|
+
"lucide-react": "^0.564.0",
|
|
18
|
+
"radix-ui": "^1.4.3",
|
|
19
|
+
"react": "^19",
|
|
20
|
+
"react-dom": "^19",
|
|
21
|
+
"react-markdown": "^10.1.0",
|
|
22
|
+
"remark-breaks": "^4.0.0",
|
|
23
|
+
"remark-gfm": "^4.0.1",
|
|
24
|
+
"tailwind-merge": "^3.4.1",
|
|
25
|
+
"use-stick-to-bottom": "^1.1.3",
|
|
26
|
+
"workers-ai-provider": "^3"
|
|
27
|
+
},
|
|
28
|
+
"devDependencies": {
|
|
29
|
+
"@cloudflare/vite-plugin": "^1",
|
|
30
|
+
"@cloudflare/workers-types": "^4.20241205.0",
|
|
31
|
+
"@tailwindcss/vite": "^4",
|
|
32
|
+
"@types/react": "^19",
|
|
33
|
+
"@types/react-dom": "^19",
|
|
34
|
+
"@vitejs/plugin-react": "^4",
|
|
35
|
+
"shadcn": "^3.8.5",
|
|
36
|
+
"tailwindcss": "^4",
|
|
37
|
+
"tw-animate-css": "^1.4.0",
|
|
38
|
+
"typescript": "^5",
|
|
39
|
+
"vite": "^6"
|
|
40
|
+
}
|
|
41
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { AIChatAgent } from "@cloudflare/ai-chat";
|
|
2
|
+
import {
|
|
3
|
+
type StreamTextOnFinishCallback,
|
|
4
|
+
type ToolSet,
|
|
5
|
+
convertToModelMessages,
|
|
6
|
+
extractReasoningMiddleware,
|
|
7
|
+
streamText,
|
|
8
|
+
wrapLanguageModel,
|
|
9
|
+
} from "ai";
|
|
10
|
+
import { createWorkersAI } from "workers-ai-provider";
|
|
11
|
+
import { createJackAI } from "./jack-ai";
|
|
12
|
+
|
|
13
|
+
interface Env {
|
|
14
|
+
AI?: Ai;
|
|
15
|
+
__AI_PROXY?: Fetcher;
|
|
16
|
+
ASSETS: Fetcher;
|
|
17
|
+
Chat: DurableObjectNamespace;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function getAIProvider(env: Env) {
|
|
21
|
+
if (env.__AI_PROXY) {
|
|
22
|
+
const jackAI = createJackAI(env as { __AI_PROXY: Fetcher });
|
|
23
|
+
return createWorkersAI({ binding: jackAI as unknown as Ai });
|
|
24
|
+
}
|
|
25
|
+
if (env.AI) {
|
|
26
|
+
return createWorkersAI({ binding: env.AI });
|
|
27
|
+
}
|
|
28
|
+
throw new Error("No AI binding available");
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export class Chat extends AIChatAgent<Env> {
|
|
32
|
+
maxPersistedMessages = 500;
|
|
33
|
+
|
|
34
|
+
async onChatMessage(onFinish: StreamTextOnFinishCallback<ToolSet>) {
|
|
35
|
+
const provider = getAIProvider(this.env);
|
|
36
|
+
|
|
37
|
+
// Fast general-purpose model (recommended default)
|
|
38
|
+
const model = provider(
|
|
39
|
+
"@cf/meta/llama-3.3-70b-instruct-fp8-fast" as Parameters<typeof provider>[0],
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
// Reasoning model — uncomment for chain-of-thought (shows thinking process):
|
|
43
|
+
// const baseModel = provider(
|
|
44
|
+
// "@cf/deepseek-ai/deepseek-r1-distill-qwen-32b" as Parameters<typeof provider>[0],
|
|
45
|
+
// );
|
|
46
|
+
// const model = wrapLanguageModel({
|
|
47
|
+
// model: baseModel,
|
|
48
|
+
// middleware: extractReasoningMiddleware({ tagName: "think" }),
|
|
49
|
+
// });
|
|
50
|
+
|
|
51
|
+
const result = streamText({
|
|
52
|
+
model,
|
|
53
|
+
system:
|
|
54
|
+
"You are a helpful assistant. Be concise and direct. " +
|
|
55
|
+
"Use short paragraphs. Only use markdown formatting when it genuinely helps clarity.",
|
|
56
|
+
messages: await convertToModelMessages(this.messages),
|
|
57
|
+
maxOutputTokens: 2048,
|
|
58
|
+
onFinish,
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
return result.toUIMessageStreamResponse();
|
|
62
|
+
}
|
|
63
|
+
}
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import { Button } from "@/components/ui/button";
|
|
2
|
+
import { MessageSquarePlus, Pencil, Share2 } from "lucide-react";
|
|
3
|
+
import { Suspense, useCallback, useEffect, useRef, useState } from "react";
|
|
4
|
+
import Chat from "./chat";
|
|
5
|
+
|
|
6
|
+
const ADJECTIVES = [
|
|
7
|
+
"Happy",
|
|
8
|
+
"Fuzzy",
|
|
9
|
+
"Cosmic",
|
|
10
|
+
"Crispy",
|
|
11
|
+
"Spicy",
|
|
12
|
+
"Chill",
|
|
13
|
+
"Zesty",
|
|
14
|
+
"Snappy",
|
|
15
|
+
"Bouncy",
|
|
16
|
+
"Groovy",
|
|
17
|
+
];
|
|
18
|
+
const FRUITS = [
|
|
19
|
+
"Mango",
|
|
20
|
+
"Peach",
|
|
21
|
+
"Lemon",
|
|
22
|
+
"Kiwi",
|
|
23
|
+
"Melon",
|
|
24
|
+
"Berry",
|
|
25
|
+
"Guava",
|
|
26
|
+
"Plum",
|
|
27
|
+
"Fig",
|
|
28
|
+
"Grape",
|
|
29
|
+
];
|
|
30
|
+
|
|
31
|
+
function generateUsername(): string {
|
|
32
|
+
const adj = ADJECTIVES[Math.floor(Math.random() * ADJECTIVES.length)];
|
|
33
|
+
const fruit = FRUITS[Math.floor(Math.random() * FRUITS.length)];
|
|
34
|
+
const num = Math.floor(Math.random() * 100);
|
|
35
|
+
return `${adj}${fruit}${num}`;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function getOrCreateUsername(): string {
|
|
39
|
+
const stored = localStorage.getItem("jack-chat-username");
|
|
40
|
+
if (stored) return stored;
|
|
41
|
+
const name = generateUsername();
|
|
42
|
+
localStorage.setItem("jack-chat-username", name);
|
|
43
|
+
return name;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function generateRoomId(): string {
|
|
47
|
+
return crypto.randomUUID().replace(/-/g, "").slice(0, 12);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function getRoomFromPath(): string | null {
|
|
51
|
+
const match = window.location.pathname.match(/^\/room\/([^/]+)/);
|
|
52
|
+
return match ? match[1] : null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export default function App() {
|
|
56
|
+
const [username, setUsername] = useState(getOrCreateUsername);
|
|
57
|
+
const [isEditingName, setIsEditingName] = useState(false);
|
|
58
|
+
const [nameInput, setNameInput] = useState(username);
|
|
59
|
+
const nameInputRef = useRef<HTMLInputElement>(null);
|
|
60
|
+
|
|
61
|
+
const pathRoom = getRoomFromPath();
|
|
62
|
+
const [roomId, setRoomId] = useState<string>(() => pathRoom || generateRoomId());
|
|
63
|
+
const [isSharedRoom, setIsSharedRoom] = useState(!!pathRoom);
|
|
64
|
+
|
|
65
|
+
useEffect(() => {
|
|
66
|
+
if (!getRoomFromPath()) {
|
|
67
|
+
window.history.replaceState(null, "", `/room/${roomId}`);
|
|
68
|
+
}
|
|
69
|
+
}, [roomId]);
|
|
70
|
+
|
|
71
|
+
useEffect(() => {
|
|
72
|
+
const handlePopState = () => {
|
|
73
|
+
const room = getRoomFromPath();
|
|
74
|
+
if (room) {
|
|
75
|
+
setRoomId(room);
|
|
76
|
+
setIsSharedRoom(true);
|
|
77
|
+
}
|
|
78
|
+
};
|
|
79
|
+
window.addEventListener("popstate", handlePopState);
|
|
80
|
+
return () => window.removeEventListener("popstate", handlePopState);
|
|
81
|
+
}, []);
|
|
82
|
+
|
|
83
|
+
const handleNewChat = useCallback(() => {
|
|
84
|
+
const newId = generateRoomId();
|
|
85
|
+
setRoomId(newId);
|
|
86
|
+
setIsSharedRoom(false);
|
|
87
|
+
window.history.pushState(null, "", `/room/${newId}`);
|
|
88
|
+
}, []);
|
|
89
|
+
|
|
90
|
+
const [copied, setCopied] = useState(false);
|
|
91
|
+
const copiedTimer = useRef<ReturnType<typeof setTimeout>>(undefined);
|
|
92
|
+
|
|
93
|
+
const handleShareRoom = useCallback(async () => {
|
|
94
|
+
try {
|
|
95
|
+
await navigator.clipboard.writeText(window.location.href);
|
|
96
|
+
setCopied(true);
|
|
97
|
+
clearTimeout(copiedTimer.current);
|
|
98
|
+
copiedTimer.current = setTimeout(() => setCopied(false), 2000);
|
|
99
|
+
} catch {
|
|
100
|
+
// Fallback: silently fail if clipboard API is unavailable
|
|
101
|
+
}
|
|
102
|
+
}, []);
|
|
103
|
+
|
|
104
|
+
const saveName = () => {
|
|
105
|
+
const trimmed = nameInput.trim();
|
|
106
|
+
if (trimmed) {
|
|
107
|
+
setUsername(trimmed);
|
|
108
|
+
localStorage.setItem("jack-chat-username", trimmed);
|
|
109
|
+
} else {
|
|
110
|
+
setNameInput(username);
|
|
111
|
+
}
|
|
112
|
+
setIsEditingName(false);
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
useEffect(() => {
|
|
116
|
+
if (isEditingName && nameInputRef.current) {
|
|
117
|
+
nameInputRef.current.focus();
|
|
118
|
+
nameInputRef.current.select();
|
|
119
|
+
}
|
|
120
|
+
}, [isEditingName]);
|
|
121
|
+
|
|
122
|
+
const chatContent = isSharedRoom ? (
|
|
123
|
+
<Suspense
|
|
124
|
+
fallback={
|
|
125
|
+
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
|
|
126
|
+
Loading conversation...
|
|
127
|
+
</div>
|
|
128
|
+
}
|
|
129
|
+
>
|
|
130
|
+
<Chat key={roomId} roomId={roomId} username={username} loadHistory />
|
|
131
|
+
</Suspense>
|
|
132
|
+
) : (
|
|
133
|
+
<Chat key={roomId} roomId={roomId} username={username} />
|
|
134
|
+
);
|
|
135
|
+
|
|
136
|
+
return (
|
|
137
|
+
<div className="flex h-dvh flex-col bg-background text-foreground">
|
|
138
|
+
<header className="flex items-center justify-between border-b border-border px-4 py-2.5">
|
|
139
|
+
<div className="flex items-center gap-3">
|
|
140
|
+
<h1 className="text-base font-semibold tracking-tight">jack-template</h1>
|
|
141
|
+
<div className="flex items-center gap-1 text-xs text-muted-foreground">
|
|
142
|
+
<span className="hidden sm:inline">as</span>
|
|
143
|
+
{isEditingName ? (
|
|
144
|
+
<input
|
|
145
|
+
ref={nameInputRef}
|
|
146
|
+
value={nameInput}
|
|
147
|
+
onChange={(e) => setNameInput(e.target.value)}
|
|
148
|
+
onBlur={saveName}
|
|
149
|
+
onKeyDown={(e) => {
|
|
150
|
+
if (e.key === "Enter") saveName();
|
|
151
|
+
if (e.key === "Escape") {
|
|
152
|
+
setNameInput(username);
|
|
153
|
+
setIsEditingName(false);
|
|
154
|
+
}
|
|
155
|
+
}}
|
|
156
|
+
className="w-24 rounded border border-input bg-secondary px-1.5 py-0.5 text-xs text-foreground focus:outline-none focus:ring-1 focus:ring-ring"
|
|
157
|
+
maxLength={20}
|
|
158
|
+
/>
|
|
159
|
+
) : (
|
|
160
|
+
<button
|
|
161
|
+
type="button"
|
|
162
|
+
onClick={() => {
|
|
163
|
+
setNameInput(username);
|
|
164
|
+
setIsEditingName(true);
|
|
165
|
+
}}
|
|
166
|
+
className="flex items-center gap-1 rounded px-1.5 py-0.5 text-foreground hover:bg-secondary transition-colors"
|
|
167
|
+
>
|
|
168
|
+
{username}
|
|
169
|
+
<Pencil className="h-3 w-3 text-muted-foreground" />
|
|
170
|
+
</button>
|
|
171
|
+
)}
|
|
172
|
+
</div>
|
|
173
|
+
</div>
|
|
174
|
+
<div className="flex items-center gap-2">
|
|
175
|
+
<Button variant="ghost" size="sm" onClick={handleShareRoom}>
|
|
176
|
+
<Share2 className="h-4 w-4" />
|
|
177
|
+
{copied ? "Copied!" : "Share"}
|
|
178
|
+
</Button>
|
|
179
|
+
<Button variant="secondary" size="sm" onClick={handleNewChat}>
|
|
180
|
+
<MessageSquarePlus className="h-4 w-4" />
|
|
181
|
+
New Chat
|
|
182
|
+
</Button>
|
|
183
|
+
</div>
|
|
184
|
+
</header>
|
|
185
|
+
|
|
186
|
+
<main className="min-h-0 flex-1">{chatContent}</main>
|
|
187
|
+
</div>
|
|
188
|
+
);
|
|
189
|
+
}
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ChatContainerContent,
|
|
3
|
+
ChatContainerRoot,
|
|
4
|
+
ChatContainerScrollAnchor,
|
|
5
|
+
} from "@/components/prompt-kit/chat-container";
|
|
6
|
+
import { TextShimmerLoader } from "@/components/prompt-kit/loader";
|
|
7
|
+
import { Message, MessageAvatar, MessageContent } from "@/components/prompt-kit/message";
|
|
8
|
+
import { PromptSuggestion } from "@/components/prompt-kit/prompt-suggestion";
|
|
9
|
+
import { Reasoning, ReasoningContent, ReasoningTrigger } from "@/components/prompt-kit/reasoning";
|
|
10
|
+
import { ScrollButton } from "@/components/prompt-kit/scroll-button";
|
|
11
|
+
import { Button } from "@/components/ui/button";
|
|
12
|
+
import { cn } from "@/lib/utils";
|
|
13
|
+
import { useAgentChat } from "@cloudflare/ai-chat/react";
|
|
14
|
+
import { useAgent } from "agents/react";
|
|
15
|
+
import type { UIMessage } from "ai";
|
|
16
|
+
import { ArrowUp, Square } from "lucide-react";
|
|
17
|
+
import { type KeyboardEvent, useEffect, useRef, useState } from "react";
|
|
18
|
+
|
|
19
|
+
const SUGGESTIONS = [
|
|
20
|
+
"What can you help me with?",
|
|
21
|
+
"Write a haiku about coding",
|
|
22
|
+
"Explain quantum computing simply",
|
|
23
|
+
"Tell me a fun fact",
|
|
24
|
+
];
|
|
25
|
+
|
|
26
|
+
interface ChatProps {
|
|
27
|
+
roomId: string;
|
|
28
|
+
username: string;
|
|
29
|
+
loadHistory?: boolean;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export default function Chat({ roomId, username, loadHistory }: ChatProps) {
|
|
33
|
+
const agent = useAgent({ agent: "chat", name: roomId });
|
|
34
|
+
|
|
35
|
+
const { messages, sendMessage, status, stop } = useAgentChat({
|
|
36
|
+
agent,
|
|
37
|
+
getInitialMessages: loadHistory ? undefined : null,
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
const [input, setInput] = useState("");
|
|
41
|
+
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
|
42
|
+
|
|
43
|
+
const isStreaming = status === "streaming";
|
|
44
|
+
const isLoading = status === "submitted";
|
|
45
|
+
|
|
46
|
+
// Auto-resize textarea
|
|
47
|
+
const inputLength = input.length;
|
|
48
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: trigger on input change
|
|
49
|
+
useEffect(() => {
|
|
50
|
+
const textarea = textareaRef.current;
|
|
51
|
+
if (textarea) {
|
|
52
|
+
textarea.style.height = "auto";
|
|
53
|
+
textarea.style.height = `${Math.min(textarea.scrollHeight, 200)}px`;
|
|
54
|
+
}
|
|
55
|
+
}, [inputLength]);
|
|
56
|
+
|
|
57
|
+
const send = (text?: string) => {
|
|
58
|
+
const msg = text ?? input.trim();
|
|
59
|
+
if (!msg || isStreaming || isLoading) return;
|
|
60
|
+
sendMessage({ text: msg });
|
|
61
|
+
setInput("");
|
|
62
|
+
if (textareaRef.current) {
|
|
63
|
+
textareaRef.current.style.height = "auto";
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const onKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {
|
|
68
|
+
if (e.key === "Enter" && !e.shiftKey) {
|
|
69
|
+
e.preventDefault();
|
|
70
|
+
send();
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
function getMessageText(message: UIMessage): string {
|
|
75
|
+
return message.parts
|
|
76
|
+
.filter((part) => part.type === "text")
|
|
77
|
+
.map((part) => part.text)
|
|
78
|
+
.join("");
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function getReasoningText(message: UIMessage): string | null {
|
|
82
|
+
const texts: string[] = [];
|
|
83
|
+
for (const part of message.parts) {
|
|
84
|
+
if (part.type === "reasoning") {
|
|
85
|
+
texts.push(part.text);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return texts.length > 0 ? texts.join("") : null;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const lastMessage = messages.length > 0 ? messages[messages.length - 1] : null;
|
|
92
|
+
const isWaitingForResponse = isLoading || (isStreaming && lastMessage?.role === "user");
|
|
93
|
+
|
|
94
|
+
const userInitials = username.slice(0, 2).toUpperCase();
|
|
95
|
+
|
|
96
|
+
return (
|
|
97
|
+
<div className="flex h-full flex-col">
|
|
98
|
+
<ChatContainerRoot className="flex-1">
|
|
99
|
+
<ChatContainerContent className="mx-auto max-w-3xl px-4 py-6">
|
|
100
|
+
{messages.length === 0 && (
|
|
101
|
+
<div className="flex flex-1 flex-col items-center justify-center gap-8">
|
|
102
|
+
<div className="max-w-lg space-y-3 text-center">
|
|
103
|
+
<h2 className="text-2xl font-semibold text-foreground">jack-template</h2>
|
|
104
|
+
<p className="text-sm leading-relaxed text-muted-foreground">
|
|
105
|
+
Real-time AI chat with persistent rooms. Share the link — anyone
|
|
106
|
+
who opens it joins this conversation live. No database, no
|
|
107
|
+
WebSocket server, just code.
|
|
108
|
+
</p>
|
|
109
|
+
</div>
|
|
110
|
+
<div className="flex flex-wrap justify-center gap-2">
|
|
111
|
+
{SUGGESTIONS.map((s) => (
|
|
112
|
+
<PromptSuggestion key={s} onClick={() => send(s)}>
|
|
113
|
+
{s}
|
|
114
|
+
</PromptSuggestion>
|
|
115
|
+
))}
|
|
116
|
+
</div>
|
|
117
|
+
</div>
|
|
118
|
+
)}
|
|
119
|
+
|
|
120
|
+
{messages.map((message: UIMessage, index: number) => {
|
|
121
|
+
const isUser = message.role === "user";
|
|
122
|
+
const text = getMessageText(message);
|
|
123
|
+
const reasoning = isUser ? null : getReasoningText(message);
|
|
124
|
+
const isLast = index === messages.length - 1;
|
|
125
|
+
const isStreamingThis = isLast && !isUser && isStreaming;
|
|
126
|
+
|
|
127
|
+
if (isUser) {
|
|
128
|
+
return (
|
|
129
|
+
<Message key={message.id} className={cn("mb-4 flex-row-reverse")}>
|
|
130
|
+
<MessageAvatar
|
|
131
|
+
initials={userInitials}
|
|
132
|
+
className="bg-primary text-primary-foreground"
|
|
133
|
+
/>
|
|
134
|
+
<div className="min-w-0 max-w-[75%]">
|
|
135
|
+
<div className="mb-1 text-right text-xs text-muted-foreground">{username}</div>
|
|
136
|
+
<div className="rounded-2xl rounded-tr-md bg-secondary px-4 py-2.5 text-sm">
|
|
137
|
+
{text}
|
|
138
|
+
</div>
|
|
139
|
+
</div>
|
|
140
|
+
</Message>
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return (
|
|
145
|
+
<Message key={message.id} className="mb-4">
|
|
146
|
+
<MessageAvatar initials="AI" className="bg-accent text-accent-foreground" />
|
|
147
|
+
<div className="min-w-0 max-w-[75%] space-y-1">
|
|
148
|
+
<div className="text-xs text-muted-foreground">AI</div>
|
|
149
|
+
{reasoning && (
|
|
150
|
+
<Reasoning isStreaming={isStreamingThis}>
|
|
151
|
+
<ReasoningTrigger>Reasoning</ReasoningTrigger>
|
|
152
|
+
<ReasoningContent markdown className="mt-1">
|
|
153
|
+
{reasoning}
|
|
154
|
+
</ReasoningContent>
|
|
155
|
+
</Reasoning>
|
|
156
|
+
)}
|
|
157
|
+
<MessageContent markdown>{isStreamingThis ? `${text}▍` : text}</MessageContent>
|
|
158
|
+
</div>
|
|
159
|
+
</Message>
|
|
160
|
+
);
|
|
161
|
+
})}
|
|
162
|
+
|
|
163
|
+
{isWaitingForResponse && (
|
|
164
|
+
<Message className="mb-4">
|
|
165
|
+
<MessageAvatar initials="AI" className="bg-accent text-accent-foreground" />
|
|
166
|
+
<div className="pt-1">
|
|
167
|
+
<TextShimmerLoader />
|
|
168
|
+
</div>
|
|
169
|
+
</Message>
|
|
170
|
+
)}
|
|
171
|
+
|
|
172
|
+
<ChatContainerScrollAnchor />
|
|
173
|
+
</ChatContainerContent>
|
|
174
|
+
|
|
175
|
+
<div className="pointer-events-none sticky bottom-4 flex justify-center">
|
|
176
|
+
<ScrollButton className="pointer-events-auto" />
|
|
177
|
+
</div>
|
|
178
|
+
</ChatContainerRoot>
|
|
179
|
+
|
|
180
|
+
{/* Input area */}
|
|
181
|
+
<div className="border-t border-border bg-background p-4">
|
|
182
|
+
<div className="mx-auto max-w-3xl">
|
|
183
|
+
<div className="flex items-end gap-2 rounded-2xl border border-input bg-secondary p-2">
|
|
184
|
+
<textarea
|
|
185
|
+
ref={textareaRef}
|
|
186
|
+
value={input}
|
|
187
|
+
onChange={(e) => setInput(e.target.value)}
|
|
188
|
+
onKeyDown={onKeyDown}
|
|
189
|
+
placeholder="Type a message..."
|
|
190
|
+
rows={1}
|
|
191
|
+
className="min-h-[36px] flex-1 resize-none bg-transparent px-2 py-1.5 text-sm text-foreground placeholder-muted-foreground focus:outline-none"
|
|
192
|
+
/>
|
|
193
|
+
{isStreaming ? (
|
|
194
|
+
<Button
|
|
195
|
+
size="icon"
|
|
196
|
+
variant="ghost"
|
|
197
|
+
onClick={() => stop()}
|
|
198
|
+
className="h-9 w-9 shrink-0 rounded-xl"
|
|
199
|
+
aria-label="Stop generating"
|
|
200
|
+
>
|
|
201
|
+
<Square className="h-4 w-4" />
|
|
202
|
+
</Button>
|
|
203
|
+
) : (
|
|
204
|
+
<Button
|
|
205
|
+
size="icon"
|
|
206
|
+
onClick={() => send()}
|
|
207
|
+
disabled={!input.trim() || isLoading}
|
|
208
|
+
className="h-9 w-9 shrink-0 rounded-xl"
|
|
209
|
+
aria-label="Send message"
|
|
210
|
+
>
|
|
211
|
+
<ArrowUp className="h-5 w-5" />
|
|
212
|
+
</Button>
|
|
213
|
+
)}
|
|
214
|
+
</div>
|
|
215
|
+
<p className="mt-1.5 text-center text-xs text-muted-foreground/60">
|
|
216
|
+
Shift+Enter for new line
|
|
217
|
+
</p>
|
|
218
|
+
</div>
|
|
219
|
+
</div>
|
|
220
|
+
</div>
|
|
221
|
+
);
|
|
222
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { cn } from "@/lib/utils";
|
|
2
|
+
import { StickToBottom } from "use-stick-to-bottom";
|
|
3
|
+
|
|
4
|
+
function ChatContainerRoot({
|
|
5
|
+
children,
|
|
6
|
+
className,
|
|
7
|
+
...props
|
|
8
|
+
}: { children: React.ReactNode; className?: string } & React.HTMLAttributes<HTMLDivElement>) {
|
|
9
|
+
return (
|
|
10
|
+
<StickToBottom
|
|
11
|
+
className={cn("flex overflow-y-auto", className)}
|
|
12
|
+
resize="smooth"
|
|
13
|
+
initial="instant"
|
|
14
|
+
role="log"
|
|
15
|
+
{...props}
|
|
16
|
+
>
|
|
17
|
+
{children}
|
|
18
|
+
</StickToBottom>
|
|
19
|
+
);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function ChatContainerContent({
|
|
23
|
+
children,
|
|
24
|
+
className,
|
|
25
|
+
...props
|
|
26
|
+
}: { children: React.ReactNode; className?: string } & React.HTMLAttributes<HTMLDivElement>) {
|
|
27
|
+
return (
|
|
28
|
+
<StickToBottom.Content className={cn("flex w-full flex-col", className)} {...props}>
|
|
29
|
+
{children}
|
|
30
|
+
</StickToBottom.Content>
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function ChatContainerScrollAnchor({
|
|
35
|
+
className,
|
|
36
|
+
...props
|
|
37
|
+
}: { className?: string } & React.HTMLAttributes<HTMLDivElement>) {
|
|
38
|
+
return (
|
|
39
|
+
<div
|
|
40
|
+
className={cn("h-px w-full shrink-0 scroll-mt-4", className)}
|
|
41
|
+
aria-hidden="true"
|
|
42
|
+
{...props}
|
|
43
|
+
/>
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export { ChatContainerRoot, ChatContainerContent, ChatContainerScrollAnchor };
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { cn } from "@/lib/utils";
|
|
2
|
+
|
|
3
|
+
function TypingLoader({
|
|
4
|
+
className,
|
|
5
|
+
size = "md",
|
|
6
|
+
}: { className?: string; size?: "sm" | "md" | "lg" }) {
|
|
7
|
+
const dotSizes = { sm: "h-1 w-1", md: "h-1.5 w-1.5", lg: "h-2 w-2" };
|
|
8
|
+
return (
|
|
9
|
+
<div className={cn("flex items-center space-x-1", className)}>
|
|
10
|
+
{[0, 1, 2].map((i) => (
|
|
11
|
+
<div
|
|
12
|
+
key={i}
|
|
13
|
+
className={cn("bg-muted-foreground/60 animate-bounce rounded-full", dotSizes[size])}
|
|
14
|
+
style={{ animationDelay: `${i * 150}ms`, animationDuration: "1s" }}
|
|
15
|
+
/>
|
|
16
|
+
))}
|
|
17
|
+
<span className="sr-only">Loading</span>
|
|
18
|
+
</div>
|
|
19
|
+
);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function TextShimmerLoader({
|
|
23
|
+
text = "Thinking",
|
|
24
|
+
className,
|
|
25
|
+
}: { text?: string; className?: string }) {
|
|
26
|
+
return (
|
|
27
|
+
<span className={cn("animate-pulse text-sm font-medium text-muted-foreground", className)}>
|
|
28
|
+
{text}...
|
|
29
|
+
</span>
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export { TypingLoader, TextShimmerLoader };
|