diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml new file mode 100644 index 00000000..2162772a --- /dev/null +++ b/.github/workflows/docker-publish.yml @@ -0,0 +1,61 @@ +name: docker build and publish + +on: + push: + branches: + - main + - feat/dockerize-app + tags: + - 'v*' + pull_request: + branches: + - main + +env: + REGISTRY: ghcr.io + IMAGE_NAME: ${{ github.repository }} + +jobs: + build: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + + steps: + - name: checkout repository + uses: actions/checkout@v4 + + - name: set up docker buildx + uses: docker/setup-buildx-action@v3 + + - name: log in to github container registry + if: github.event_name != 'pull_request' + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: extract metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + tags: | + type=ref,event=branch + type=ref,event=pr + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=raw,value=latest,enable={{is_default_branch}} + + - name: build and push docker image + uses: docker/build-push-action@v5 + with: + context: . + platforms: linux/amd64,linux/arm64 + push: ${{ github.event_name != 'pull_request' }} + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max \ No newline at end of file diff --git a/DOCKER_DEPLOYMENT.md b/DOCKER_DEPLOYMENT.md new file mode 100644 index 00000000..d15808c1 --- /dev/null +++ b/DOCKER_DEPLOYMENT.md @@ -0,0 +1,142 @@ +# claudable docker deployment guide + +## quick start - one click run + +### prerequisites +- docker and docker compose installed +- claude api key from anthropic + +### 1. pull and run (easiest method) + +```bash +# pull the latest image +docker pull ghcr.io/opactorai/claudable:latest + +# run with your api key +docker run -d \ + -p 3000:3000 \ + -p 8080:8080 \ + -e ANTHROPIC_API_KEY="your-claude-api-key-here" \ + -v claudable-data:/app/data \ + --name claudable \ + ghcr.io/opactorai/claudable:latest +``` + +### 2. using docker compose + +create a `.env` file: +```bash +ANTHROPIC_API_KEY=your-claude-api-key-here +``` + +create `docker-compose.yml`: +```yaml +version: '3.8' + +services: + claudable: + image: ghcr.io/opactorai/claudable:latest + ports: + - "3000:3000" + - "8080:8080" + environment: + - ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY} + volumes: + - claudable-data:/app/data + restart: unless-stopped + +volumes: + claudable-data: +``` + +run: +```bash +docker compose up -d +``` + +## access the application + +- **frontend**: http://localhost:3000 +- **api**: http://localhost:8080 +- **api docs**: http://localhost:8080/docs + +## build from source + +```bash +# clone repository +git clone https://github.com/opactorai/claudable.git +cd claudable + +# build image +docker build -t claudable:local . + +# run with docker compose +docker compose up -d +``` + +## environment variables + +| variable | description | required | default | +|----------|-------------|----------|---------| +| `ANTHROPIC_API_KEY` | claude api key for ai functionality | yes | - | +| `NEXT_PUBLIC_API_URL` | backend api url | no | http://localhost:8080 | +| `API_PORT` | api server port | no | 8080 | +| `WEB_PORT` | web server port | no | 3000 | + +## data persistence + +the sqlite database is stored in `/app/data` inside the container. to persist data: + +```bash +# named volume (recommended) +docker run -v claudable-data:/app/data ... + +# or bind mount +docker run -v $(pwd)/data:/app/data ... +``` + +## stopping and removing + +```bash +# stop container +docker stop claudable + +# remove container +docker rm claudable + +# remove image +docker rmi ghcr.io/opactorai/claudable:latest +``` + +## troubleshooting + +### ports already in use +change the port mapping: +```bash +docker run -p 3001:3000 -p 8081:8080 ... +``` + +### permission issues +ensure the data directory has correct permissions: +```bash +docker exec claudable chown -R nextjs:nodejs /app/data +``` + +### api connection issues +verify the api is accessible: +```bash +docker logs claudable +curl http://localhost:8080/docs +``` + +## security notes + +- **never commit** your `.env` file with api keys +- use docker secrets for production deployments +- regularly update the base image for security patches +- consider using a reverse proxy for production + +## support + +- github issues: https://github.com/opactorai/claudable/issues +- discord: https://discord.gg/njnbafhnqc \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000..10d6df2c --- /dev/null +++ b/Dockerfile @@ -0,0 +1,79 @@ +# multi-stage build for claudable app +FROM node:20-alpine AS base + +# install python and dependencies +RUN apk add --no-cache python3 py3-pip python3-dev build-base git + +# set working directory +WORKDIR /app + +# copy package files and scripts +COPY package*.json ./ +COPY scripts ./scripts +COPY apps/web/package*.json ./apps/web/ +COPY apps/api/requirements.txt ./apps/api/ + +# install node dependencies without running postinstall +RUN npm ci --only=production --ignore-scripts + +# build stage for next.js +FROM base AS builder +WORKDIR /app +COPY . . + +# install all dependencies for build +RUN npm ci --ignore-scripts +WORKDIR /app/apps/web +RUN npm ci + +# build next.js app +WORKDIR /app/apps/web +RUN npm run build + +# production stage +FROM node:20-alpine AS runner +RUN apk add --no-cache python3 py3-pip + +WORKDIR /app + +# create non-root user +RUN addgroup -g 1001 -S nodejs && \ + adduser -S nextjs -u 1001 + +# copy built application +COPY --from=builder --chown=nextjs:nodejs /app/apps/web/.next ./apps/web/.next +COPY --from=builder --chown=nextjs:nodejs /app/apps/web/public ./apps/web/public +COPY --from=builder --chown=nextjs:nodejs /app/apps/web/package*.json ./apps/web/ +COPY --from=builder --chown=nextjs:nodejs /app/apps/api ./apps/api +COPY --from=builder --chown=nextjs:nodejs /app/package*.json ./ +COPY --from=builder --chown=nextjs:nodejs /app/scripts ./scripts + +# install production dependencies +RUN npm ci --only=production +WORKDIR /app/apps/web +RUN npm ci --only=production + +# setup python environment +WORKDIR /app/apps/api +RUN python3 -m venv .venv && \ + .venv/bin/pip install --no-cache-dir -r requirements.txt + +# create data directory for sqlite +RUN mkdir -p /app/data && chown -R nextjs:nodejs /app/data + +USER nextjs + +# expose ports +EXPOSE 3000 8080 + +# environment variables +ENV NODE_ENV=production \ + ANTHROPIC_API_KEY="" \ + NEXT_PUBLIC_API_URL=http://localhost:8080 \ + API_HOST=0.0.0.0 \ + API_PORT=8080 \ + WEB_PORT=3000 + +# start both services +WORKDIR /app +CMD ["sh", "-c", "cd /app/apps/api && .venv/bin/uvicorn app.main:app --host 0.0.0.0 --port 8080 & cd /app/apps/web && npm start"] \ No newline at end of file diff --git a/apps/api/app/services/cli/unified_manager.py b/apps/api/app/services/cli/unified_manager.py index 56dd8744..4130eb3a 100644 --- a/apps/api/app/services/cli/unified_manager.py +++ b/apps/api/app/services/cli/unified_manager.py @@ -18,7 +18,8 @@ def get_project_root() -> str: """Get project root directory using relative path navigation""" current_file_dir = os.path.dirname(os.path.abspath(__file__)) - # unified_manager.py -> cli -> services -> app -> api -> apps -> project-root + # unified_manager.py is in: app/services/cli/ + # Navigate: cli -> services -> app -> api -> apps -> project-root project_root = os.path.join(current_file_dir, "..", "..", "..", "..", "..") return os.path.abspath(project_root) @@ -1026,8 +1027,9 @@ async def _ensure_agent_md(self, project_path: str) -> None: try: # Read system prompt from the source file using relative path current_file_dir = os.path.dirname(os.path.abspath(__file__)) - # unified_manager.py -> cli -> services -> app - app_dir = os.path.join(current_file_dir, "..", "..", "..") + # unified_manager.py is in: app/services/cli/ + # Navigate: cli -> services -> app + app_dir = os.path.join(current_file_dir, "..", "..") app_dir = os.path.abspath(app_dir) system_prompt_path = os.path.join(app_dir, "prompt", "system-prompt.md") diff --git a/apps/web/app/page.tsx b/apps/web/app/page.tsx index 7374b497..51a7ff77 100644 --- a/apps/web/app/page.tsx +++ b/apps/web/app/page.tsx @@ -984,8 +984,8 @@ export default function HomePage() { initial={{ opacity: 0, scale: 0.9 }} animate={{ opacity: 1, scale: 1 }} exit={{ opacity: 0, scale: 0.9 }} - className="bg-white dark:bg-gray-900 rounded-lg p-6 max-w-md w-full mx-4 border border-gray-200 dark:border-gray-700" > +
@@ -1029,6 +1029,7 @@ export default function HomePage() { )}
+
)} diff --git a/apps/web/components/ChatLog.tsx b/apps/web/components/ChatLog.tsx index d1407f9d..2c64541a 100644 --- a/apps/web/components/ChatLog.tsx +++ b/apps/web/components/ChatLog.tsx @@ -9,7 +9,12 @@ import ToolResultItem from './ToolResultItem'; // Tool Message Component - Enhanced with new design const ToolMessage = ({ content, metadata }: { content: unknown; metadata?: { tool_name?: string; summary?: string; description?: string; file_path?: string; [key: string]: unknown } }) => { // Process tool content to extract action and file path - const processToolContent = (rawContent: unknown) => { + const processToolContent = (rawContent: unknown): { + action: 'Edited' | 'Created' | 'Read' | 'Deleted' | 'Generated' | 'Searched' | 'Executed'; + filePath: string; + cleanContent: string | undefined; + toolName: string; + } => { let processedContent = '' as string; let action: 'Edited' | 'Created' | 'Read' | 'Deleted' | 'Generated' | 'Searched' | 'Executed' = 'Executed'; let filePath = ''; diff --git a/apps/web/components/ToolResultItem.tsx b/apps/web/components/ToolResultItem.tsx index 5765dca8..96965d91 100644 --- a/apps/web/components/ToolResultItem.tsx +++ b/apps/web/components/ToolResultItem.tsx @@ -110,7 +110,7 @@ const ToolResultItem: React.FC = ({ action, filePath, conte animate={{ height: 'auto', opacity: 1 }} exit={{ height: 0, opacity: 0 }} transition={{ duration: 0.2 }} - className="overflow-hidden" + style={{ overflow: 'hidden' }} >
diff --git a/apps/web/hooks/useUserRequests.ts b/apps/web/hooks/useUserRequests.ts
index 757de0bf..85226c33 100644
--- a/apps/web/hooks/useUserRequests.ts
+++ b/apps/web/hooks/useUserRequests.ts
@@ -14,7 +14,7 @@ export function useUserRequests({ projectId }: UseUserRequestsOptions) {
   const [activeCount, setActiveCount] = useState(0);
   const [isTabVisible, setIsTabVisible] = useState(true); // 기본값 true로 설정
   
-  const intervalRef = useRef();
+  const intervalRef = useRef(null);
   const previousActiveState = useRef(false);
 
   // 탭 활성화 상태 추적
@@ -65,7 +65,7 @@ export function useUserRequests({ projectId }: UseUserRequestsOptions) {
     if (!isTabVisible) {
       if (intervalRef.current) {
         clearInterval(intervalRef.current);
-        intervalRef.current = undefined;
+        intervalRef.current = null;
       }
       return;
     }
diff --git a/apps/web/package.json b/apps/web/package.json
index 0345bb59..35318fc5 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -18,6 +18,7 @@
     "open": "^10.2.0",
     "react": "18.2.0",
     "react-dom": "18.2.0",
+    "react-icons": "^5.5.0",
     "react-markdown": "^10.1.0",
     "react-syntax-highlighter": "^15.5.0"
   },
diff --git a/docker-compose.yml b/docker-compose.yml
new file mode 100644
index 00000000..051d8347
--- /dev/null
+++ b/docker-compose.yml
@@ -0,0 +1,26 @@
+version: '3.8'
+
+services:
+  claudable:
+    build:
+      context: .
+      dockerfile: Dockerfile
+    ports:
+      - "3000:3000"  # next.js frontend
+      - "8080:8080"  # fastapi backend
+    environment:
+      - ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY}
+      - NODE_ENV=production
+      - NEXT_PUBLIC_API_URL=http://localhost:8080
+      - API_HOST=0.0.0.0
+      - API_PORT=8080
+      - WEB_PORT=3000
+    volumes:
+      - ./data:/app/data  # persist sqlite database
+    restart: unless-stopped
+    healthcheck:
+      test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:3000"]
+      interval: 30s
+      timeout: 10s
+      retries: 3
+      start_period: 40s
\ No newline at end of file