create-claude-pipeline 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/cli.js +359 -0
- package/package.json +32 -0
- package/template/.claude/agents/be-developer.md +218 -0
- package/template/.claude/agents/designer.md +192 -0
- package/template/.claude/agents/fe-developer.md +175 -0
- package/template/.claude/agents/infra-developer.md +270 -0
- package/template/.claude/agents/planner.md +126 -0
- package/template/.claude/agents/pm.md +130 -0
- package/template/.claude/agents/qa-engineer.md +270 -0
- package/template/.claude/agents/security-reviewer.md +281 -0
- package/template/.claude/settings.json +5 -0
- package/template/.claude/skills/analyze-requirements/SKILL.md +166 -0
- package/template/.claude/skills/api-integration/SKILL.md +354 -0
- package/template/.claude/skills/assemble-context/SKILL.md +192 -0
- package/template/.claude/skills/db-migration/SKILL.md +228 -0
- package/template/.claude/skills/explore-be-codebase/SKILL.md +260 -0
- package/template/.claude/skills/explore-codebase/SKILL.md +190 -0
- package/template/.claude/skills/explore-design-system/SKILL.md +150 -0
- package/template/.claude/skills/explore-fe-codebase/SKILL.md +209 -0
- package/template/.claude/skills/explore-implementation/SKILL.md +147 -0
- package/template/.claude/skills/explore-infra/SKILL.md +242 -0
- package/template/.claude/skills/implement-api/SKILL.md +477 -0
- package/template/.claude/skills/implement-components/SKILL.md +217 -0
- package/template/.claude/skills/review-auth/SKILL.md +175 -0
- package/template/.claude/skills/scan-vulnerabilities/SKILL.md +200 -0
- package/template/.claude/skills/write-cicd/SKILL.md +293 -0
- package/template/.claude/skills/write-design-spec/SKILL.md +363 -0
- package/template/.claude/skills/write-dockerfile/SKILL.md +269 -0
- package/template/.claude/skills/write-plan-doc/SKILL.md +164 -0
- package/template/.claude/skills/write-plan-doc/assets/plan_template.html +251 -0
- package/template/.claude/skills/write-qa-report/SKILL.md +151 -0
- package/template/.claude/skills/write-security-report/SKILL.md +185 -0
- package/template/.claude/skills/write-test-cases/SKILL.md +234 -0
- package/template/.claude-pipeline/dashboard/.env.example +1 -0
- package/template/.claude-pipeline/dashboard/.eslintrc.json +3 -0
- package/template/.claude-pipeline/dashboard/README.md +36 -0
- package/template/.claude-pipeline/dashboard/next.config.mjs +6 -0
- package/template/.claude-pipeline/dashboard/package-lock.json +8148 -0
- package/template/.claude-pipeline/dashboard/package.json +36 -0
- package/template/.claude-pipeline/dashboard/postcss.config.mjs +8 -0
- package/template/.claude-pipeline/dashboard/server.ts +24 -0
- package/template/.claude-pipeline/dashboard/src/app/api/pipelines/[id]/checkpoint/route.ts +23 -0
- package/template/.claude-pipeline/dashboard/src/app/api/pipelines/[id]/outputs/[...filepath]/route.ts +18 -0
- package/template/.claude-pipeline/dashboard/src/app/api/pipelines/[id]/route.ts +10 -0
- package/template/.claude-pipeline/dashboard/src/app/api/pipelines/route.ts +64 -0
- package/template/.claude-pipeline/dashboard/src/app/favicon.ico +0 -0
- package/template/.claude-pipeline/dashboard/src/app/fonts/GeistMonoVF.woff +0 -0
- package/template/.claude-pipeline/dashboard/src/app/fonts/GeistVF.woff +0 -0
- package/template/.claude-pipeline/dashboard/src/app/globals.css +52 -0
- package/template/.claude-pipeline/dashboard/src/app/layout.tsx +33 -0
- package/template/.claude-pipeline/dashboard/src/app/page.tsx +49 -0
- package/template/.claude-pipeline/dashboard/src/app/pipeline/[id]/page.tsx +84 -0
- package/template/.claude-pipeline/dashboard/src/components/agent-card.tsx +40 -0
- package/template/.claude-pipeline/dashboard/src/components/agent-logs.tsx +65 -0
- package/template/.claude-pipeline/dashboard/src/components/artifact-viewer.tsx +130 -0
- package/template/.claude-pipeline/dashboard/src/components/checkpoint-banner.tsx +59 -0
- package/template/.claude-pipeline/dashboard/src/components/new-pipeline-modal.tsx +63 -0
- package/template/.claude-pipeline/dashboard/src/components/output-list.tsx +57 -0
- package/template/.claude-pipeline/dashboard/src/components/phase-dots.tsx +37 -0
- package/template/.claude-pipeline/dashboard/src/components/pipeline-card.tsx +53 -0
- package/template/.claude-pipeline/dashboard/src/components/resizable-panels.tsx +91 -0
- package/template/.claude-pipeline/dashboard/src/hooks/use-pipeline-detail.ts +65 -0
- package/template/.claude-pipeline/dashboard/src/hooks/use-pipelines.ts +60 -0
- package/template/.claude-pipeline/dashboard/src/hooks/use-websocket.ts +58 -0
- package/template/.claude-pipeline/dashboard/src/lib/agents.ts +30 -0
- package/template/.claude-pipeline/dashboard/src/lib/checkpoint.ts +37 -0
- package/template/.claude-pipeline/dashboard/src/lib/pipelines.ts +91 -0
- package/template/.claude-pipeline/dashboard/src/lib/watcher.ts +90 -0
- package/template/.claude-pipeline/dashboard/src/lib/ws-server.ts +123 -0
- package/template/.claude-pipeline/dashboard/src/types/pipeline.ts +61 -0
- package/template/.claude-pipeline/dashboard/tailwind.config.ts +31 -0
- package/template/.claude-pipeline/dashboard/tsconfig.json +26 -0
- package/template/CLAUDE.md +301 -0
- package/template/references/context-structure.md +34 -0
- package/template/references/pm-context-assembly.md +34 -0
- package/template/references/task-context-template.md +65 -0
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "dashboard",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"scripts": {
|
|
6
|
+
"dev": "tsx server.ts",
|
|
7
|
+
"build": "next build",
|
|
8
|
+
"start": "NODE_ENV=production tsx server.ts",
|
|
9
|
+
"lint": "next lint"
|
|
10
|
+
},
|
|
11
|
+
"dependencies": {
|
|
12
|
+
"@tailwindcss/typography": "^0.5.19",
|
|
13
|
+
"next": "14.2.35",
|
|
14
|
+
"react": "^18",
|
|
15
|
+
"react-dom": "^18",
|
|
16
|
+
"react-markdown": "^10.1.0",
|
|
17
|
+
"react-syntax-highlighter": "^16.1.1",
|
|
18
|
+
"remark-gfm": "^4.0.1",
|
|
19
|
+
"uuid": "^13.0.0",
|
|
20
|
+
"ws": "^8.20.0"
|
|
21
|
+
},
|
|
22
|
+
"devDependencies": {
|
|
23
|
+
"@types/node": "^20",
|
|
24
|
+
"@types/react": "^18",
|
|
25
|
+
"@types/react-dom": "^18",
|
|
26
|
+
"@types/react-syntax-highlighter": "^15.5.13",
|
|
27
|
+
"@types/uuid": "^10.0.0",
|
|
28
|
+
"@types/ws": "^8.18.1",
|
|
29
|
+
"eslint": "^8",
|
|
30
|
+
"eslint-config-next": "14.2.35",
|
|
31
|
+
"postcss": "^8",
|
|
32
|
+
"tailwindcss": "^3.4.1",
|
|
33
|
+
"tsx": "^4.21.0",
|
|
34
|
+
"typescript": "^5"
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { createServer } from "http";
|
|
2
|
+
import { parse } from "url";
|
|
3
|
+
import next from "next";
|
|
4
|
+
import { createWSServer } from "./src/lib/ws-server";
|
|
5
|
+
|
|
6
|
+
const dev = process.env.NODE_ENV !== "production";
|
|
7
|
+
const hostname = "localhost";
|
|
8
|
+
const port = parseInt(process.env.PORT || "3000", 10);
|
|
9
|
+
|
|
10
|
+
const app = next({ dev, hostname, port });
|
|
11
|
+
const handle = app.getRequestHandler();
|
|
12
|
+
|
|
13
|
+
app.prepare().then(() => {
|
|
14
|
+
const server = createServer((req, res) => {
|
|
15
|
+
const parsedUrl = parse(req.url!, true);
|
|
16
|
+
handle(req, res, parsedUrl);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
createWSServer(server);
|
|
20
|
+
|
|
21
|
+
server.listen(port, () => {
|
|
22
|
+
console.log(`> Ready on http://${hostname}:${port}`);
|
|
23
|
+
});
|
|
24
|
+
});
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { NextResponse } from "next/server";
|
|
2
|
+
import { readPipelineState, writeCheckpointResponse } from "@/lib/pipelines";
|
|
3
|
+
|
|
4
|
+
export async function POST(request: Request, { params }: { params: { id: string } }) {
|
|
5
|
+
const state = readPipelineState(params.id);
|
|
6
|
+
if (!state) {
|
|
7
|
+
return NextResponse.json({ error: "Pipeline not found" }, { status: 404 });
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const body = await request.json();
|
|
11
|
+
const { action, message } = body;
|
|
12
|
+
|
|
13
|
+
if (action !== "approve" && action !== "reject") {
|
|
14
|
+
return NextResponse.json({ error: "Invalid action" }, { status: 400 });
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const success = writeCheckpointResponse(params.id, action, message);
|
|
18
|
+
if (!success) {
|
|
19
|
+
return NextResponse.json({ error: "Failed to write checkpoint response" }, { status: 500 });
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
return NextResponse.json({ success: true });
|
|
23
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { NextResponse } from "next/server";
|
|
2
|
+
import { readOutputFile } from "@/lib/pipelines";
|
|
3
|
+
|
|
4
|
+
export async function GET(_: Request, { params }: { params: { id: string; filepath: string[] } }) {
|
|
5
|
+
const filepath = params.filepath.join("/");
|
|
6
|
+
const result = readOutputFile(params.id, filepath);
|
|
7
|
+
|
|
8
|
+
if ("error" in result) {
|
|
9
|
+
if (result.error === "forbidden") {
|
|
10
|
+
return NextResponse.json({ error: "File not registered in outputs" }, { status: 403 });
|
|
11
|
+
}
|
|
12
|
+
return NextResponse.json({ error: "File not found" }, { status: 404 });
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
return new NextResponse(result.content, {
|
|
16
|
+
headers: { "Content-Type": result.contentType },
|
|
17
|
+
});
|
|
18
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { NextResponse } from "next/server";
|
|
2
|
+
import { readPipelineState } from "@/lib/pipelines";
|
|
3
|
+
|
|
4
|
+
export async function GET(_: Request, { params }: { params: { id: string } }) {
|
|
5
|
+
const state = readPipelineState(params.id);
|
|
6
|
+
if (!state) {
|
|
7
|
+
return NextResponse.json({ error: "Pipeline not found" }, { status: 404 });
|
|
8
|
+
}
|
|
9
|
+
return NextResponse.json(state);
|
|
10
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { NextResponse } from "next/server";
|
|
2
|
+
import { listPipelines, getPipelinesDir } from "@/lib/pipelines";
|
|
3
|
+
import { v4 as uuidv4 } from "uuid";
|
|
4
|
+
import fs from "fs";
|
|
5
|
+
import path from "path";
|
|
6
|
+
import { spawn } from "child_process";
|
|
7
|
+
|
|
8
|
+
export async function GET() {
|
|
9
|
+
try {
|
|
10
|
+
const pipelines = listPipelines();
|
|
11
|
+
return NextResponse.json({ pipelines });
|
|
12
|
+
} catch {
|
|
13
|
+
return NextResponse.json({ error: "Failed to read pipelines directory" }, { status: 500 });
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export async function POST(request: Request) {
|
|
18
|
+
try {
|
|
19
|
+
const body = await request.json();
|
|
20
|
+
const { requirements } = body;
|
|
21
|
+
|
|
22
|
+
if (!requirements || !requirements.trim()) {
|
|
23
|
+
return NextResponse.json({ error: "Requirements is required" }, { status: 400 });
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const id = uuidv4();
|
|
27
|
+
const pipelineDir = path.join(getPipelinesDir(), id);
|
|
28
|
+
fs.mkdirSync(pipelineDir, { recursive: true });
|
|
29
|
+
|
|
30
|
+
const initialState = {
|
|
31
|
+
id,
|
|
32
|
+
name: `Pipeline ${id.slice(0, 8)}`,
|
|
33
|
+
requirements: requirements.trim(),
|
|
34
|
+
status: "running",
|
|
35
|
+
currentPhase: 0,
|
|
36
|
+
agents: {},
|
|
37
|
+
outputs: [],
|
|
38
|
+
activities: [],
|
|
39
|
+
createdAt: new Date().toISOString(),
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
fs.writeFileSync(
|
|
43
|
+
path.join(pipelineDir, "state.json"),
|
|
44
|
+
JSON.stringify(initialState, null, 2)
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
// Spawn CLI process (fire-and-forget)
|
|
48
|
+
try {
|
|
49
|
+
const child = spawn("claude", [requirements.trim()], {
|
|
50
|
+
cwd: path.resolve(process.cwd(), ".."),
|
|
51
|
+
detached: true,
|
|
52
|
+
stdio: "ignore",
|
|
53
|
+
env: { ...process.env, PIPELINE_ID: id },
|
|
54
|
+
});
|
|
55
|
+
child.unref();
|
|
56
|
+
} catch (e) {
|
|
57
|
+
console.error("Failed to spawn CLI:", e);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return NextResponse.json({ id, status: "running" }, { status: 201 });
|
|
61
|
+
} catch {
|
|
62
|
+
return NextResponse.json({ error: "Failed to start pipeline process" }, { status: 500 });
|
|
63
|
+
}
|
|
64
|
+
}
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
@tailwind base;
|
|
2
|
+
@tailwind components;
|
|
3
|
+
@tailwind utilities;
|
|
4
|
+
|
|
5
|
+
:root {
|
|
6
|
+
--background: #111827;
|
|
7
|
+
--foreground: #f3f4f6;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
body {
|
|
11
|
+
color: var(--foreground);
|
|
12
|
+
background: var(--background);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/* Scrollbar styling */
|
|
16
|
+
::-webkit-scrollbar {
|
|
17
|
+
width: 6px;
|
|
18
|
+
height: 6px;
|
|
19
|
+
}
|
|
20
|
+
::-webkit-scrollbar-track {
|
|
21
|
+
background: #1f2937;
|
|
22
|
+
}
|
|
23
|
+
::-webkit-scrollbar-thumb {
|
|
24
|
+
background: #374151;
|
|
25
|
+
border-radius: 3px;
|
|
26
|
+
}
|
|
27
|
+
::-webkit-scrollbar-thumb:hover {
|
|
28
|
+
background: #4b5563;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
.writing-mode-vertical {
|
|
32
|
+
writing-mode: vertical-lr;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/* Prose overrides for dark theme */
|
|
36
|
+
.prose-invert {
|
|
37
|
+
--tw-prose-headings: #a5b4fc;
|
|
38
|
+
--tw-prose-links: #8b5cf6;
|
|
39
|
+
--tw-prose-bold: #f3f4f6;
|
|
40
|
+
--tw-prose-code: #a5b4fc;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
.prose table {
|
|
44
|
+
border-collapse: collapse;
|
|
45
|
+
}
|
|
46
|
+
.prose th, .prose td {
|
|
47
|
+
border: 1px solid #374151;
|
|
48
|
+
padding: 6px 12px;
|
|
49
|
+
}
|
|
50
|
+
.prose th {
|
|
51
|
+
background: #1f2937;
|
|
52
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { Metadata } from "next";
|
|
2
|
+
import localFont from "next/font/local";
|
|
3
|
+
import "./globals.css";
|
|
4
|
+
|
|
5
|
+
const geistSans = localFont({
|
|
6
|
+
src: "./fonts/GeistVF.woff",
|
|
7
|
+
variable: "--font-geist-sans",
|
|
8
|
+
weight: "100 900",
|
|
9
|
+
});
|
|
10
|
+
const geistMono = localFont({
|
|
11
|
+
src: "./fonts/GeistMonoVF.woff",
|
|
12
|
+
variable: "--font-geist-mono",
|
|
13
|
+
weight: "100 900",
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
export const metadata: Metadata = {
|
|
17
|
+
title: "Pipeline Dashboard",
|
|
18
|
+
description: "Agent Pipeline 진행 상태 모니터링 대시보드",
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export default function RootLayout({
|
|
22
|
+
children,
|
|
23
|
+
}: Readonly<{
|
|
24
|
+
children: React.ReactNode;
|
|
25
|
+
}>) {
|
|
26
|
+
return (
|
|
27
|
+
<html lang="ko">
|
|
28
|
+
<body className={`${geistSans.variable} ${geistMono.variable} antialiased`}>
|
|
29
|
+
{children}
|
|
30
|
+
</body>
|
|
31
|
+
</html>
|
|
32
|
+
);
|
|
33
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState } from "react";
|
|
4
|
+
import { usePipelines } from "@/hooks/use-pipelines";
|
|
5
|
+
import { PipelineCard } from "@/components/pipeline-card";
|
|
6
|
+
import { NewPipelineModal } from "@/components/new-pipeline-modal";
|
|
7
|
+
|
|
8
|
+
export default function Home() {
|
|
9
|
+
const { pipelines, loading } = usePipelines();
|
|
10
|
+
const [modalOpen, setModalOpen] = useState(false);
|
|
11
|
+
|
|
12
|
+
return (
|
|
13
|
+
<div className="min-h-screen font-[family-name:var(--font-geist-sans)]">
|
|
14
|
+
<header className="flex justify-between items-center px-6 py-4 border-b border-border">
|
|
15
|
+
<h1 className="text-lg font-bold bg-gradient-to-r from-accent-purple to-accent-purple-light bg-clip-text text-transparent">
|
|
16
|
+
Pipeline Dashboard
|
|
17
|
+
</h1>
|
|
18
|
+
<button
|
|
19
|
+
onClick={() => setModalOpen(true)}
|
|
20
|
+
className="bg-gradient-to-r from-accent-purple to-accent-purple-light text-white text-sm px-4 py-2 rounded-lg hover:opacity-90"
|
|
21
|
+
>
|
|
22
|
+
+ New Pipeline
|
|
23
|
+
</button>
|
|
24
|
+
</header>
|
|
25
|
+
|
|
26
|
+
<main className="p-6">
|
|
27
|
+
{loading ? (
|
|
28
|
+
<div className="text-text-secondary text-center py-20">Loading...</div>
|
|
29
|
+
) : pipelines.length === 0 ? (
|
|
30
|
+
<div className="text-text-secondary text-center py-20">
|
|
31
|
+
아직 파이프라인이 없습니다.
|
|
32
|
+
<br />
|
|
33
|
+
<button onClick={() => setModalOpen(true)} className="text-accent-purple-light mt-2 hover:underline">
|
|
34
|
+
+ New Pipeline으로 시작하세요.
|
|
35
|
+
</button>
|
|
36
|
+
</div>
|
|
37
|
+
) : (
|
|
38
|
+
<div className="flex flex-col gap-3 max-w-4xl mx-auto">
|
|
39
|
+
{pipelines.map((p) => (
|
|
40
|
+
<PipelineCard key={p.id} pipeline={p} />
|
|
41
|
+
))}
|
|
42
|
+
</div>
|
|
43
|
+
)}
|
|
44
|
+
</main>
|
|
45
|
+
|
|
46
|
+
<NewPipelineModal open={modalOpen} onClose={() => setModalOpen(false)} />
|
|
47
|
+
</div>
|
|
48
|
+
);
|
|
49
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState } from "react";
|
|
4
|
+
import { useParams, useRouter } from "next/navigation";
|
|
5
|
+
import { usePipelineDetail } from "@/hooks/use-pipeline-detail";
|
|
6
|
+
import { PhaseDots } from "@/components/phase-dots";
|
|
7
|
+
import { AgentCardList } from "@/components/agent-card";
|
|
8
|
+
import { AgentLogs } from "@/components/agent-logs";
|
|
9
|
+
import { OutputList } from "@/components/output-list";
|
|
10
|
+
import { CheckpointBanner } from "@/components/checkpoint-banner";
|
|
11
|
+
import { ResizablePanels } from "@/components/resizable-panels";
|
|
12
|
+
import { ArtifactViewer } from "@/components/artifact-viewer";
|
|
13
|
+
|
|
14
|
+
export default function PipelineDetailPage() {
|
|
15
|
+
const params = useParams();
|
|
16
|
+
const router = useRouter();
|
|
17
|
+
const id = params.id as string;
|
|
18
|
+
const { pipeline, checkpoint, loading, notFound, respondToCheckpoint } = usePipelineDetail(id);
|
|
19
|
+
const [selectedOutput, setSelectedOutput] = useState<string | null>(null);
|
|
20
|
+
|
|
21
|
+
if (loading) {
|
|
22
|
+
return <div className="text-text-secondary text-center py-20">Loading...</div>;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
if (notFound || !pipeline) {
|
|
26
|
+
router.push("/");
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const statusColor =
|
|
31
|
+
pipeline.status === "running" ? "text-accent-green" :
|
|
32
|
+
pipeline.status === "completed" ? "text-text-muted" :
|
|
33
|
+
pipeline.status === "failed" ? "text-red-500" :
|
|
34
|
+
"text-yellow-500";
|
|
35
|
+
|
|
36
|
+
return (
|
|
37
|
+
<div className="h-screen flex flex-col font-[family-name:var(--font-geist-sans)]">
|
|
38
|
+
{/* Header */}
|
|
39
|
+
<header className="px-6 py-3 border-b border-border flex-shrink-0">
|
|
40
|
+
<div className="flex justify-between items-start">
|
|
41
|
+
<button onClick={() => router.push("/")} className="text-text-secondary text-[11px] hover:text-text-primary">
|
|
42
|
+
← Back
|
|
43
|
+
</button>
|
|
44
|
+
<span className={`text-[11px] ${statusColor}`}>● {pipeline.status.toUpperCase()}</span>
|
|
45
|
+
</div>
|
|
46
|
+
<div className="flex justify-between items-center mt-1">
|
|
47
|
+
<h1 className="text-base font-semibold text-text-primary">{pipeline.requirements}</h1>
|
|
48
|
+
<PhaseDots currentPhase={pipeline.currentPhase} showLabel />
|
|
49
|
+
</div>
|
|
50
|
+
</header>
|
|
51
|
+
|
|
52
|
+
{/* 3-Panel Layout */}
|
|
53
|
+
<div className="flex-1 overflow-hidden">
|
|
54
|
+
<ResizablePanels
|
|
55
|
+
left={<AgentCardList agents={pipeline.agents} />}
|
|
56
|
+
center={<AgentLogs activities={pipeline.activities} />}
|
|
57
|
+
right={
|
|
58
|
+
<OutputList
|
|
59
|
+
outputs={pipeline.outputs}
|
|
60
|
+
onSelect={setSelectedOutput}
|
|
61
|
+
selected={selectedOutput || undefined}
|
|
62
|
+
/>
|
|
63
|
+
}
|
|
64
|
+
/>
|
|
65
|
+
</div>
|
|
66
|
+
|
|
67
|
+
{/* Checkpoint Banner */}
|
|
68
|
+
{checkpoint && checkpoint.status === "pending" && (
|
|
69
|
+
<CheckpointBanner checkpoint={checkpoint} onRespond={respondToCheckpoint} />
|
|
70
|
+
)}
|
|
71
|
+
|
|
72
|
+
{/* Artifact Viewer */}
|
|
73
|
+
{selectedOutput && (
|
|
74
|
+
<ArtifactViewer
|
|
75
|
+
pipelineId={id}
|
|
76
|
+
outputs={pipeline.outputs}
|
|
77
|
+
selected={selectedOutput}
|
|
78
|
+
onSelect={setSelectedOutput}
|
|
79
|
+
onClose={() => setSelectedOutput(null)}
|
|
80
|
+
/>
|
|
81
|
+
)}
|
|
82
|
+
</div>
|
|
83
|
+
);
|
|
84
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { AGENTS } from "@/lib/agents";
|
|
4
|
+
import type { AgentState } from "@/types/pipeline";
|
|
5
|
+
|
|
6
|
+
interface AgentCardListProps {
|
|
7
|
+
agents: Record<string, AgentState>;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function AgentCardList({ agents }: AgentCardListProps) {
|
|
11
|
+
return (
|
|
12
|
+
<div className="p-3">
|
|
13
|
+
<div className="text-text-secondary text-[11px] font-semibold mb-2">AGENTS</div>
|
|
14
|
+
<div className="flex flex-col gap-[6px]">
|
|
15
|
+
{AGENTS.map((meta) => {
|
|
16
|
+
const state = agents[meta.id];
|
|
17
|
+
const status = state?.status || "idle";
|
|
18
|
+
const borderColor =
|
|
19
|
+
status === "working" ? meta.workingColor : "#6b7280";
|
|
20
|
+
const statusColor =
|
|
21
|
+
status === "working" ? meta.workingColor :
|
|
22
|
+
status === "done" ? "#6b7280" : "#6b7280";
|
|
23
|
+
|
|
24
|
+
return (
|
|
25
|
+
<div
|
|
26
|
+
key={meta.id}
|
|
27
|
+
className="bg-panel p-2 rounded-md flex justify-between items-center text-[11px]"
|
|
28
|
+
style={{ borderLeft: `3px solid ${borderColor}` }}
|
|
29
|
+
>
|
|
30
|
+
<span className="text-text-primary">
|
|
31
|
+
{meta.emoji} {meta.name} ({meta.role})
|
|
32
|
+
</span>
|
|
33
|
+
<span style={{ color: statusColor }}>{status}</span>
|
|
34
|
+
</div>
|
|
35
|
+
);
|
|
36
|
+
})}
|
|
37
|
+
</div>
|
|
38
|
+
</div>
|
|
39
|
+
);
|
|
40
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState, useEffect, useRef } from "react";
|
|
4
|
+
import { AGENTS, AGENT_MAP, ACTIVITY_TAG } from "@/lib/agents";
|
|
5
|
+
import type { Activity } from "@/types/pipeline";
|
|
6
|
+
|
|
7
|
+
interface AgentLogsProps {
|
|
8
|
+
activities: Activity[];
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function AgentLogs({ activities }: AgentLogsProps) {
|
|
12
|
+
const activeAgentIds = Array.from(new Set(activities.map((a) => a.agentId)));
|
|
13
|
+
const tabOrder = AGENTS.filter((a) => activeAgentIds.includes(a.id));
|
|
14
|
+
// Always show system as first tab option if it has activities
|
|
15
|
+
const allTabs = activeAgentIds.includes("system")
|
|
16
|
+
? [{ id: "system", emoji: "⚙️", name: "System" }, ...tabOrder]
|
|
17
|
+
: tabOrder;
|
|
18
|
+
|
|
19
|
+
const [selectedTab, setSelectedTab] = useState(allTabs[0]?.id || "");
|
|
20
|
+
const logsEndRef = useRef<HTMLDivElement>(null);
|
|
21
|
+
|
|
22
|
+
const filteredLogs = activities.filter((a) => a.agentId === selectedTab);
|
|
23
|
+
|
|
24
|
+
useEffect(() => {
|
|
25
|
+
logsEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
|
26
|
+
}, [filteredLogs.length]);
|
|
27
|
+
|
|
28
|
+
return (
|
|
29
|
+
<>
|
|
30
|
+
<div className="flex gap-0 border-b border-border px-3 bg-[#111827] overflow-x-auto">
|
|
31
|
+
{allTabs.map((agent) => (
|
|
32
|
+
<button
|
|
33
|
+
key={agent.id}
|
|
34
|
+
onClick={() => setSelectedTab(agent.id)}
|
|
35
|
+
className={`text-[11px] px-3 py-2 whitespace-nowrap ${
|
|
36
|
+
selectedTab === agent.id
|
|
37
|
+
? "text-accent-purple-light font-semibold border-b-2 border-accent-purple-light"
|
|
38
|
+
: "text-text-muted hover:text-text-secondary"
|
|
39
|
+
}`}
|
|
40
|
+
>
|
|
41
|
+
{agent.emoji} {agent.name}
|
|
42
|
+
</button>
|
|
43
|
+
))}
|
|
44
|
+
</div>
|
|
45
|
+
<div className="flex-1 bg-[#0d1117] p-3 overflow-y-auto font-[family-name:var(--font-geist-mono)] text-[11px]">
|
|
46
|
+
{filteredLogs.length === 0 ? (
|
|
47
|
+
<div className="text-text-muted text-center py-10">아직 활동 로그가 없습니다.</div>
|
|
48
|
+
) : (
|
|
49
|
+
filteredLogs.map((log) => {
|
|
50
|
+
const tag = ACTIVITY_TAG[log.type] || ACTIVITY_TAG.info;
|
|
51
|
+
const time = new Date(log.timestamp).toLocaleTimeString("ko-KR", { hour: "2-digit", minute: "2-digit" });
|
|
52
|
+
return (
|
|
53
|
+
<div key={log.id} className="mb-[5px]">
|
|
54
|
+
<span className="text-text-muted">{time}</span>{" "}
|
|
55
|
+
<span style={{ color: tag.color }}>[{tag.label}]</span>{" "}
|
|
56
|
+
<span className="text-text-primary">{log.message}</span>
|
|
57
|
+
</div>
|
|
58
|
+
);
|
|
59
|
+
})
|
|
60
|
+
)}
|
|
61
|
+
<div ref={logsEndRef} />
|
|
62
|
+
</div>
|
|
63
|
+
</>
|
|
64
|
+
);
|
|
65
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState, useEffect } from "react";
|
|
4
|
+
import dynamic from "next/dynamic";
|
|
5
|
+
import remarkGfm from "remark-gfm";
|
|
6
|
+
import type { OutputEntry } from "@/types/pipeline";
|
|
7
|
+
|
|
8
|
+
const ReactMarkdown = dynamic(() => import("react-markdown").then((mod) => mod.default), {
|
|
9
|
+
ssr: false,
|
|
10
|
+
loading: () => <div className="text-text-muted text-center py-10">Loading renderer...</div>,
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
interface ArtifactViewerProps {
|
|
14
|
+
pipelineId: string;
|
|
15
|
+
outputs: OutputEntry[];
|
|
16
|
+
selected: string;
|
|
17
|
+
onSelect: (filename: string | null) => void;
|
|
18
|
+
onClose: () => void;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function ArtifactViewer({ pipelineId, outputs, selected, onSelect, onClose }: ArtifactViewerProps) {
|
|
22
|
+
const [content, setContent] = useState<string>("");
|
|
23
|
+
const [loading, setLoading] = useState(false);
|
|
24
|
+
const [viewMode, setViewMode] = useState<"rendered" | "raw">("rendered");
|
|
25
|
+
|
|
26
|
+
useEffect(() => {
|
|
27
|
+
if (!selected) return;
|
|
28
|
+
setLoading(true);
|
|
29
|
+
fetch(`/api/pipelines/${pipelineId}/outputs/${selected}`)
|
|
30
|
+
.then((res) => res.text())
|
|
31
|
+
.then(setContent)
|
|
32
|
+
.catch(() => setContent("Failed to load file"))
|
|
33
|
+
.finally(() => setLoading(false));
|
|
34
|
+
}, [pipelineId, selected]);
|
|
35
|
+
|
|
36
|
+
const ext = selected.split(".").pop()?.toLowerCase() || "";
|
|
37
|
+
const isMarkdown = ext === "md";
|
|
38
|
+
const isHtml = ext === "html";
|
|
39
|
+
|
|
40
|
+
const grouped = outputs.reduce((acc, o) => {
|
|
41
|
+
(acc[o.phase] ||= []).push(o);
|
|
42
|
+
return acc;
|
|
43
|
+
}, {} as Record<number, OutputEntry[]>);
|
|
44
|
+
|
|
45
|
+
return (
|
|
46
|
+
<div className="fixed inset-0 bg-black/60 z-50 flex justify-end" onClick={onClose}>
|
|
47
|
+
<div className="w-full max-w-4xl bg-[#111827] border-l border-border flex" onClick={(e) => e.stopPropagation()}>
|
|
48
|
+
{/* File list */}
|
|
49
|
+
<div className="w-[200px] bg-panel border-r border-border overflow-y-auto flex-shrink-0">
|
|
50
|
+
<div className="px-3 py-2 border-b border-border">
|
|
51
|
+
<div className="text-text-secondary text-[10px] font-semibold">FILES</div>
|
|
52
|
+
</div>
|
|
53
|
+
<div className="p-2 text-[11px]">
|
|
54
|
+
{Object.entries(grouped)
|
|
55
|
+
.sort(([a], [b]) => Number(a) - Number(b))
|
|
56
|
+
.map(([phase, files]) => (
|
|
57
|
+
<div key={phase}>
|
|
58
|
+
<div className="text-text-secondary text-[9px] font-semibold mt-2 mb-1">
|
|
59
|
+
PHASE {phase}
|
|
60
|
+
</div>
|
|
61
|
+
{files.map((f) => {
|
|
62
|
+
const icon = f.filename.endsWith(".html") ? "🌐" : "📄";
|
|
63
|
+
const name = f.filename.split("/").pop();
|
|
64
|
+
return (
|
|
65
|
+
<div
|
|
66
|
+
key={f.filename}
|
|
67
|
+
onClick={() => onSelect(f.filename)}
|
|
68
|
+
className={`px-2 py-1 rounded cursor-pointer ${
|
|
69
|
+
selected === f.filename
|
|
70
|
+
? "bg-border text-accent-purple-light"
|
|
71
|
+
: "text-text-secondary hover:bg-panel"
|
|
72
|
+
}`}
|
|
73
|
+
>
|
|
74
|
+
{icon} {name}
|
|
75
|
+
</div>
|
|
76
|
+
);
|
|
77
|
+
})}
|
|
78
|
+
</div>
|
|
79
|
+
))}
|
|
80
|
+
</div>
|
|
81
|
+
</div>
|
|
82
|
+
|
|
83
|
+
{/* Preview */}
|
|
84
|
+
<div className="flex-1 flex flex-col overflow-hidden">
|
|
85
|
+
<div className="flex justify-between items-center px-4 py-3 border-b border-border">
|
|
86
|
+
<div className="text-text-primary text-sm font-semibold">{selected.split("/").pop()}</div>
|
|
87
|
+
<div className="flex items-center gap-2">
|
|
88
|
+
{isMarkdown && (
|
|
89
|
+
<div className="flex gap-1">
|
|
90
|
+
<button
|
|
91
|
+
onClick={() => setViewMode("rendered")}
|
|
92
|
+
className={`px-2 py-1 text-[10px] rounded ${viewMode === "rendered" ? "bg-panel text-accent-purple-light" : "text-text-muted"}`}
|
|
93
|
+
>
|
|
94
|
+
Rendered
|
|
95
|
+
</button>
|
|
96
|
+
<button
|
|
97
|
+
onClick={() => setViewMode("raw")}
|
|
98
|
+
className={`px-2 py-1 text-[10px] rounded ${viewMode === "raw" ? "bg-panel text-accent-purple-light" : "text-text-muted"}`}
|
|
99
|
+
>
|
|
100
|
+
Raw
|
|
101
|
+
</button>
|
|
102
|
+
</div>
|
|
103
|
+
)}
|
|
104
|
+
<button onClick={onClose} className="text-text-muted hover:text-text-primary text-lg">×</button>
|
|
105
|
+
</div>
|
|
106
|
+
</div>
|
|
107
|
+
<div className="flex-1 overflow-y-auto p-4">
|
|
108
|
+
{loading ? (
|
|
109
|
+
<div className="text-text-muted text-center py-10">Loading...</div>
|
|
110
|
+
) : isHtml ? (
|
|
111
|
+
<iframe
|
|
112
|
+
srcDoc={content}
|
|
113
|
+
className="w-full h-full border-0 bg-white rounded"
|
|
114
|
+
sandbox="allow-scripts"
|
|
115
|
+
/>
|
|
116
|
+
) : isMarkdown && viewMode === "rendered" ? (
|
|
117
|
+
<div className="prose prose-invert prose-sm max-w-none">
|
|
118
|
+
<ReactMarkdown remarkPlugins={[remarkGfm]}>{content}</ReactMarkdown>
|
|
119
|
+
</div>
|
|
120
|
+
) : isMarkdown && viewMode === "raw" ? (
|
|
121
|
+
<pre className="text-text-primary text-xs whitespace-pre-wrap font-[family-name:var(--font-geist-mono)]">{content}</pre>
|
|
122
|
+
) : (
|
|
123
|
+
<pre className="text-text-primary text-xs whitespace-pre-wrap font-[family-name:var(--font-geist-mono)]">{content}</pre>
|
|
124
|
+
)}
|
|
125
|
+
</div>
|
|
126
|
+
</div>
|
|
127
|
+
</div>
|
|
128
|
+
</div>
|
|
129
|
+
);
|
|
130
|
+
}
|