@mandujs/cli 0.15.1 → 0.15.2
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 +48 -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 +177 -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 +73 -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 +88 -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 +151 -0
- package/templates/realtime-chat/tests/chat-starter.test.ts +149 -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,177 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ChatHistoryResponse,
|
|
3
|
+
ChatMessagePayload,
|
|
4
|
+
ChatMessageResponse,
|
|
5
|
+
ChatStreamEvent,
|
|
6
|
+
} from "@/shared/contracts/chat";
|
|
7
|
+
|
|
8
|
+
const API_BASE = "/api/chat";
|
|
9
|
+
|
|
10
|
+
export type ChatStreamConnectionState =
|
|
11
|
+
| "connecting"
|
|
12
|
+
| "connected"
|
|
13
|
+
| "reconnecting"
|
|
14
|
+
| "failed"
|
|
15
|
+
| "closed";
|
|
16
|
+
|
|
17
|
+
interface ChatStreamOptions {
|
|
18
|
+
maxRetries?: number;
|
|
19
|
+
baseDelayMs?: number;
|
|
20
|
+
maxDelayMs?: number;
|
|
21
|
+
jitterRatio?: number;
|
|
22
|
+
random?: () => number;
|
|
23
|
+
eventSourceFactory?: (url: string) => EventSource;
|
|
24
|
+
onConnectionStateChange?: (state: ChatStreamConnectionState) => void;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const DEFAULT_STREAM_OPTIONS: Required<Omit<ChatStreamOptions, "eventSourceFactory" | "onConnectionStateChange">> = {
|
|
28
|
+
maxRetries: 8,
|
|
29
|
+
baseDelayMs: 500,
|
|
30
|
+
maxDelayMs: 10_000,
|
|
31
|
+
jitterRatio: 0.25,
|
|
32
|
+
random: Math.random,
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export async function sendChatMessage(payload: ChatMessagePayload): Promise<ChatMessageResponse> {
|
|
36
|
+
const response = await fetch(`${API_BASE}/messages`, {
|
|
37
|
+
method: "POST",
|
|
38
|
+
headers: { "Content-Type": "application/json" },
|
|
39
|
+
body: JSON.stringify(payload),
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
if (!response.ok) {
|
|
43
|
+
throw new Error(`Failed to send message: ${response.status}`);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return response.json() as Promise<ChatMessageResponse>;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export async function fetchChatHistory(): Promise<ChatHistoryResponse> {
|
|
50
|
+
const response = await fetch(`${API_BASE}/messages`);
|
|
51
|
+
if (!response.ok) {
|
|
52
|
+
throw new Error(`Failed to load history: ${response.status}`);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return response.json() as Promise<ChatHistoryResponse>;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function toReconnectDelayMs(attempt: number, options: Required<Omit<ChatStreamOptions, "eventSourceFactory" | "onConnectionStateChange">>): number {
|
|
59
|
+
const exponentialDelay = Math.min(options.maxDelayMs, options.baseDelayMs * 2 ** attempt);
|
|
60
|
+
const jitterRange = exponentialDelay * options.jitterRatio;
|
|
61
|
+
const jitter = (options.random() * 2 - 1) * jitterRange;
|
|
62
|
+
return Math.max(0, Math.round(exponentialDelay + jitter));
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function openChatStream(
|
|
66
|
+
onEvent: (event: ChatStreamEvent) => void,
|
|
67
|
+
streamOptions: ChatStreamOptions = {},
|
|
68
|
+
): () => void {
|
|
69
|
+
const options: Required<Omit<ChatStreamOptions, "eventSourceFactory" | "onConnectionStateChange">> = {
|
|
70
|
+
maxRetries: streamOptions.maxRetries ?? DEFAULT_STREAM_OPTIONS.maxRetries,
|
|
71
|
+
baseDelayMs: streamOptions.baseDelayMs ?? DEFAULT_STREAM_OPTIONS.baseDelayMs,
|
|
72
|
+
maxDelayMs: streamOptions.maxDelayMs ?? DEFAULT_STREAM_OPTIONS.maxDelayMs,
|
|
73
|
+
jitterRatio: streamOptions.jitterRatio ?? DEFAULT_STREAM_OPTIONS.jitterRatio,
|
|
74
|
+
random: streamOptions.random ?? DEFAULT_STREAM_OPTIONS.random,
|
|
75
|
+
};
|
|
76
|
+
const createSource = streamOptions.eventSourceFactory ?? ((url: string) => new EventSource(url));
|
|
77
|
+
|
|
78
|
+
let source: EventSource | null = null;
|
|
79
|
+
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
|
80
|
+
let reconnectAttempts = 0;
|
|
81
|
+
let isDisposed = false;
|
|
82
|
+
|
|
83
|
+
const setConnectionState = (state: ChatStreamConnectionState) => {
|
|
84
|
+
streamOptions.onConnectionStateChange?.(state);
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
const clearReconnectTimer = () => {
|
|
88
|
+
if (reconnectTimer) {
|
|
89
|
+
clearTimeout(reconnectTimer);
|
|
90
|
+
reconnectTimer = null;
|
|
91
|
+
}
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
const closeSource = () => {
|
|
95
|
+
if (!source) {
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
source.onopen = null;
|
|
100
|
+
source.onmessage = null;
|
|
101
|
+
source.onerror = null;
|
|
102
|
+
source.close();
|
|
103
|
+
source = null;
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
const scheduleReconnect = () => {
|
|
107
|
+
if (isDisposed || reconnectTimer) {
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (reconnectAttempts >= options.maxRetries) {
|
|
112
|
+
closeSource();
|
|
113
|
+
setConnectionState("failed");
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
setConnectionState("reconnecting");
|
|
118
|
+
const delayMs = toReconnectDelayMs(reconnectAttempts, options);
|
|
119
|
+
reconnectAttempts += 1;
|
|
120
|
+
|
|
121
|
+
reconnectTimer = setTimeout(() => {
|
|
122
|
+
reconnectTimer = null;
|
|
123
|
+
connect();
|
|
124
|
+
}, delayMs);
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
const connect = () => {
|
|
128
|
+
if (isDisposed) {
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
setConnectionState("connecting");
|
|
133
|
+
closeSource();
|
|
134
|
+
const currentSource = createSource(`${API_BASE}/stream`);
|
|
135
|
+
source = currentSource;
|
|
136
|
+
|
|
137
|
+
currentSource.onopen = () => {
|
|
138
|
+
if (source !== currentSource || isDisposed) {
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
reconnectAttempts = 0;
|
|
143
|
+
setConnectionState("connected");
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
currentSource.onmessage = (event) => {
|
|
147
|
+
if (source !== currentSource || isDisposed) {
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
try {
|
|
152
|
+
const parsed = JSON.parse(event.data) as ChatStreamEvent;
|
|
153
|
+
onEvent(parsed);
|
|
154
|
+
} catch {
|
|
155
|
+
// Ignore malformed SSE payloads.
|
|
156
|
+
}
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
currentSource.onerror = () => {
|
|
160
|
+
if (source !== currentSource || isDisposed) {
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
closeSource();
|
|
165
|
+
scheduleReconnect();
|
|
166
|
+
};
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
connect();
|
|
170
|
+
|
|
171
|
+
return () => {
|
|
172
|
+
isDisposed = true;
|
|
173
|
+
clearReconnectTimer();
|
|
174
|
+
closeSource();
|
|
175
|
+
setConnectionState("closed");
|
|
176
|
+
};
|
|
177
|
+
}
|
|
@@ -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,73 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
4
|
+
import type { ChatMessage } from "@/shared/contracts/chat";
|
|
5
|
+
import {
|
|
6
|
+
fetchChatHistory,
|
|
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
|
+
fetchChatHistory()
|
|
21
|
+
.then((res) => {
|
|
22
|
+
if (mounted) setMessages(res.messages);
|
|
23
|
+
})
|
|
24
|
+
.catch(() => {
|
|
25
|
+
// starter template keeps errors simple
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
const close = openChatStream((event) => {
|
|
29
|
+
if (!mounted) return;
|
|
30
|
+
|
|
31
|
+
if (event.type === "snapshot" && Array.isArray(event.data)) {
|
|
32
|
+
setMessages(event.data);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (event.type === "message" && !Array.isArray(event.data)) {
|
|
36
|
+
setMessages((prev) => [...prev, event.data]);
|
|
37
|
+
}
|
|
38
|
+
}, {
|
|
39
|
+
onConnectionStateChange: (state) => {
|
|
40
|
+
if (mounted) {
|
|
41
|
+
setConnectionState(state);
|
|
42
|
+
}
|
|
43
|
+
},
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
return () => {
|
|
47
|
+
mounted = false;
|
|
48
|
+
close();
|
|
49
|
+
};
|
|
50
|
+
}, []);
|
|
51
|
+
|
|
52
|
+
const send = useCallback(async (text: string) => {
|
|
53
|
+
const trimmed = text.trim();
|
|
54
|
+
if (!trimmed) return;
|
|
55
|
+
|
|
56
|
+
setSending(true);
|
|
57
|
+
try {
|
|
58
|
+
await sendChatMessage({ text: trimmed });
|
|
59
|
+
} finally {
|
|
60
|
+
setSending(false);
|
|
61
|
+
}
|
|
62
|
+
}, []);
|
|
63
|
+
|
|
64
|
+
const canSend = useMemo(() => !sending, [sending]);
|
|
65
|
+
|
|
66
|
+
return {
|
|
67
|
+
messages,
|
|
68
|
+
send,
|
|
69
|
+
sending,
|
|
70
|
+
canSend,
|
|
71
|
+
connectionState,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import type { ChatMessage } from "@/shared/contracts/chat";
|
|
2
|
+
|
|
3
|
+
type ChatListener = (message: ChatMessage) => void;
|
|
4
|
+
|
|
5
|
+
type SubscriptionSnapshot = {
|
|
6
|
+
snapshot: ChatMessage[];
|
|
7
|
+
commit: () => () => void;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
const listeners = new Set<ChatListener>();
|
|
11
|
+
const messages: ChatMessage[] = [];
|
|
12
|
+
const MAX_HISTORY_MESSAGES = 200;
|
|
13
|
+
let storeVersion = 0;
|
|
14
|
+
let testHookBeforeSubscribeCommit: (() => void) | undefined;
|
|
15
|
+
|
|
16
|
+
function createMessage(role: ChatMessage["role"], text: string): ChatMessage {
|
|
17
|
+
return {
|
|
18
|
+
id: crypto.randomUUID(),
|
|
19
|
+
role,
|
|
20
|
+
text,
|
|
21
|
+
createdAt: new Date().toISOString(),
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function getMessages(): ChatMessage[] {
|
|
26
|
+
return [...messages];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function appendMessage(role: ChatMessage["role"], text: string): ChatMessage {
|
|
30
|
+
const message = createMessage(role, text);
|
|
31
|
+
messages.push(message);
|
|
32
|
+
|
|
33
|
+
if (messages.length > MAX_HISTORY_MESSAGES) {
|
|
34
|
+
messages.splice(0, messages.length - MAX_HISTORY_MESSAGES);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
storeVersion += 1;
|
|
38
|
+
|
|
39
|
+
for (const listener of listeners) {
|
|
40
|
+
try {
|
|
41
|
+
listener(message);
|
|
42
|
+
} catch {
|
|
43
|
+
// Ignore listener errors so one broken subscriber does not stop fan-out.
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return message;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function subscribe(listener: ChatListener): () => void {
|
|
51
|
+
listeners.add(listener);
|
|
52
|
+
return () => listeners.delete(listener);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function subscribeWithSnapshot(listener: ChatListener): SubscriptionSnapshot {
|
|
56
|
+
// Optimistic lock-free retry: snapshot과 subscribe 사이에 write가 끼면 재시도
|
|
57
|
+
// => snapshot-subscription 경계에서 메시지 유실 방지
|
|
58
|
+
// commit()은 snapshot 전송 후 호출하여 listener 활성화 (이벤트 순서 보장)
|
|
59
|
+
for (;;) {
|
|
60
|
+
const beforeVersion = storeVersion;
|
|
61
|
+
const snapshot = [...messages];
|
|
62
|
+
|
|
63
|
+
testHookBeforeSubscribeCommit?.();
|
|
64
|
+
|
|
65
|
+
if (beforeVersion === storeVersion) {
|
|
66
|
+
return {
|
|
67
|
+
snapshot,
|
|
68
|
+
commit: () => {
|
|
69
|
+
listeners.add(listener);
|
|
70
|
+
return () => listeners.delete(listener);
|
|
71
|
+
},
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function __resetChatStoreForTests(): void {
|
|
78
|
+
messages.length = 0;
|
|
79
|
+
listeners.clear();
|
|
80
|
+
storeVersion = 0;
|
|
81
|
+
testHookBeforeSubscribeCommit = undefined;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function __setSubscribeCommitHookForTests(hook?: () => void): void {
|
|
85
|
+
testHookBeforeSubscribeCommit = hook;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export { MAX_HISTORY_MESSAGES };
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|