claudeship 0.2.19 → 0.2.21
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/apps/server/dist/chat/chat.controller.d.ts +1 -1
- package/apps/server/dist/chat/chat.service.d.ts +1 -1
- package/apps/server/dist/database/database.module.js +15 -4
- package/apps/server/dist/database/database.module.js.map +1 -1
- package/apps/server/dist/database/infra/database-infra.controller.d.ts +14 -0
- package/apps/server/dist/database/infra/database-infra.controller.js +121 -0
- package/apps/server/dist/database/infra/database-infra.controller.js.map +1 -0
- package/apps/server/dist/database/infra/database-infra.service.d.ts +34 -0
- package/apps/server/dist/database/infra/database-infra.service.js +130 -0
- package/apps/server/dist/database/infra/database-infra.service.js.map +1 -0
- package/apps/server/dist/database/infra/docker.service.d.ts +34 -0
- package/apps/server/dist/database/infra/docker.service.js +186 -0
- package/apps/server/dist/database/infra/docker.service.js.map +1 -0
- package/apps/server/dist/database/infra/index.d.ts +4 -0
- package/apps/server/dist/database/infra/index.js +21 -0
- package/apps/server/dist/database/infra/index.js.map +1 -0
- package/apps/server/dist/database/infra/postgres-container.service.d.ts +31 -0
- package/apps/server/dist/database/infra/postgres-container.service.js +163 -0
- package/apps/server/dist/database/infra/postgres-container.service.js.map +1 -0
- package/apps/server/dist/database/infra/sqlite-infra.service.d.ts +14 -0
- package/apps/server/dist/database/infra/sqlite-infra.service.js +121 -0
- package/apps/server/dist/database/infra/sqlite-infra.service.js.map +1 -0
- package/apps/server/dist/project/project.controller.d.ts +7 -0
- package/apps/server/dist/project/project.module.js +2 -1
- package/apps/server/dist/project/project.module.js.map +1 -1
- package/apps/server/dist/project/project.service.d.ts +10 -1
- package/apps/server/dist/project/project.service.js +35 -3
- package/apps/server/dist/project/project.service.js.map +1 -1
- package/apps/server/dist/tsconfig.tsbuildinfo +1 -1
- package/apps/server/package.json +1 -1
- package/apps/server/prisma/dev.db +0 -0
- package/apps/server/prisma/migrations/20260109053359_add_database_provider/migration.sql +11 -0
- package/apps/server/prisma/schema.prisma +16 -5
- package/apps/web/.next/BUILD_ID +1 -1
- package/apps/web/.next/app-build-manifest.json +8 -8
- package/apps/web/.next/build-manifest.json +2 -2
- package/apps/web/.next/cache/.previewinfo +1 -1
- package/apps/web/.next/cache/.rscinfo +1 -1
- package/apps/web/.next/cache/.tsbuildinfo +1 -1
- package/apps/web/.next/cache/config.json +3 -3
- package/apps/web/.next/cache/eslint/.cache_j3uhuz +1 -1
- package/apps/web/.next/cache/webpack/client-production/0.pack +0 -0
- package/apps/web/.next/cache/webpack/client-production/index.pack +0 -0
- package/apps/web/.next/cache/webpack/edge-server-production/index.pack +0 -0
- package/apps/web/.next/cache/webpack/server-production/0.pack +0 -0
- package/apps/web/.next/cache/webpack/server-production/index.pack +0 -0
- package/apps/web/.next/prerender-manifest.json +13 -13
- package/apps/web/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
- package/apps/web/.next/server/app/_not-found.html +1 -1
- package/apps/web/.next/server/app/_not-found.rsc +2 -2
- package/apps/web/.next/server/app/index.html +1 -1
- package/apps/web/.next/server/app/index.rsc +3 -3
- package/apps/web/.next/server/app/page.js +1 -1
- package/apps/web/.next/server/app/page.js.nft.json +1 -1
- package/apps/web/.next/server/app/page_client-reference-manifest.js +1 -1
- package/apps/web/.next/server/app/project/[id]/page.js +2 -2
- package/apps/web/.next/server/app/project/[id]/page.js.nft.json +1 -1
- package/apps/web/.next/server/app/project/[id]/page_client-reference-manifest.js +1 -1
- package/apps/web/.next/server/app/settings/page.js +2 -2
- package/apps/web/.next/server/app/settings/page.js.nft.json +1 -1
- package/apps/web/.next/server/app/settings/page_client-reference-manifest.js +1 -1
- package/apps/web/.next/server/app/settings.html +1 -1
- package/apps/web/.next/server/app/settings.rsc +3 -3
- package/apps/web/.next/server/chunks/{811.js → 526.js} +1 -1
- package/apps/web/.next/server/pages/404.html +1 -1
- package/apps/web/.next/server/pages/500.html +1 -1
- package/apps/web/.next/server/server-reference-manifest.json +1 -1
- package/apps/web/.next/static/chunks/193-9e44b5a1ad3e0586.js +1 -0
- package/apps/web/.next/static/chunks/700-75e1212e819e279c.js +1 -0
- package/apps/web/.next/static/chunks/app/page-6f2bfb6c9202164b.js +1 -0
- package/apps/web/.next/static/chunks/app/project/[id]/page-388d14835cae411b.js +1 -0
- package/apps/web/.next/static/chunks/app/settings/page-34c4ce9b8e645903.js +1 -0
- package/apps/web/.next/static/css/70f2a13cf3d254d8.css +3 -0
- package/apps/web/.next/trace +18 -18
- package/apps/web/package.json +1 -1
- package/apps/web/src/app/settings/page.tsx +138 -2
- package/apps/web/src/components/chat/MessageInput.tsx +5 -8
- package/apps/web/src/components/chat/QueuePreview.tsx +98 -0
- package/apps/web/src/components/chat/StreamingMessage.tsx +126 -20
- package/apps/web/src/components/project/ProjectCard.tsx +12 -2
- package/apps/web/src/stores/useChatStore.ts +26 -6
- package/package.json +1 -1
- package/packages/shared/src/types/project.ts +6 -0
- package/apps/web/.next/static/chunks/574-1fe2bcd6cfb41646.js +0 -1
- package/apps/web/.next/static/chunks/992-806bad722ba16222.js +0 -1
- package/apps/web/.next/static/chunks/app/page-8310956d8eae9762.js +0 -1
- package/apps/web/.next/static/chunks/app/project/[id]/page-c28098a9b8a94336.js +0 -1
- package/apps/web/.next/static/chunks/app/settings/page-3532fad509d55b77.js +0 -1
- package/apps/web/.next/static/css/0a24552d9794f8c8.css +0 -3
- /package/apps/web/.next/static/{oNlRdQOvyo3lMU4vZQSEf → UHB0ELmeUrSRXrnycF8qv}/_buildManifest.js +0 -0
- /package/apps/web/.next/static/{oNlRdQOvyo3lMU4vZQSEf → UHB0ELmeUrSRXrnycF8qv}/_ssgManifest.js +0 -0
package/apps/web/package.json
CHANGED
|
@@ -4,7 +4,7 @@ import { useState, useEffect } from "react";
|
|
|
4
4
|
import { Header } from "@/components/layout/Header";
|
|
5
5
|
import { Button } from "@/components/ui/button";
|
|
6
6
|
import { Input } from "@/components/ui/input";
|
|
7
|
-
import { FolderOpen, Check, AlertCircle, Loader2 } from "lucide-react";
|
|
7
|
+
import { FolderOpen, Check, AlertCircle, Loader2, Database, RefreshCw } from "lucide-react";
|
|
8
8
|
import { api } from "@/lib/api";
|
|
9
9
|
import { useTranslation } from "@/lib/i18n";
|
|
10
10
|
|
|
@@ -12,6 +12,15 @@ interface Settings {
|
|
|
12
12
|
projectsBasePath: string;
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
+
interface InfraStatus {
|
|
16
|
+
docker: {
|
|
17
|
+
available: boolean;
|
|
18
|
+
version?: string;
|
|
19
|
+
error?: string;
|
|
20
|
+
};
|
|
21
|
+
defaultProvider: "postgres_docker" | "sqlite";
|
|
22
|
+
}
|
|
23
|
+
|
|
15
24
|
export default function SettingsPage() {
|
|
16
25
|
const { t } = useTranslation();
|
|
17
26
|
const [settings, setSettings] = useState<Settings | null>(null);
|
|
@@ -21,8 +30,14 @@ export default function SettingsPage() {
|
|
|
21
30
|
const [error, setError] = useState<string | null>(null);
|
|
22
31
|
const [success, setSuccess] = useState(false);
|
|
23
32
|
|
|
33
|
+
// Database infrastructure state
|
|
34
|
+
const [infraStatus, setInfraStatus] = useState<InfraStatus | null>(null);
|
|
35
|
+
const [isLoadingInfra, setIsLoadingInfra] = useState(true);
|
|
36
|
+
const [isRefreshingInfra, setIsRefreshingInfra] = useState(false);
|
|
37
|
+
|
|
24
38
|
useEffect(() => {
|
|
25
39
|
loadSettings();
|
|
40
|
+
loadInfraStatus();
|
|
26
41
|
}, []);
|
|
27
42
|
|
|
28
43
|
const loadSettings = async () => {
|
|
@@ -30,13 +45,36 @@ export default function SettingsPage() {
|
|
|
30
45
|
const data = await api.get<Settings>("/settings");
|
|
31
46
|
setSettings(data);
|
|
32
47
|
setProjectsPath(data.projectsBasePath);
|
|
33
|
-
} catch
|
|
48
|
+
} catch {
|
|
34
49
|
setError("Failed to load settings");
|
|
35
50
|
} finally {
|
|
36
51
|
setIsLoading(false);
|
|
37
52
|
}
|
|
38
53
|
};
|
|
39
54
|
|
|
55
|
+
const loadInfraStatus = async () => {
|
|
56
|
+
try {
|
|
57
|
+
const data = await api.get<InfraStatus>("/database/status");
|
|
58
|
+
setInfraStatus(data);
|
|
59
|
+
} catch {
|
|
60
|
+
// Silently fail - infrastructure status is optional
|
|
61
|
+
} finally {
|
|
62
|
+
setIsLoadingInfra(false);
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const refreshInfraStatus = async () => {
|
|
67
|
+
setIsRefreshingInfra(true);
|
|
68
|
+
try {
|
|
69
|
+
const data = await api.post<InfraStatus>("/database/refresh");
|
|
70
|
+
setInfraStatus(data);
|
|
71
|
+
} catch {
|
|
72
|
+
// Silently fail
|
|
73
|
+
} finally {
|
|
74
|
+
setIsRefreshingInfra(false);
|
|
75
|
+
}
|
|
76
|
+
};
|
|
77
|
+
|
|
40
78
|
const handleSave = async () => {
|
|
41
79
|
setIsSaving(true);
|
|
42
80
|
setError(null);
|
|
@@ -129,6 +167,104 @@ export default function SettingsPage() {
|
|
|
129
167
|
</div>
|
|
130
168
|
)}
|
|
131
169
|
</section>
|
|
170
|
+
|
|
171
|
+
{/* Database Infrastructure */}
|
|
172
|
+
<section className="rounded-lg border bg-card p-6">
|
|
173
|
+
<div className="flex items-center justify-between mb-4">
|
|
174
|
+
<div className="flex items-center gap-3">
|
|
175
|
+
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10">
|
|
176
|
+
<Database className="h-5 w-5 text-primary" />
|
|
177
|
+
</div>
|
|
178
|
+
<div>
|
|
179
|
+
<h2 className="text-lg font-semibold">데이터베이스 인프라</h2>
|
|
180
|
+
<p className="text-sm text-muted-foreground">
|
|
181
|
+
프로젝트별 데이터베이스 자동 관리
|
|
182
|
+
</p>
|
|
183
|
+
</div>
|
|
184
|
+
</div>
|
|
185
|
+
<Button
|
|
186
|
+
variant="ghost"
|
|
187
|
+
size="icon"
|
|
188
|
+
onClick={refreshInfraStatus}
|
|
189
|
+
disabled={isRefreshingInfra}
|
|
190
|
+
>
|
|
191
|
+
<RefreshCw className={`h-4 w-4 ${isRefreshingInfra ? "animate-spin" : ""}`} />
|
|
192
|
+
</Button>
|
|
193
|
+
</div>
|
|
194
|
+
|
|
195
|
+
{isLoadingInfra ? (
|
|
196
|
+
<div className="flex items-center justify-center py-8">
|
|
197
|
+
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
|
198
|
+
</div>
|
|
199
|
+
) : infraStatus ? (
|
|
200
|
+
<div className="space-y-4">
|
|
201
|
+
{/* Docker Status */}
|
|
202
|
+
<div className="flex items-center justify-between rounded-lg bg-muted/50 p-4">
|
|
203
|
+
<div className="flex items-center gap-3">
|
|
204
|
+
<div className={`h-3 w-3 rounded-full ${
|
|
205
|
+
infraStatus.docker.available ? "bg-green-500" : "bg-yellow-500"
|
|
206
|
+
}`} />
|
|
207
|
+
<div>
|
|
208
|
+
<p className="font-medium">Docker</p>
|
|
209
|
+
<p className="text-sm text-muted-foreground">
|
|
210
|
+
{infraStatus.docker.available
|
|
211
|
+
? `v${infraStatus.docker.version}`
|
|
212
|
+
: "설치되지 않음 또는 실행 중이 아님"}
|
|
213
|
+
</p>
|
|
214
|
+
</div>
|
|
215
|
+
</div>
|
|
216
|
+
<span className={`text-sm font-medium ${
|
|
217
|
+
infraStatus.docker.available ? "text-green-600" : "text-yellow-600"
|
|
218
|
+
}`}>
|
|
219
|
+
{infraStatus.docker.available ? "실행 중" : "비활성"}
|
|
220
|
+
</span>
|
|
221
|
+
</div>
|
|
222
|
+
|
|
223
|
+
{/* Default Provider */}
|
|
224
|
+
<div className="rounded-lg bg-muted/50 p-4">
|
|
225
|
+
<p className="text-sm text-muted-foreground mb-2">기본 데이터베이스</p>
|
|
226
|
+
<div className="flex items-center gap-2">
|
|
227
|
+
{infraStatus.defaultProvider === "postgres_docker" ? (
|
|
228
|
+
<>
|
|
229
|
+
<div className="flex h-8 w-8 items-center justify-center rounded bg-blue-500/10">
|
|
230
|
+
<span className="text-xs font-bold text-blue-600">PG</span>
|
|
231
|
+
</div>
|
|
232
|
+
<div>
|
|
233
|
+
<p className="font-medium">PostgreSQL (Docker)</p>
|
|
234
|
+
<p className="text-xs text-muted-foreground">
|
|
235
|
+
프로젝트별 독립 컨테이너
|
|
236
|
+
</p>
|
|
237
|
+
</div>
|
|
238
|
+
</>
|
|
239
|
+
) : (
|
|
240
|
+
<>
|
|
241
|
+
<div className="flex h-8 w-8 items-center justify-center rounded bg-amber-500/10">
|
|
242
|
+
<span className="text-xs font-bold text-amber-600">SL</span>
|
|
243
|
+
</div>
|
|
244
|
+
<div>
|
|
245
|
+
<p className="font-medium">SQLite</p>
|
|
246
|
+
<p className="text-xs text-muted-foreground">
|
|
247
|
+
경량 파일 기반 DB (폴백)
|
|
248
|
+
</p>
|
|
249
|
+
</div>
|
|
250
|
+
</>
|
|
251
|
+
)}
|
|
252
|
+
</div>
|
|
253
|
+
</div>
|
|
254
|
+
|
|
255
|
+
{/* Info */}
|
|
256
|
+
<p className="text-xs text-muted-foreground">
|
|
257
|
+
{infraStatus.docker.available
|
|
258
|
+
? "Docker가 감지되어 새 프로젝트에 PostgreSQL이 자동으로 생성됩니다."
|
|
259
|
+
: "Docker가 없어 SQLite로 폴백됩니다. Docker를 설치하면 PostgreSQL을 사용할 수 있습니다."}
|
|
260
|
+
</p>
|
|
261
|
+
</div>
|
|
262
|
+
) : (
|
|
263
|
+
<p className="text-sm text-muted-foreground py-4">
|
|
264
|
+
인프라 상태를 불러올 수 없습니다.
|
|
265
|
+
</p>
|
|
266
|
+
)}
|
|
267
|
+
</section>
|
|
132
268
|
</div>
|
|
133
269
|
</main>
|
|
134
270
|
</div>
|
|
@@ -6,6 +6,7 @@ import { Button } from "@/components/ui/button";
|
|
|
6
6
|
import { useTranslation } from "@/lib/i18n";
|
|
7
7
|
import { ModeToggle } from "./ModeToggle";
|
|
8
8
|
import { FilePreview } from "./FilePreview";
|
|
9
|
+
import { QueuePreview } from "./QueuePreview";
|
|
9
10
|
import { useChatStore } from "@/stores/useChatStore";
|
|
10
11
|
|
|
11
12
|
interface MessageInputProps {
|
|
@@ -34,7 +35,7 @@ const MAX_FILES = 5;
|
|
|
34
35
|
|
|
35
36
|
export function MessageInput({ onSend, projectId, disabled, isStreaming, queueCount = 0 }: MessageInputProps) {
|
|
36
37
|
const { t } = useTranslation();
|
|
37
|
-
const { mode, attachedFiles, addFiles, removeFile, uploadFiles, isUploading } = useChatStore();
|
|
38
|
+
const { mode, attachedFiles, addFiles, removeFile, uploadFiles, isUploading, messageQueue, deleteFromQueue } = useChatStore();
|
|
38
39
|
const [content, setContent] = useState("");
|
|
39
40
|
const [isDragging, setIsDragging] = useState(false);
|
|
40
41
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
|
@@ -173,7 +174,7 @@ export function MessageInput({ onSend, projectId, disabled, isStreaming, queueCo
|
|
|
173
174
|
</Button>
|
|
174
175
|
</div>
|
|
175
176
|
{(isStreaming || isUploading) && (
|
|
176
|
-
<div className="px-4 pb-
|
|
177
|
+
<div className="px-4 pb-2 flex items-center gap-2 text-xs text-muted-foreground">
|
|
177
178
|
{isUploading ? (
|
|
178
179
|
<span className="flex items-center gap-1">
|
|
179
180
|
<span className="inline-block h-1.5 w-1.5 animate-pulse rounded-full bg-blue-500" />
|
|
@@ -185,14 +186,10 @@ export function MessageInput({ onSend, projectId, disabled, isStreaming, queueCo
|
|
|
185
186
|
{t("chat.thinking")}
|
|
186
187
|
</span>
|
|
187
188
|
)}
|
|
188
|
-
{queueCount > 0 && (
|
|
189
|
-
<span className="flex items-center gap-1 text-blue-500">
|
|
190
|
-
<Clock className="h-3 w-3" />
|
|
191
|
-
{t("chat.queueCount", { count: queueCount })}
|
|
192
|
-
</span>
|
|
193
|
-
)}
|
|
194
189
|
</div>
|
|
195
190
|
)}
|
|
191
|
+
{/* Queue Preview */}
|
|
192
|
+
<QueuePreview items={messageQueue} onDelete={deleteFromQueue} />
|
|
196
193
|
</div>
|
|
197
194
|
);
|
|
198
195
|
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { ChevronDown, ChevronUp, Trash2, Play } from "lucide-react";
|
|
4
|
+
import { useState } from "react";
|
|
5
|
+
import { Button } from "@/components/ui/button";
|
|
6
|
+
import { useChatStore, type QueuedMessage } from "@/stores/useChatStore";
|
|
7
|
+
|
|
8
|
+
interface QueuePreviewProps {
|
|
9
|
+
items: QueuedMessage[];
|
|
10
|
+
onDelete?: (id: string) => void;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function QueuePreview({ items, onDelete }: QueuePreviewProps) {
|
|
14
|
+
const [isExpanded, setIsExpanded] = useState(true);
|
|
15
|
+
|
|
16
|
+
if (items.length === 0) return null;
|
|
17
|
+
|
|
18
|
+
return (
|
|
19
|
+
<div className="border-t bg-muted/30">
|
|
20
|
+
{/* Header */}
|
|
21
|
+
<button
|
|
22
|
+
onClick={() => setIsExpanded(!isExpanded)}
|
|
23
|
+
className="flex items-center justify-between w-full px-4 py-2 text-sm font-medium text-muted-foreground hover:text-foreground transition-colors"
|
|
24
|
+
>
|
|
25
|
+
<div className="flex items-center gap-2">
|
|
26
|
+
{isExpanded ? (
|
|
27
|
+
<ChevronUp className="h-4 w-4" />
|
|
28
|
+
) : (
|
|
29
|
+
<ChevronDown className="h-4 w-4" />
|
|
30
|
+
)}
|
|
31
|
+
<span>Queue ({items.length})</span>
|
|
32
|
+
</div>
|
|
33
|
+
</button>
|
|
34
|
+
|
|
35
|
+
{/* Queue Items */}
|
|
36
|
+
{isExpanded && (
|
|
37
|
+
<div className="px-4 pb-3 space-y-2">
|
|
38
|
+
{items.map((item, index) => (
|
|
39
|
+
<QueueItem
|
|
40
|
+
key={item.id}
|
|
41
|
+
item={item}
|
|
42
|
+
isNext={index === 0}
|
|
43
|
+
onDelete={onDelete}
|
|
44
|
+
/>
|
|
45
|
+
))}
|
|
46
|
+
</div>
|
|
47
|
+
)}
|
|
48
|
+
</div>
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
interface QueueItemProps {
|
|
53
|
+
item: QueuedMessage;
|
|
54
|
+
isNext: boolean;
|
|
55
|
+
onDelete?: (id: string) => void;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function QueueItem({ item, isNext, onDelete }: QueueItemProps) {
|
|
59
|
+
const isProcessing = item.status === "processing";
|
|
60
|
+
|
|
61
|
+
return (
|
|
62
|
+
<div
|
|
63
|
+
className={`relative flex items-start gap-2 p-3 rounded-md border ${
|
|
64
|
+
isProcessing
|
|
65
|
+
? "bg-blue-50 dark:bg-blue-950 border-blue-200 dark:border-blue-800"
|
|
66
|
+
: "bg-background border-border"
|
|
67
|
+
}`}
|
|
68
|
+
>
|
|
69
|
+
<div className="flex-1 min-w-0">
|
|
70
|
+
<p className="text-sm line-clamp-2 break-words">
|
|
71
|
+
{item.content}
|
|
72
|
+
</p>
|
|
73
|
+
</div>
|
|
74
|
+
<div className="flex items-center gap-1 shrink-0">
|
|
75
|
+
{isNext && !isProcessing && (
|
|
76
|
+
<span className="text-xs font-medium text-blue-600 dark:text-blue-400 px-1.5 py-0.5 bg-blue-100 dark:bg-blue-900 rounded">
|
|
77
|
+
Next
|
|
78
|
+
</span>
|
|
79
|
+
)}
|
|
80
|
+
{isProcessing && (
|
|
81
|
+
<span className="text-xs font-medium text-blue-600 dark:text-blue-400 px-1.5 py-0.5 bg-blue-100 dark:bg-blue-900 rounded animate-pulse">
|
|
82
|
+
Processing
|
|
83
|
+
</span>
|
|
84
|
+
)}
|
|
85
|
+
{onDelete && !isProcessing && (
|
|
86
|
+
<Button
|
|
87
|
+
variant="ghost"
|
|
88
|
+
size="icon"
|
|
89
|
+
className="h-6 w-6 text-muted-foreground hover:text-destructive"
|
|
90
|
+
onClick={() => onDelete(item.id)}
|
|
91
|
+
>
|
|
92
|
+
<Trash2 className="h-3 w-3" />
|
|
93
|
+
</Button>
|
|
94
|
+
)}
|
|
95
|
+
</div>
|
|
96
|
+
</div>
|
|
97
|
+
);
|
|
98
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
+
import { useState } from "react";
|
|
3
4
|
import { useChatStore, type StreamingBlock } from "@/stores/useChatStore";
|
|
4
5
|
import {
|
|
5
6
|
FileText,
|
|
@@ -12,6 +13,8 @@ import {
|
|
|
12
13
|
Loader2,
|
|
13
14
|
ListTodo,
|
|
14
15
|
Bot,
|
|
16
|
+
ChevronDown,
|
|
17
|
+
ChevronUp,
|
|
15
18
|
} from "lucide-react";
|
|
16
19
|
import { MarkdownRenderer } from "./MarkdownRenderer";
|
|
17
20
|
import { AskUserQuestionBlock } from "./AskUserQuestionBlock";
|
|
@@ -22,6 +25,9 @@ interface StreamingMessageProps {
|
|
|
22
25
|
projectId: string;
|
|
23
26
|
}
|
|
24
27
|
|
|
28
|
+
const COLLAPSE_THRESHOLD = 5; // Number of tool blocks before collapsing
|
|
29
|
+
const VISIBLE_WHEN_COLLAPSED = 2; // Number of items to show at start and end when collapsed
|
|
30
|
+
|
|
25
31
|
const toolIcons: Record<string, React.ReactNode> = {
|
|
26
32
|
Read: <FileText className="h-4 w-4" />,
|
|
27
33
|
Glob: <FolderSearch className="h-4 w-4" />,
|
|
@@ -89,6 +95,13 @@ function TextBlock({ content }: { content: string }) {
|
|
|
89
95
|
return <MarkdownRenderer content={content} />;
|
|
90
96
|
}
|
|
91
97
|
|
|
98
|
+
function formatDuration(ms: number): string {
|
|
99
|
+
if (ms < 1000) {
|
|
100
|
+
return `${ms}ms`;
|
|
101
|
+
}
|
|
102
|
+
return `${(ms / 1000).toFixed(1)}s`;
|
|
103
|
+
}
|
|
104
|
+
|
|
92
105
|
function ToolUseBlock({ block }: { block: StreamingBlock }) {
|
|
93
106
|
const isRunning = block.status === "running";
|
|
94
107
|
const toolName = block.tool?.name || "Unknown";
|
|
@@ -113,17 +126,106 @@ function ToolUseBlock({ block }: { block: StreamingBlock }) {
|
|
|
113
126
|
{getToolDisplayName(toolName)}
|
|
114
127
|
</span>
|
|
115
128
|
{getToolDescription(block) && (
|
|
116
|
-
<span className={`truncate ${isRunning ? "text-blue-600 dark:text-blue-400" : "text-muted-foreground"}`}>
|
|
129
|
+
<span className={`truncate flex-1 ${isRunning ? "text-blue-600 dark:text-blue-400" : "text-muted-foreground"}`}>
|
|
117
130
|
{getToolDescription(block)}
|
|
118
131
|
</span>
|
|
119
132
|
)}
|
|
133
|
+
{block.duration !== undefined && (
|
|
134
|
+
<span className="text-xs text-muted-foreground ml-auto tabular-nums">
|
|
135
|
+
({formatDuration(block.duration)})
|
|
136
|
+
</span>
|
|
137
|
+
)}
|
|
120
138
|
</div>
|
|
121
139
|
);
|
|
122
140
|
}
|
|
123
141
|
|
|
142
|
+
interface ToolBlockGroupProps {
|
|
143
|
+
blocks: StreamingBlock[];
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function ToolBlockGroup({ blocks }: ToolBlockGroupProps) {
|
|
147
|
+
const [isExpanded, setIsExpanded] = useState(false);
|
|
148
|
+
const shouldCollapse = blocks.length > COLLAPSE_THRESHOLD;
|
|
149
|
+
const hiddenCount = blocks.length - (VISIBLE_WHEN_COLLAPSED * 2);
|
|
150
|
+
|
|
151
|
+
if (!shouldCollapse || isExpanded) {
|
|
152
|
+
return (
|
|
153
|
+
<div className="space-y-1">
|
|
154
|
+
{blocks.map((block) => (
|
|
155
|
+
<ToolUseBlock key={block.id} block={block} />
|
|
156
|
+
))}
|
|
157
|
+
{shouldCollapse && isExpanded && (
|
|
158
|
+
<button
|
|
159
|
+
onClick={() => setIsExpanded(false)}
|
|
160
|
+
className="flex items-center gap-1 px-3 py-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors"
|
|
161
|
+
>
|
|
162
|
+
<ChevronUp className="h-4 w-4" />
|
|
163
|
+
<span>접기</span>
|
|
164
|
+
</button>
|
|
165
|
+
)}
|
|
166
|
+
</div>
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Show first few, collapse button, then last few
|
|
171
|
+
const firstBlocks = blocks.slice(0, VISIBLE_WHEN_COLLAPSED);
|
|
172
|
+
const lastBlocks = blocks.slice(-VISIBLE_WHEN_COLLAPSED);
|
|
173
|
+
|
|
174
|
+
return (
|
|
175
|
+
<div className="space-y-1">
|
|
176
|
+
{firstBlocks.map((block) => (
|
|
177
|
+
<ToolUseBlock key={block.id} block={block} />
|
|
178
|
+
))}
|
|
179
|
+
<button
|
|
180
|
+
onClick={() => setIsExpanded(true)}
|
|
181
|
+
className="flex items-center gap-1 px-3 py-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors w-full justify-center border border-dashed border-border rounded-md hover:bg-muted/50"
|
|
182
|
+
>
|
|
183
|
+
<ChevronDown className="h-4 w-4" />
|
|
184
|
+
<span>{hiddenCount}개 더 보기</span>
|
|
185
|
+
</button>
|
|
186
|
+
{lastBlocks.map((block) => (
|
|
187
|
+
<ToolUseBlock key={block.id} block={block} />
|
|
188
|
+
))}
|
|
189
|
+
</div>
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Group consecutive blocks by type
|
|
194
|
+
interface BlockGroup {
|
|
195
|
+
type: "tool_group" | "other";
|
|
196
|
+
blocks: StreamingBlock[];
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function groupBlocks(blocks: StreamingBlock[]): BlockGroup[] {
|
|
200
|
+
const groups: BlockGroup[] = [];
|
|
201
|
+
let currentToolGroup: StreamingBlock[] = [];
|
|
202
|
+
|
|
203
|
+
for (const block of blocks) {
|
|
204
|
+
if (block.type === "tool_use") {
|
|
205
|
+
currentToolGroup.push(block);
|
|
206
|
+
} else {
|
|
207
|
+
// Flush current tool group if exists
|
|
208
|
+
if (currentToolGroup.length > 0) {
|
|
209
|
+
groups.push({ type: "tool_group", blocks: currentToolGroup });
|
|
210
|
+
currentToolGroup = [];
|
|
211
|
+
}
|
|
212
|
+
// Add non-tool block as single item
|
|
213
|
+
groups.push({ type: "other", blocks: [block] });
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// Flush remaining tool group
|
|
218
|
+
if (currentToolGroup.length > 0) {
|
|
219
|
+
groups.push({ type: "tool_group", blocks: currentToolGroup });
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
return groups;
|
|
223
|
+
}
|
|
224
|
+
|
|
124
225
|
export function StreamingMessage({ blocks, isStreaming = true, projectId }: StreamingMessageProps) {
|
|
125
226
|
const hasBlocks = blocks.length > 0;
|
|
126
|
-
const { respondToQuestion
|
|
227
|
+
const { respondToQuestion } = useChatStore();
|
|
228
|
+
const blockGroups = groupBlocks(blocks);
|
|
127
229
|
|
|
128
230
|
const handleQuestionSubmit = (answers: Record<string, string>) => {
|
|
129
231
|
respondToQuestion(projectId, answers);
|
|
@@ -135,25 +237,29 @@ export function StreamingMessage({ blocks, isStreaming = true, projectId }: Stre
|
|
|
135
237
|
AI
|
|
136
238
|
</div>
|
|
137
239
|
<div className="flex-1 space-y-2 overflow-hidden">
|
|
138
|
-
{/* Render
|
|
139
|
-
{
|
|
140
|
-
if (
|
|
141
|
-
return <
|
|
142
|
-
}
|
|
143
|
-
if (block.type === "tool_use") {
|
|
144
|
-
return <ToolUseBlock key={block.id} block={block} />;
|
|
240
|
+
{/* Render block groups */}
|
|
241
|
+
{blockGroups.map((group, groupIndex) => {
|
|
242
|
+
if (group.type === "tool_group") {
|
|
243
|
+
return <ToolBlockGroup key={`group-${groupIndex}`} blocks={group.blocks} />;
|
|
145
244
|
}
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
245
|
+
|
|
246
|
+
// Render other blocks individually
|
|
247
|
+
return group.blocks.map((block) => {
|
|
248
|
+
if (block.type === "text") {
|
|
249
|
+
return <TextBlock key={block.id} content={block.content || ""} />;
|
|
250
|
+
}
|
|
251
|
+
if (block.type === "ask_user_question" && block.askUserQuestion) {
|
|
252
|
+
return (
|
|
253
|
+
<AskUserQuestionBlock
|
|
254
|
+
key={block.id}
|
|
255
|
+
data={block.askUserQuestion}
|
|
256
|
+
isWaiting={block.status === "waiting"}
|
|
257
|
+
onSubmit={handleQuestionSubmit}
|
|
258
|
+
/>
|
|
259
|
+
);
|
|
260
|
+
}
|
|
261
|
+
return null;
|
|
262
|
+
});
|
|
157
263
|
})}
|
|
158
264
|
|
|
159
265
|
{/* Show thinking state when streaming but no blocks yet */}
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
3
|
import Link from "next/link";
|
|
4
|
-
import { Trash2, Globe, Smartphone, Server } from "lucide-react";
|
|
4
|
+
import { Trash2, Globe, Smartphone, Server, Database } from "lucide-react";
|
|
5
5
|
import { Card } from "@/components/ui/card";
|
|
6
6
|
import { Button } from "@/components/ui/button";
|
|
7
7
|
import type { ProjectListItem } from "@claudeship/shared";
|
|
8
|
-
import { ProjectType, BackendFramework,
|
|
8
|
+
import { ProjectType, BackendFramework, DatabaseProvider } from "@claudeship/shared";
|
|
9
9
|
import { useTranslation } from "@/lib/i18n";
|
|
10
10
|
|
|
11
11
|
interface ProjectCardProps {
|
|
@@ -104,6 +104,16 @@ export function ProjectCard({ project, onDelete }: ProjectCardProps) {
|
|
|
104
104
|
{project.backendFramework === BackendFramework.EXPRESS ? "Express" : "FastAPI"}
|
|
105
105
|
</span>
|
|
106
106
|
)}
|
|
107
|
+
{project.databaseProvider && (
|
|
108
|
+
<span className={`inline-flex items-center gap-1 rounded-full px-2.5 py-0.5 text-xs font-medium ${
|
|
109
|
+
project.databaseProvider === DatabaseProvider.POSTGRES_DOCKER
|
|
110
|
+
? "bg-blue-500/10 text-blue-600"
|
|
111
|
+
: "bg-amber-500/10 text-amber-600"
|
|
112
|
+
}`}>
|
|
113
|
+
<Database className="h-3 w-3" />
|
|
114
|
+
{project.databaseProvider === DatabaseProvider.POSTGRES_DOCKER ? "PostgreSQL" : "SQLite"}
|
|
115
|
+
</span>
|
|
116
|
+
)}
|
|
107
117
|
<span className="rounded-full bg-secondary px-2.5 py-0.5 text-xs text-muted-foreground">
|
|
108
118
|
{project.projectType === ProjectType.WEB ? "Web" : "Native"}
|
|
109
119
|
</span>
|
|
@@ -28,6 +28,8 @@ export interface StreamingBlock {
|
|
|
28
28
|
askUserQuestion?: AskUserQuestionData;
|
|
29
29
|
status?: "running" | "completed" | "error" | "waiting";
|
|
30
30
|
result?: string;
|
|
31
|
+
timestamp?: number; // Unix timestamp in milliseconds
|
|
32
|
+
duration?: number; // Duration in milliseconds
|
|
31
33
|
}
|
|
32
34
|
|
|
33
35
|
export interface QueuedMessage {
|
|
@@ -60,6 +62,7 @@ interface ChatState {
|
|
|
60
62
|
fetchActiveSession: (projectId: string) => Promise<void>;
|
|
61
63
|
sendMessage: (projectId: string, content: string, fromQueue?: boolean) => Promise<void>;
|
|
62
64
|
queueMessage: (projectId: string, content: string) => void;
|
|
65
|
+
deleteFromQueue: (id: string) => void;
|
|
63
66
|
processQueue: (projectId: string) => Promise<void>;
|
|
64
67
|
respondToQuestion: (projectId: string, answers: Record<string, string>) => Promise<void>;
|
|
65
68
|
addMessage: (message: ChatMessage) => void;
|
|
@@ -263,19 +266,24 @@ export const useChatStore = create<ChatState>((set, get) => ({
|
|
|
263
266
|
input: data.tool.input,
|
|
264
267
|
},
|
|
265
268
|
status: "running",
|
|
269
|
+
timestamp: Date.now(),
|
|
266
270
|
};
|
|
267
271
|
set((state) => ({
|
|
268
272
|
streamingBlocks: [...state.streamingBlocks, toolBlock],
|
|
269
273
|
}));
|
|
270
274
|
} else if (data.type === "tool_result") {
|
|
275
|
+
const now = Date.now();
|
|
271
276
|
set((state) => {
|
|
272
277
|
const blocks = [...state.streamingBlocks];
|
|
273
278
|
for (let i = blocks.length - 1; i >= 0; i--) {
|
|
274
|
-
|
|
279
|
+
const block = blocks[i];
|
|
280
|
+
if (block.type === "tool_use" && block.status === "running") {
|
|
281
|
+
const duration = block.timestamp ? now - block.timestamp : undefined;
|
|
275
282
|
blocks[i] = {
|
|
276
|
-
...
|
|
283
|
+
...block,
|
|
277
284
|
status: "completed",
|
|
278
285
|
result: data.content,
|
|
286
|
+
duration,
|
|
279
287
|
};
|
|
280
288
|
break;
|
|
281
289
|
}
|
|
@@ -341,6 +349,13 @@ export const useChatStore = create<ChatState>((set, get) => ({
|
|
|
341
349
|
}));
|
|
342
350
|
},
|
|
343
351
|
|
|
352
|
+
// Delete a message from the queue
|
|
353
|
+
deleteFromQueue: (id: string) => {
|
|
354
|
+
set((state) => ({
|
|
355
|
+
messageQueue: state.messageQueue.filter((m) => m.id !== id),
|
|
356
|
+
}));
|
|
357
|
+
},
|
|
358
|
+
|
|
344
359
|
// Process the message queue sequentially
|
|
345
360
|
processQueue: async (projectId: string) => {
|
|
346
361
|
const state = get();
|
|
@@ -487,7 +502,7 @@ export const useChatStore = create<ChatState>((set, get) => ({
|
|
|
487
502
|
}));
|
|
488
503
|
}
|
|
489
504
|
} else if (data.type === "tool_use" && data.tool) {
|
|
490
|
-
// Add tool_use block
|
|
505
|
+
// Add tool_use block with timestamp
|
|
491
506
|
const toolBlock: StreamingBlock = {
|
|
492
507
|
id: `tool-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
|
493
508
|
type: "tool_use",
|
|
@@ -496,21 +511,26 @@ export const useChatStore = create<ChatState>((set, get) => ({
|
|
|
496
511
|
input: data.tool.input,
|
|
497
512
|
},
|
|
498
513
|
status: "running",
|
|
514
|
+
timestamp: Date.now(),
|
|
499
515
|
};
|
|
500
516
|
set((state) => ({
|
|
501
517
|
streamingBlocks: [...state.streamingBlocks, toolBlock],
|
|
502
518
|
}));
|
|
503
519
|
} else if (data.type === "tool_result") {
|
|
504
|
-
// Update the last running tool_use block with result
|
|
520
|
+
// Update the last running tool_use block with result and duration
|
|
521
|
+
const now = Date.now();
|
|
505
522
|
set((state) => {
|
|
506
523
|
const blocks = [...state.streamingBlocks];
|
|
507
524
|
// Find the last running tool_use block
|
|
508
525
|
for (let i = blocks.length - 1; i >= 0; i--) {
|
|
509
|
-
|
|
526
|
+
const block = blocks[i];
|
|
527
|
+
if (block.type === "tool_use" && block.status === "running") {
|
|
528
|
+
const duration = block.timestamp ? now - block.timestamp : undefined;
|
|
510
529
|
blocks[i] = {
|
|
511
|
-
...
|
|
530
|
+
...block,
|
|
512
531
|
status: "completed",
|
|
513
532
|
result: data.content,
|
|
533
|
+
duration,
|
|
514
534
|
};
|
|
515
535
|
break;
|
|
516
536
|
}
|