@mandujs/cli 0.15.1 → 0.15.3
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.ko.md +33 -33
- package/README.md +354 -354
- package/package.json +2 -2
- package/src/commands/check.ts +71 -7
- package/src/commands/contract.ts +173 -173
- package/src/commands/dev.ts +9 -42
- package/src/commands/guard-arch.ts +303 -303
- package/src/commands/init.ts +50 -5
- package/src/commands/monitor.ts +300 -300
- package/src/commands/openapi.ts +107 -107
- package/src/commands/registry.ts +1 -0
- package/src/commands/start.ts +9 -42
- package/src/errors/codes.ts +35 -35
- package/src/errors/index.ts +2 -2
- package/src/errors/messages.ts +143 -143
- package/src/hooks/index.ts +17 -17
- package/src/hooks/preaction.ts +256 -256
- package/src/main.ts +9 -7
- package/src/terminal/banner.ts +166 -166
- package/src/terminal/help.ts +306 -306
- package/src/terminal/index.ts +71 -71
- package/src/terminal/output.ts +295 -295
- package/src/terminal/palette.ts +30 -30
- package/src/terminal/progress.ts +327 -327
- package/src/terminal/stream-writer.ts +214 -214
- package/src/terminal/table.ts +354 -354
- package/src/terminal/theme.ts +142 -142
- package/src/util/bun.ts +6 -6
- package/src/util/fs.ts +23 -23
- package/src/util/handlers.ts +49 -5
- package/src/util/lockfile.ts +66 -0
- package/src/util/output.ts +22 -22
- package/src/util/port.ts +71 -71
- package/templates/default/AGENTS.md +96 -96
- package/templates/default/app/api/health/route.ts +13 -13
- package/templates/default/app/globals.css +49 -49
- package/templates/default/app/layout.tsx +27 -27
- package/templates/default/app/page.tsx +38 -38
- package/templates/default/src/client/shared/lib/utils.ts +16 -16
- package/templates/default/src/client/shared/ui/button.tsx +57 -57
- package/templates/default/src/client/shared/ui/card.tsx +1 -1
- package/templates/default/src/client/shared/ui/index.ts +21 -21
- package/templates/default/src/client/shared/ui/input.tsx +5 -1
- package/templates/default/tests/example.test.ts +58 -58
- package/templates/default/tests/helpers.ts +52 -52
- package/templates/default/tests/setup.ts +9 -9
- package/templates/default/tsconfig.json +23 -23
- package/templates/realtime-chat/AGENTS.md +96 -0
- package/templates/realtime-chat/app/api/chat/messages/route.ts +63 -0
- package/templates/realtime-chat/app/api/chat/stream/route.ts +85 -0
- package/templates/realtime-chat/app/api/health/route.ts +13 -0
- package/templates/realtime-chat/app/globals.css +49 -0
- package/templates/realtime-chat/app/layout.tsx +27 -0
- package/templates/realtime-chat/app/page.tsx +16 -0
- package/templates/realtime-chat/package.json +34 -0
- package/templates/realtime-chat/src/client/app/index.ts +1 -0
- package/templates/realtime-chat/src/client/entities/index.ts +1 -0
- package/templates/realtime-chat/src/client/features/chat/chat-api.ts +209 -0
- package/templates/realtime-chat/src/client/features/chat/realtime-chat-starter.client.tsx +89 -0
- package/templates/realtime-chat/src/client/features/chat/use-realtime-chat.ts +65 -0
- package/templates/realtime-chat/src/client/features/index.ts +1 -0
- package/templates/realtime-chat/src/client/pages/index.ts +1 -0
- package/templates/realtime-chat/src/client/shared/index.ts +1 -0
- package/templates/realtime-chat/src/client/shared/lib/utils.ts +16 -0
- package/templates/realtime-chat/src/client/shared/ui/button.tsx +57 -0
- package/templates/realtime-chat/src/client/shared/ui/card.tsx +78 -0
- package/templates/realtime-chat/src/client/shared/ui/index.ts +21 -0
- package/templates/realtime-chat/src/client/shared/ui/input.tsx +28 -0
- package/templates/realtime-chat/src/client/widgets/index.ts +1 -0
- package/templates/realtime-chat/src/server/api/index.ts +1 -0
- package/templates/realtime-chat/src/server/application/ai-adapter.ts +24 -0
- package/templates/realtime-chat/src/server/application/chat-store.ts +158 -0
- package/templates/realtime-chat/src/server/application/index.ts +1 -0
- package/templates/realtime-chat/src/server/core/index.ts +1 -0
- package/templates/realtime-chat/src/server/domain/index.ts +1 -0
- package/templates/realtime-chat/src/server/infra/index.ts +1 -0
- package/templates/realtime-chat/src/shared/contracts/chat.ts +29 -0
- package/templates/realtime-chat/src/shared/contracts/index.ts +1 -0
- package/templates/realtime-chat/src/shared/env/index.ts +1 -0
- package/templates/realtime-chat/src/shared/schema/index.ts +1 -0
- package/templates/realtime-chat/src/shared/types/index.ts +1 -0
- package/templates/realtime-chat/src/shared/utils/client/index.ts +1 -0
- package/templates/realtime-chat/src/shared/utils/server/index.ts +1 -0
- package/templates/realtime-chat/tests/chat-api.sse.test.ts +188 -0
- package/templates/realtime-chat/tests/chat-starter.test.ts +200 -0
- package/templates/realtime-chat/tests/chat-store.concurrency.test.ts +39 -0
- package/templates/realtime-chat/tests/example.test.ts +58 -0
- package/templates/realtime-chat/tests/helpers.ts +52 -0
- package/templates/realtime-chat/tests/setup.ts +9 -0
- package/templates/realtime-chat/tsconfig.json +23 -0
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "{{PROJECT_NAME}}",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"packageManager": "bun@1.2.0",
|
|
6
|
+
"engines": {
|
|
7
|
+
"bun": ">=1.0.0"
|
|
8
|
+
},
|
|
9
|
+
"scripts": {
|
|
10
|
+
"dev": "mandu dev",
|
|
11
|
+
"build": "mandu build",
|
|
12
|
+
"start": "mandu start",
|
|
13
|
+
"check": "mandu check",
|
|
14
|
+
"guard": "mandu guard",
|
|
15
|
+
"test": "bun test"
|
|
16
|
+
},
|
|
17
|
+
"dependencies": {
|
|
18
|
+
"@mandujs/core": "{{CORE_VERSION}}",
|
|
19
|
+
"@radix-ui/react-slot": "^1.1.0",
|
|
20
|
+
"class-variance-authority": "^0.7.0",
|
|
21
|
+
"clsx": "^2.1.1",
|
|
22
|
+
"react": "^19.2.0",
|
|
23
|
+
"react-dom": "^19.2.0",
|
|
24
|
+
"tailwind-merge": "^2.5.2"
|
|
25
|
+
},
|
|
26
|
+
"devDependencies": {
|
|
27
|
+
"@mandujs/cli": "{{CLI_VERSION}}",
|
|
28
|
+
"@tailwindcss/cli": "^4.1.0",
|
|
29
|
+
"@types/react": "^19.2.0",
|
|
30
|
+
"@types/react-dom": "^19.2.0",
|
|
31
|
+
"tailwindcss": "^4.1.0",
|
|
32
|
+
"typescript": "^5.0.0"
|
|
33
|
+
}
|
|
34
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ChatHistoryResponse,
|
|
3
|
+
ChatMessage,
|
|
4
|
+
ChatMessagePayload,
|
|
5
|
+
ChatMessageResponse,
|
|
6
|
+
ChatStreamEvent,
|
|
7
|
+
} from "@/shared/contracts/chat";
|
|
8
|
+
|
|
9
|
+
const API_BASE = "/api/chat";
|
|
10
|
+
|
|
11
|
+
export type ChatStreamConnectionState =
|
|
12
|
+
| "connecting"
|
|
13
|
+
| "connected"
|
|
14
|
+
| "reconnecting"
|
|
15
|
+
| "failed"
|
|
16
|
+
| "closed";
|
|
17
|
+
|
|
18
|
+
interface ChatStreamOptions {
|
|
19
|
+
maxRetries?: number;
|
|
20
|
+
baseDelayMs?: number;
|
|
21
|
+
maxDelayMs?: number;
|
|
22
|
+
jitterRatio?: number;
|
|
23
|
+
random?: () => number;
|
|
24
|
+
eventSourceFactory?: (url: string) => EventSource;
|
|
25
|
+
onConnectionStateChange?: (state: ChatStreamConnectionState) => void;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
type ReconnectOptions = Required<Omit<ChatStreamOptions, "eventSourceFactory" | "onConnectionStateChange">>;
|
|
29
|
+
|
|
30
|
+
const DEFAULT_STREAM_OPTIONS: ReconnectOptions = {
|
|
31
|
+
maxRetries: 8,
|
|
32
|
+
baseDelayMs: 500,
|
|
33
|
+
maxDelayMs: 10_000,
|
|
34
|
+
jitterRatio: 0.25,
|
|
35
|
+
random: Math.random,
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export async function sendChatMessage(payload: ChatMessagePayload): Promise<ChatMessageResponse> {
|
|
39
|
+
const response = await fetch(`${API_BASE}/messages`, {
|
|
40
|
+
method: "POST",
|
|
41
|
+
headers: { "Content-Type": "application/json" },
|
|
42
|
+
body: JSON.stringify(payload),
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
if (!response.ok) {
|
|
46
|
+
throw new Error(`Failed to send message: ${response.status}`);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return response.json() as Promise<ChatMessageResponse>;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export async function fetchChatHistory(): Promise<ChatHistoryResponse> {
|
|
53
|
+
const response = await fetch(`${API_BASE}/messages`);
|
|
54
|
+
if (!response.ok) {
|
|
55
|
+
throw new Error(`Failed to load history: ${response.status}`);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return response.json() as Promise<ChatHistoryResponse>;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function mergeChatMessages(base: ChatMessage[], incoming: ChatMessage[]): ChatMessage[] {
|
|
62
|
+
const merged = new Map<string, ChatMessage>();
|
|
63
|
+
|
|
64
|
+
for (const message of base) {
|
|
65
|
+
merged.set(message.id, message);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
for (const message of incoming) {
|
|
69
|
+
merged.set(message.id, message);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return [...merged.values()].sort((a, b) => {
|
|
73
|
+
const byTime = a.createdAt.localeCompare(b.createdAt);
|
|
74
|
+
if (byTime !== 0) return byTime;
|
|
75
|
+
return a.id.localeCompare(b.id);
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function toReconnectDelayMs(attempt: number, options: ReconnectOptions): number {
|
|
80
|
+
const exponentialDelay = Math.min(options.maxDelayMs, options.baseDelayMs * 2 ** attempt);
|
|
81
|
+
const jitterRange = exponentialDelay * options.jitterRatio;
|
|
82
|
+
const jitter = (options.random() * 2 - 1) * jitterRange;
|
|
83
|
+
return Math.max(0, Math.min(options.maxDelayMs, Math.round(exponentialDelay + jitter)));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function openChatStream(
|
|
87
|
+
onEvent: (event: ChatStreamEvent) => void,
|
|
88
|
+
streamOptions: ChatStreamOptions = {},
|
|
89
|
+
): () => void {
|
|
90
|
+
const options: ReconnectOptions = {
|
|
91
|
+
maxRetries: streamOptions.maxRetries ?? DEFAULT_STREAM_OPTIONS.maxRetries,
|
|
92
|
+
baseDelayMs: streamOptions.baseDelayMs ?? DEFAULT_STREAM_OPTIONS.baseDelayMs,
|
|
93
|
+
maxDelayMs: streamOptions.maxDelayMs ?? DEFAULT_STREAM_OPTIONS.maxDelayMs,
|
|
94
|
+
jitterRatio: streamOptions.jitterRatio ?? DEFAULT_STREAM_OPTIONS.jitterRatio,
|
|
95
|
+
random: streamOptions.random ?? DEFAULT_STREAM_OPTIONS.random,
|
|
96
|
+
};
|
|
97
|
+
const createSource = streamOptions.eventSourceFactory ?? ((url: string) => new EventSource(url));
|
|
98
|
+
|
|
99
|
+
let source: EventSource | null = null;
|
|
100
|
+
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
|
101
|
+
let reconnectAttempts = 0;
|
|
102
|
+
let isDisposed = false;
|
|
103
|
+
let lastEventId: string | null = null;
|
|
104
|
+
|
|
105
|
+
const setConnectionState = (state: ChatStreamConnectionState) => {
|
|
106
|
+
streamOptions.onConnectionStateChange?.(state);
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
const clearReconnectTimer = () => {
|
|
110
|
+
if (reconnectTimer) {
|
|
111
|
+
clearTimeout(reconnectTimer);
|
|
112
|
+
reconnectTimer = null;
|
|
113
|
+
}
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
const closeSource = () => {
|
|
117
|
+
if (!source) {
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
source.onopen = null;
|
|
122
|
+
source.onmessage = null;
|
|
123
|
+
source.onerror = null;
|
|
124
|
+
source.close();
|
|
125
|
+
source = null;
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
const scheduleReconnect = () => {
|
|
129
|
+
if (isDisposed || reconnectTimer) {
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (reconnectAttempts >= options.maxRetries) {
|
|
134
|
+
closeSource();
|
|
135
|
+
setConnectionState("failed");
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
setConnectionState("reconnecting");
|
|
140
|
+
const delayMs = toReconnectDelayMs(reconnectAttempts, options);
|
|
141
|
+
reconnectAttempts += 1;
|
|
142
|
+
|
|
143
|
+
reconnectTimer = setTimeout(() => {
|
|
144
|
+
reconnectTimer = null;
|
|
145
|
+
connect();
|
|
146
|
+
}, delayMs);
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
const toStreamUrl = () => {
|
|
150
|
+
if (!lastEventId) return `${API_BASE}/stream`;
|
|
151
|
+
return `${API_BASE}/stream?lastEventId=${encodeURIComponent(lastEventId)}`;
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
const connect = () => {
|
|
155
|
+
if (isDisposed) {
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
setConnectionState("connecting");
|
|
160
|
+
closeSource();
|
|
161
|
+
const currentSource = createSource(toStreamUrl());
|
|
162
|
+
source = currentSource;
|
|
163
|
+
|
|
164
|
+
currentSource.onopen = () => {
|
|
165
|
+
if (source !== currentSource || isDisposed) {
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
reconnectAttempts = 0;
|
|
170
|
+
setConnectionState("connected");
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
currentSource.onmessage = (event) => {
|
|
174
|
+
if (source !== currentSource || isDisposed) {
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const maybeLastEventId = (event as MessageEvent).lastEventId;
|
|
179
|
+
if (typeof maybeLastEventId === "string" && maybeLastEventId.trim().length > 0) {
|
|
180
|
+
lastEventId = maybeLastEventId.trim();
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
try {
|
|
184
|
+
const parsed = JSON.parse(event.data) as ChatStreamEvent;
|
|
185
|
+
onEvent(parsed);
|
|
186
|
+
} catch {
|
|
187
|
+
// Ignore malformed SSE payloads.
|
|
188
|
+
}
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
currentSource.onerror = () => {
|
|
192
|
+
if (source !== currentSource || isDisposed) {
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
closeSource();
|
|
197
|
+
scheduleReconnect();
|
|
198
|
+
};
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
connect();
|
|
202
|
+
|
|
203
|
+
return () => {
|
|
204
|
+
isDisposed = true;
|
|
205
|
+
clearReconnectTimer();
|
|
206
|
+
closeSource();
|
|
207
|
+
setConnectionState("closed");
|
|
208
|
+
};
|
|
209
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { FormEvent, useMemo, useState } from "react";
|
|
4
|
+
import { Button, Input } from "@/client/shared/ui";
|
|
5
|
+
import { useRealtimeChat } from "./use-realtime-chat";
|
|
6
|
+
|
|
7
|
+
export function RealtimeChatStarter() {
|
|
8
|
+
const { messages, send, canSend, sending, connectionState } = useRealtimeChat();
|
|
9
|
+
const [text, setText] = useState("");
|
|
10
|
+
const messagesWithTime = useMemo(
|
|
11
|
+
() =>
|
|
12
|
+
messages.map((message) => ({
|
|
13
|
+
...message,
|
|
14
|
+
displayTime: new Date(message.createdAt).toLocaleTimeString(),
|
|
15
|
+
})),
|
|
16
|
+
[messages],
|
|
17
|
+
);
|
|
18
|
+
|
|
19
|
+
const onSubmit = async (event: FormEvent<HTMLFormElement>) => {
|
|
20
|
+
event.preventDefault();
|
|
21
|
+
const current = text;
|
|
22
|
+
setText("");
|
|
23
|
+
await send(current);
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const showConnectionWarning = connectionState === "reconnecting" || connectionState === "failed";
|
|
27
|
+
|
|
28
|
+
return (
|
|
29
|
+
<section className="flex h-[70vh] flex-col rounded-xl border bg-card" aria-label="Realtime chat">
|
|
30
|
+
{showConnectionWarning ? (
|
|
31
|
+
<div className="border-b border-amber-300 bg-amber-50 px-4 py-2 text-xs text-amber-900">
|
|
32
|
+
{connectionState === "failed"
|
|
33
|
+
? "Live updates disconnected. Please refresh to reconnect."
|
|
34
|
+
: "Live updates are reconnecting..."}
|
|
35
|
+
</div>
|
|
36
|
+
) : null}
|
|
37
|
+
|
|
38
|
+
<div
|
|
39
|
+
className="flex-1 space-y-3 overflow-y-auto p-4"
|
|
40
|
+
role="log"
|
|
41
|
+
aria-live="polite"
|
|
42
|
+
aria-label="Chat messages"
|
|
43
|
+
aria-relevant="additions text"
|
|
44
|
+
>
|
|
45
|
+
{messages.length === 0 ? (
|
|
46
|
+
<p className="text-sm text-muted-foreground" role="status">
|
|
47
|
+
No messages yet. Start chatting.
|
|
48
|
+
</p>
|
|
49
|
+
) : (
|
|
50
|
+
messagesWithTime.map((message) => (
|
|
51
|
+
<div
|
|
52
|
+
key={message.id}
|
|
53
|
+
className={`max-w-[80%] rounded-lg px-3 py-2 text-sm ${
|
|
54
|
+
message.role === "user"
|
|
55
|
+
? "ml-auto bg-primary text-primary-foreground"
|
|
56
|
+
: "bg-muted"
|
|
57
|
+
}`}
|
|
58
|
+
>
|
|
59
|
+
<div>{message.text}</div>
|
|
60
|
+
<div className="mt-1 text-[10px] opacity-70">{message.displayTime}</div>
|
|
61
|
+
</div>
|
|
62
|
+
))
|
|
63
|
+
)}
|
|
64
|
+
</div>
|
|
65
|
+
|
|
66
|
+
<form onSubmit={onSubmit} className="flex gap-2 border-t p-3" aria-label="Send chat message">
|
|
67
|
+
<Input
|
|
68
|
+
value={text}
|
|
69
|
+
onChange={(e) => setText(e.target.value)}
|
|
70
|
+
placeholder="Type your message..."
|
|
71
|
+
className="flex-1"
|
|
72
|
+
maxLength={500}
|
|
73
|
+
aria-label="Chat message input"
|
|
74
|
+
aria-describedby="chat-input-description"
|
|
75
|
+
/>
|
|
76
|
+
<span id="chat-input-description" className="sr-only">
|
|
77
|
+
Press Enter to send your message.
|
|
78
|
+
</span>
|
|
79
|
+
<Button
|
|
80
|
+
type="submit"
|
|
81
|
+
aria-label="Send message"
|
|
82
|
+
disabled={!canSend || text.trim().length === 0}
|
|
83
|
+
>
|
|
84
|
+
{sending ? "Sending..." : "Send"}
|
|
85
|
+
</Button>
|
|
86
|
+
</form>
|
|
87
|
+
</section>
|
|
88
|
+
);
|
|
89
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
4
|
+
import type { ChatMessage } from "@/shared/contracts/chat";
|
|
5
|
+
import {
|
|
6
|
+
mergeChatMessages,
|
|
7
|
+
openChatStream,
|
|
8
|
+
sendChatMessage,
|
|
9
|
+
type ChatStreamConnectionState,
|
|
10
|
+
} from "./chat-api";
|
|
11
|
+
|
|
12
|
+
export function useRealtimeChat() {
|
|
13
|
+
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
|
14
|
+
const [sending, setSending] = useState(false);
|
|
15
|
+
const [connectionState, setConnectionState] = useState<ChatStreamConnectionState>("connecting");
|
|
16
|
+
|
|
17
|
+
useEffect(() => {
|
|
18
|
+
let mounted = true;
|
|
19
|
+
|
|
20
|
+
const close = openChatStream((event) => {
|
|
21
|
+
if (!mounted) return;
|
|
22
|
+
|
|
23
|
+
if (event.type === "snapshot" && Array.isArray(event.data)) {
|
|
24
|
+
setMessages(event.data);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
if (event.type === "message" && !Array.isArray(event.data)) {
|
|
28
|
+
setMessages((prev) => mergeChatMessages(prev, [event.data]));
|
|
29
|
+
}
|
|
30
|
+
}, {
|
|
31
|
+
onConnectionStateChange: (state) => {
|
|
32
|
+
if (mounted) {
|
|
33
|
+
setConnectionState(state);
|
|
34
|
+
}
|
|
35
|
+
},
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
return () => {
|
|
39
|
+
mounted = false;
|
|
40
|
+
close();
|
|
41
|
+
};
|
|
42
|
+
}, []);
|
|
43
|
+
|
|
44
|
+
const send = useCallback(async (text: string) => {
|
|
45
|
+
const trimmed = text.trim();
|
|
46
|
+
if (!trimmed) return;
|
|
47
|
+
|
|
48
|
+
setSending(true);
|
|
49
|
+
try {
|
|
50
|
+
await sendChatMessage({ text: trimmed });
|
|
51
|
+
} finally {
|
|
52
|
+
setSending(false);
|
|
53
|
+
}
|
|
54
|
+
}, []);
|
|
55
|
+
|
|
56
|
+
const canSend = useMemo(() => !sending, [sending]);
|
|
57
|
+
|
|
58
|
+
return {
|
|
59
|
+
messages,
|
|
60
|
+
send,
|
|
61
|
+
sending,
|
|
62
|
+
canSend,
|
|
63
|
+
connectionState,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "./chat/use-realtime-chat";
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { type ClassValue, clsx } from "clsx";
|
|
2
|
+
import { twMerge } from "tailwind-merge";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* cn - Tailwind CSS 클래스 병합 유틸리티
|
|
6
|
+
*
|
|
7
|
+
* clsx로 조건부 클래스를 결합하고
|
|
8
|
+
* tailwind-merge로 충돌하는 클래스를 스마트하게 병합
|
|
9
|
+
*
|
|
10
|
+
* @example
|
|
11
|
+
* cn("px-4 py-2", isActive && "bg-primary", className)
|
|
12
|
+
* cn("text-sm", "text-lg") // → "text-lg" (충돌 해결)
|
|
13
|
+
*/
|
|
14
|
+
export function cn(...inputs: ClassValue[]) {
|
|
15
|
+
return twMerge(clsx(inputs));
|
|
16
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import * as React from "react";
|
|
4
|
+
import { Slot } from "@radix-ui/react-slot";
|
|
5
|
+
import { cva, type VariantProps } from "class-variance-authority";
|
|
6
|
+
import { cn } from "@/client/shared/lib/utils";
|
|
7
|
+
|
|
8
|
+
const buttonVariants = cva(
|
|
9
|
+
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
|
|
10
|
+
{
|
|
11
|
+
variants: {
|
|
12
|
+
variant: {
|
|
13
|
+
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
|
14
|
+
destructive:
|
|
15
|
+
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
|
|
16
|
+
outline:
|
|
17
|
+
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
|
|
18
|
+
secondary:
|
|
19
|
+
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
|
20
|
+
ghost: "hover:bg-accent hover:text-accent-foreground",
|
|
21
|
+
link: "text-primary underline-offset-4 hover:underline",
|
|
22
|
+
},
|
|
23
|
+
size: {
|
|
24
|
+
default: "h-10 px-4 py-2",
|
|
25
|
+
sm: "h-9 rounded-md px-3",
|
|
26
|
+
lg: "h-11 rounded-md px-8",
|
|
27
|
+
icon: "h-10 w-10",
|
|
28
|
+
},
|
|
29
|
+
},
|
|
30
|
+
defaultVariants: {
|
|
31
|
+
variant: "default",
|
|
32
|
+
size: "default",
|
|
33
|
+
},
|
|
34
|
+
}
|
|
35
|
+
);
|
|
36
|
+
|
|
37
|
+
export interface ButtonProps
|
|
38
|
+
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
|
39
|
+
VariantProps<typeof buttonVariants> {
|
|
40
|
+
asChild?: boolean;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
|
44
|
+
({ className, variant, size, asChild = false, ...props }, ref) => {
|
|
45
|
+
const Comp = asChild ? Slot : "button";
|
|
46
|
+
return (
|
|
47
|
+
<Comp
|
|
48
|
+
className={cn(buttonVariants({ variant, size, className }))}
|
|
49
|
+
ref={ref}
|
|
50
|
+
{...props}
|
|
51
|
+
/>
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
);
|
|
55
|
+
Button.displayName = "Button";
|
|
56
|
+
|
|
57
|
+
export { Button, buttonVariants };
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import { cn } from "@/client/shared/lib/utils";
|
|
3
|
+
|
|
4
|
+
const Card = React.forwardRef<
|
|
5
|
+
HTMLDivElement,
|
|
6
|
+
React.HTMLAttributes<HTMLDivElement>
|
|
7
|
+
>(({ className, ...props }, ref) => (
|
|
8
|
+
<div
|
|
9
|
+
ref={ref}
|
|
10
|
+
className={cn(
|
|
11
|
+
"rounded-lg border bg-card text-card-foreground shadow-sm",
|
|
12
|
+
className
|
|
13
|
+
)}
|
|
14
|
+
{...props}
|
|
15
|
+
/>
|
|
16
|
+
));
|
|
17
|
+
Card.displayName = "Card";
|
|
18
|
+
|
|
19
|
+
const CardHeader = React.forwardRef<
|
|
20
|
+
HTMLDivElement,
|
|
21
|
+
React.HTMLAttributes<HTMLDivElement>
|
|
22
|
+
>(({ className, ...props }, ref) => (
|
|
23
|
+
<div
|
|
24
|
+
ref={ref}
|
|
25
|
+
className={cn("flex flex-col space-y-1.5 p-6", className)}
|
|
26
|
+
{...props}
|
|
27
|
+
/>
|
|
28
|
+
));
|
|
29
|
+
CardHeader.displayName = "CardHeader";
|
|
30
|
+
|
|
31
|
+
const CardTitle = React.forwardRef<
|
|
32
|
+
HTMLHeadingElement,
|
|
33
|
+
React.HTMLAttributes<HTMLHeadingElement>
|
|
34
|
+
>(({ className, ...props }, ref) => (
|
|
35
|
+
<h3
|
|
36
|
+
ref={ref}
|
|
37
|
+
className={cn(
|
|
38
|
+
"text-2xl font-semibold leading-none tracking-tight",
|
|
39
|
+
className
|
|
40
|
+
)}
|
|
41
|
+
{...props}
|
|
42
|
+
/>
|
|
43
|
+
));
|
|
44
|
+
CardTitle.displayName = "CardTitle";
|
|
45
|
+
|
|
46
|
+
const CardDescription = React.forwardRef<
|
|
47
|
+
HTMLParagraphElement,
|
|
48
|
+
React.HTMLAttributes<HTMLParagraphElement>
|
|
49
|
+
>(({ className, ...props }, ref) => (
|
|
50
|
+
<p
|
|
51
|
+
ref={ref}
|
|
52
|
+
className={cn("text-sm text-muted-foreground", className)}
|
|
53
|
+
{...props}
|
|
54
|
+
/>
|
|
55
|
+
));
|
|
56
|
+
CardDescription.displayName = "CardDescription";
|
|
57
|
+
|
|
58
|
+
const CardContent = React.forwardRef<
|
|
59
|
+
HTMLDivElement,
|
|
60
|
+
React.HTMLAttributes<HTMLDivElement>
|
|
61
|
+
>(({ className, ...props }, ref) => (
|
|
62
|
+
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
|
|
63
|
+
));
|
|
64
|
+
CardContent.displayName = "CardContent";
|
|
65
|
+
|
|
66
|
+
const CardFooter = React.forwardRef<
|
|
67
|
+
HTMLDivElement,
|
|
68
|
+
React.HTMLAttributes<HTMLDivElement>
|
|
69
|
+
>(({ className, ...props }, ref) => (
|
|
70
|
+
<div
|
|
71
|
+
ref={ref}
|
|
72
|
+
className={cn("flex items-center p-6 pt-0", className)}
|
|
73
|
+
{...props}
|
|
74
|
+
/>
|
|
75
|
+
));
|
|
76
|
+
CardFooter.displayName = "CardFooter";
|
|
77
|
+
|
|
78
|
+
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent };
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* UI Components
|
|
3
|
+
*
|
|
4
|
+
* shadcn/ui 스타일의 컴포넌트 라이브러리
|
|
5
|
+
* Radix UI primitives + Tailwind CSS + cva 기반
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export { Button, buttonVariants } from "./button";
|
|
9
|
+
export type { ButtonProps } from "./button";
|
|
10
|
+
|
|
11
|
+
export {
|
|
12
|
+
Card,
|
|
13
|
+
CardHeader,
|
|
14
|
+
CardFooter,
|
|
15
|
+
CardTitle,
|
|
16
|
+
CardDescription,
|
|
17
|
+
CardContent,
|
|
18
|
+
} from "./card";
|
|
19
|
+
|
|
20
|
+
export { Input } from "./input";
|
|
21
|
+
export type { InputProps } from "./input";
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import { cn } from "@/client/shared/lib/utils";
|
|
3
|
+
|
|
4
|
+
export interface InputProps
|
|
5
|
+
extends React.InputHTMLAttributes<HTMLInputElement> {}
|
|
6
|
+
|
|
7
|
+
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
|
8
|
+
({ className, type, placeholder, "aria-label": ariaLabel, ...props }, ref) => {
|
|
9
|
+
const accessibleLabel = ariaLabel ?? (typeof placeholder === "string" ? placeholder : undefined);
|
|
10
|
+
|
|
11
|
+
return (
|
|
12
|
+
<input
|
|
13
|
+
type={type}
|
|
14
|
+
placeholder={placeholder}
|
|
15
|
+
aria-label={accessibleLabel}
|
|
16
|
+
className={cn(
|
|
17
|
+
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
|
18
|
+
className
|
|
19
|
+
)}
|
|
20
|
+
ref={ref}
|
|
21
|
+
{...props}
|
|
22
|
+
/>
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
);
|
|
26
|
+
Input.displayName = "Input";
|
|
27
|
+
|
|
28
|
+
export { Input };
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { ChatMessage } from "@/shared/contracts/chat";
|
|
2
|
+
|
|
3
|
+
export interface AIChatAdapter {
|
|
4
|
+
complete(input: {
|
|
5
|
+
userText: string;
|
|
6
|
+
history: ChatMessage[];
|
|
7
|
+
}): Promise<string | null>;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
class EchoAdapter implements AIChatAdapter {
|
|
11
|
+
async complete(input: { userText: string }): Promise<string> {
|
|
12
|
+
return `Echo: ${input.userText}`;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
let adapter: AIChatAdapter = new EchoAdapter();
|
|
17
|
+
|
|
18
|
+
export function getAIAdapter(): AIChatAdapter {
|
|
19
|
+
return adapter;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function setAIAdapter(next: AIChatAdapter): void {
|
|
23
|
+
adapter = next;
|
|
24
|
+
}
|