diff --git a/.github/workflows/deploy-gce.yml b/.github/workflows/deploy-gce.yml index 5e4fe94..f3c8c52 100644 --- a/.github/workflows/deploy-gce.yml +++ b/.github/workflows/deploy-gce.yml @@ -195,7 +195,7 @@ jobs: # Optional: Vertex AI (works best when the VM service account has Vertex permissions) GCP_PROJECT_ID=${{ env.GCP_PROJECT_ID }} GCP_LOCATION=us-central1 - VERTEX_AI_MODEL=gemini-2.5-pro + # NOTE: 模型選擇現在由前端透過 API 參數傳遞,不再需要 VERTEX_AI_MODEL 環境變數 LOG_LEVEL=INFO EOF diff --git a/README.md b/README.md index 9d67f3d..906c816 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,6 @@ -# AI 舊程式碼智能重構系統 +# Reforge + +AI Refactoring. Measured. Continuous. 基於 LangChain Deep Agents 的智能程式碼分析與重構服務,提供隔離的 Docker 容器環境進行安全的程式碼重構。 @@ -41,27 +43,27 @@ Dockerfile 位置: ### 啟動服務 -**使用 Docker Compose(推薦)** +**Docker Compose(開發/測試)** ```bash -# 啟動所有服務(MongoDB + Backend API + Frontend) -docker-compose -f devops/docker-compose.dev.yml up -d +# 啟動所有服務(PostgreSQL + MongoDB + Backend API + Frontend) +docker-compose -f devops/docker-compose.yml up -d # 查看服務狀態 -docker-compose -f devops/docker-compose.dev.yml ps +docker-compose -f devops/docker-compose.yml ps # 查看日誌 -docker-compose -f devops/docker-compose.dev.yml logs -f api +docker-compose -f devops/docker-compose.yml logs -f api # 停止服務 -docker-compose -f devops/docker-compose.dev.yml down +docker-compose -f devops/docker-compose.yml down ``` **GCE 單機(正式環境)** -- 先將 `refactor-base` / `refactor-api` / `refactor-frontend` 映像推送到 Artifact Registry -- 設定 `REGISTRY_HOST` / `GCP_PROJECT_ID` / `GAR_REPOSITORY` / `IMAGE_TAG` 後,用 `devops/docker-compose.prod.yml` 啟動(會從 Artifact Registry 拉取映像) -- 也可直接使用 `./scripts/deploy-prod.sh`(會先 pull + 啟動) +- 使用 `devops/docker-compose.prod.yml` +- 設定 `REGISTRY_HOST` / `GCP_PROJECT_ID` / `GAR_REPOSITORY` / `IMAGE_TAG` +- 映像從 Artifact Registry 拉取 服務端點: - Frontend: http://localhost:5173 @@ -106,7 +108,6 @@ curl -X POST http://localhost:8000/api/v1/auth/login \ ### 2. 建立專案 ```bash -# 建立專案 curl -X POST http://localhost:8000/api/v1/projects \ -H "Authorization: Bearer YOUR_TOKEN" \ -H "Content-Type: application/json" \ @@ -120,7 +121,6 @@ curl -X POST http://localhost:8000/api/v1/projects \ ### 3. Provision 專案 ```bash -# Provision 專案(建立隔離容器並 clone repository) curl -X POST http://localhost:8000/api/v1/projects/{project_id}/provision \ -H "Authorization: Bearer YOUR_TOKEN" ``` @@ -143,16 +143,10 @@ curl -N http://localhost:8000/api/v1/projects/{project_id}/agent/runs/{run_id}/s ## 測試 -### E2E 測試 - ```bash # 執行完整 E2E 測試 ./test_cloud_run_e2e_v2.sh -``` - -### Base Image 測試 -```bash # 測試 base image 建置 export ANTHROPIC_API_KEY=your-api-key ./test_base_image.sh @@ -162,65 +156,44 @@ export ANTHROPIC_API_KEY=your-api-key ``` ┌─────────────┐ HTTP ┌──────────────────┐ -│ Frontend │ ◄─────────────► │ Backend API │ -│ (React/Vite)│ │ (FastAPI) │ -└─────────────┘ └────────┬─────────┘ - │ - ┌────────────────┼────────────────┐ - │ │ │ - ▼ ▼ ▼ - ┌──────────┐ ┌──────────────┐ ┌──────────┐ - │ MongoDB │ │Docker Network│ │ Project │ - │ │ │ │ │Container │ - └──────────┘ └──────┬───────┘ └────┬─────┘ - │ │ - │ HTTP │ - └───────────────┤ - │ - ┌─────▼──────┐ - │ AI Server │ - │ (FastAPI) │ - │ │ - │ Deep Agent │ - └────────────┘ +│ Frontend │ <────────────> │ Backend API │ +│ (React/Vite)│ │ (FastAPI) │ +└─────────────┘ └────────┬─────────┘ + │ + ┌────────────────┼────────────────┐ + │ │ │ + v v v + ┌──────────┐ ┌──────────────┐ ┌──────────┐ + │ MongoDB │ │Docker Network│ │ Project │ + │ │ │ │ │Container │ + └──────────┘ └──────┬───────┘ └────┬─────┘ + │ │ + │ HTTP │ + └───────────────┤ + │ + ┌─────v──────┐ + │ AI Server │ + │ (FastAPI) │ + │ │ + │ Deep Agent │ + └────────────┘ ``` -### 核心特性 - -- **隔離環境**: 每個專案在獨立的 Docker 容器中執行 -- **AI Server**: 容器內建 FastAPI HTTP Server,提供 Agent 執行介面 -- **異步任務**: 支援長時間執行的 Agent 任務(無 timeout 限制) -- **實時日誌**: SSE 串流提供 Agent 執行過程的實時日誌 -- **JWT 認證**: 安全的使用者認證機制 - ## 技術棧 -- **Backend**: FastAPI, Python 3.11, MongoDB +- **Backend**: FastAPI, Python 3.11, MongoDB, PostgreSQL - **Frontend**: React 18, Vite, TypeScript, Tailwind CSS, shadcn/ui - **AI/ML**: LangChain, Deep Agents, Anthropic Claude - **容器**: Docker, Docker Compose - **認證**: JWT (JSON Web Tokens) -## 📚 文件 +## 文件 -### 完整文件導覽 - -請參閱 **[docs/](./docs/)** 資料夾: - -- **[docs/API.md](./docs/API.md)** - REST API 完整規格(詳細的 Request/Response) -- **[docs/BACKEND.md](./docs/BACKEND.md)** - 後端技術文件(架構、服務層、部署) -- **[docs/guides/](./docs/guides/)** - 使用指南(CLI 工具等) +- **[docs/API.md](./docs/API.md)** - REST API 完整規格 +- **[docs/BACKEND.md](./docs/BACKEND.md)** - 後端技術文件 +- **[docs/guides/](./docs/guides/)** - 使用指南 - **[docs/testing/](./docs/testing/)** - 測試文件 - -### 開發指引 - -- [CLAUDE.md](./CLAUDE.md) - Claude Code 專案指引 -- [docs/README.md](./docs/README.md) - 文件索引 - -### API 文件 - -- **Swagger UI**: http://localhost:8000/docs(互動式 API 文件) -- **詳細規格**: [docs/API.md](./docs/API.md)(完整的 Request/Response 範例) +- **[CLAUDE.md](./CLAUDE.md)** - Claude Code 專案指引 ## 常見問題 @@ -235,14 +208,13 @@ docker images | grep refactor-base 1. 檢查容器內 AI Server 的 LLM API Key 設定 2. 查看容器日誌:`docker logs refactor-project-{project_id}` -3. 檢查 API 日誌:`docker-compose -f devops/docker-compose.dev.yml logs -f api` -4. 查看 Agent 執行日誌:使用 SSE stream 端點 +3. 檢查 API 日誌:`docker-compose -f devops/docker-compose.yml logs -f api` ### 如何清理測試資料? ```bash # 停止並移除所有容器和資料 -docker-compose -f devops/docker-compose.dev.yml down -v +docker-compose -f devops/docker-compose.yml down -v # 清理專案容器 docker ps -a | grep refactor-project | awk '{print $1}' | xargs docker rm -f diff --git a/agent/deep_agent.py b/agent/deep_agent.py index 40d13c4..c6e1a66 100644 --- a/agent/deep_agent.py +++ b/agent/deep_agent.py @@ -149,9 +149,16 @@ def _agent_init(self): ) # 準備 middleware 列表 - # 注意:當 checkpointer 啟用時,SummarizationMiddleware 可能已被自動添加 - # 暫時不手動添加 middleware,避免重複 - middleware = [] + # 啟用 SummarizationMiddleware 來自動壓縮過長的對話歷史 + middleware = [ + SummarizationMiddleware( + model=self.model, + # 當訊息數量超過 50 條時觸發壓縮 + trigger=("messages", 50), + # 壓縮後保留最近的 20 條訊息 + keep=("messages", 20), + ) + ] self.agent = create_deep_agent( model=self.model, diff --git a/devops/docker-compose.dev.yml b/devops/docker-compose.dev.yml deleted file mode 100644 index d1d5e9f..0000000 --- a/devops/docker-compose.dev.yml +++ /dev/null @@ -1,94 +0,0 @@ -services: - # PostgreSQL for LangGraph persistence - postgres: - image: postgres:16 - container_name: refactor-postgres - ports: - - "5432:5432" - volumes: - - postgres_data:/var/lib/postgresql/data - environment: - POSTGRES_USER: langgraph - POSTGRES_PASSWORD: langgraph_secret - POSTGRES_DB: langgraph - networks: - - refactor-network - healthcheck: - test: ["CMD-SHELL", "pg_isready -U langgraph"] - interval: 10s - timeout: 5s - retries: 5 - - # MongoDB 資料庫 - mongodb: - image: mongo:7 - container_name: refactor-mongodb - ports: - - "27017:27017" - volumes: - - mongodb_data:/data/db - environment: - MONGO_INITDB_DATABASE: refactor_agent - networks: - - refactor-network - healthcheck: - test: ["CMD", "mongosh", "--eval", "db.adminCommand('ping')"] - interval: 10s - timeout: 5s - retries: 5 - - # FastAPI 後端服務 - api: - build: - context: .. - dockerfile: backend/Dockerfile - container_name: refactor-api - ports: - - "8000:8000" - volumes: - - ../backend:/app - - /var/run/docker.sock:/var/run/docker.sock - - /tmp/refactor-workspaces:/tmp/refactor-workspaces - - ${HOME}/.config/gcloud/application_default_credentials.json:/root/.config/gcloud/application_default_credentials.json:ro - env_file: - - ../backend/.env - environment: - - MONGODB_URL=mongodb://mongodb:27017 - - MONGODB_DATABASE=refactor_agent - - POSTGRES_URL=postgresql://langgraph:langgraph_secret@postgres:5432/langgraph - - DEBUG=true - depends_on: - mongodb: - condition: service_healthy - postgres: - condition: service_healthy - networks: - - refactor-network - command: uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload - - # React 前端服務 - frontend: - build: - context: .. - dockerfile: frontend/Dockerfile - container_name: refactor-frontend - ports: - - "5173:5173" - volumes: - - ../frontend:/app - - /app/node_modules # 避免覆蓋 node_modules - environment: - - VITE_API_BASE_URL=http://localhost:8000 - depends_on: - - api - networks: - - refactor-network - -volumes: - mongodb_data: - postgres_data: - -networks: - refactor-network: - name: refactor-network - driver: bridge diff --git a/frontend/components.json b/frontend/components.json index afb0587..adf4f7a 100644 --- a/frontend/components.json +++ b/frontend/components.json @@ -1,13 +1,13 @@ { "$schema": "https://ui.shadcn.com/schema.json", - "style": "default", + "style": "new-york", "rsc": false, "tsx": true, "tailwind": { "config": "tailwind.config.js", "css": "src/index.css", - "baseColor": "neutral", - "cssVariables": false, + "baseColor": "stone", + "cssVariables": true, "prefix": "" }, "aliases": { diff --git a/frontend/index.html b/frontend/index.html index dce9be6..9374b40 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -2,9 +2,12 @@
- + -尚未啟動 Agent Run
點擊「開始重構」按鈕來啟動
@@ -77,28 +78,28 @@ export function AgentRunPanel({ projectId, currentRun, onTasksUpdate, onReconnec } const statusConfig = { - RUNNING: { icon: Loader2, color: 'text-purple-400', spin: true, label: '執行中' }, + RUNNING: { icon: Loader2, color: 'text-brand-blue-400', spin: true, label: '執行中' }, DONE: { icon: CheckCircle, color: 'text-green-400', spin: false, label: '完成' }, FAILED: { icon: AlertCircle, color: 'text-red-400', spin: false, label: '失敗' }, - STOPPED: { icon: Square, color: 'text-gray-400', spin: false, label: '已停止' }, + STOPPED: { icon: Square, color: 'text-muted-foreground', spin: false, label: '已停止' }, } const status = statusConfig[currentRun.status] || statusConfig.STOPPED const StatusIcon = status.icon return ( -等待日誌...
-等待日誌...
++{JSON.stringify(log.content.args, null, 2)})} @@ -173,7 +176,7 @@ function LogEntry({ log }: { log: LogEntry }) {✓ 工具結果 {log.content.output && ( -+{typeof log.content.output === 'string' ? log.content.output : JSON.stringify(log.content.output, null, 2)} @@ -191,28 +194,28 @@ function LogEntry({ log }: { log: LogEntry }) { case 'token_usage': return ( -+📊 Token 使用: 輸入 {log.content.input_tokens || 0} / 輸出 {log.content.output_tokens || 0}) case 'status': return ( -+📌 狀態: {log.content.status || '未知'}) case 'log': return ( -+{log.content.message || JSON.stringify(log.content)}) default: return ( -+{JSON.stringify(log.content)}) @@ -220,8 +223,8 @@ function LogEntry({ log }: { log: LogEntry }) { } return ( --++[{new Date(log.timestamp).toLocaleTimeString()}] {log.type}{renderContent()} diff --git a/frontend/src/components/agent/TaskList.tsx b/frontend/src/components/agent/TaskList.tsx index 5c9ccc8..6916f8c 100644 --- a/frontend/src/components/agent/TaskList.tsx +++ b/frontend/src/components/agent/TaskList.tsx @@ -29,25 +29,25 @@ export function TaskList({ tasks, compact = false }: TaskListProps) { } return ( -+{/* Header */} --++{/* Task List */} -Tasks {inProgressCount > 0 && ( - {inProgressCount} running + {inProgressCount} running )} - + {completedCount}/{tasks.length}+{tasks.map((task, index) => ())} @@ -59,17 +59,17 @@ export function TaskList({ tasks, compact = false }: TaskListProps) { function TaskItem({ task }: { task: Task }) { return ( {task.content} @@ -83,10 +83,10 @@ function CompactTaskItem({ task }: { task: Task }) {{task.content} @@ -101,8 +101,8 @@ function StatusIcon({ status, size = 'md' }: { status: Task['status']; size?: 's case 'completed': return case 'in_progress': - return + return case 'pending': - return + return } } diff --git a/frontend/src/components/brand/ReforgeLogo.tsx b/frontend/src/components/brand/ReforgeLogo.tsx new file mode 100644 index 0000000..70f410f --- /dev/null +++ b/frontend/src/components/brand/ReforgeLogo.tsx @@ -0,0 +1,20 @@ +interface ReforgeLogoProps { + size?: 'sm' | 'md' | 'lg' + className?: string +} + +const sizes = { + sm: 'w-8 h-8', + md: 'w-12 h-12', + lg: 'w-16 h-16', +} + +export function ReforgeLogo({ size = 'md', className = '' }: ReforgeLogoProps) { + return ( + + ) +} diff --git a/frontend/src/components/chat/ChatPanel.tsx b/frontend/src/components/chat/ChatPanel.tsx index d69f95f..673a69c 100644 --- a/frontend/src/components/chat/ChatPanel.tsx +++ b/frontend/src/components/chat/ChatPanel.tsx @@ -21,6 +21,9 @@ import { EmptyState } from "@/components/ui/EmptyState"; import { apiErrorMessage } from "@/utils/apiError"; import { useAgentRunStream } from "@/hooks/useAgentRunStream"; import { ModelSelector } from "@/components/common/ModelSelector"; +import { useToast } from "@/hooks/useToast"; +import { Toast } from "@/components/ui/toast"; +import { ScrollArea } from "@/components/ui/scroll-area"; interface Props { projectId: string; @@ -65,6 +68,10 @@ export function ChatPanel({ const messagesEndRef = useRef
(null); const textareaRef = useRef (null); const cancelStreamRef = useRef<(() => void) | null>(null); + const warningShownRef = useRef<{ [key: number]: boolean }>({}); + + // Toast for warnings + const toast = useToast(); // Agent Run 串流整合 const isAgentRunning = currentRun?.status === 'RUNNING'; @@ -130,6 +137,34 @@ export function ChatPanel({ messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); }, [messages]); + // 監控會話長度,顯示警告 + useEffect(() => { + const count = messages.length; + + // 第一次警告: 50 條訊息 + if (count >= 50 && count < 100 && !warningShownRef.current[50]) { + warningShownRef.current[50] = true; + toast.info( + "💡 對話已累積 50+ 條訊息,建議開啟新會話以獲得更好的效能", + 8000 + ); + } + + // 第二次警告: 100 條訊息(更嚴重) + if (count >= 100 && !warningShownRef.current[100]) { + warningShownRef.current[100] = true; + toast.info( + "⚠️ 對話已累積 100+ 條訊息,強烈建議開啟新會話避免效能問題", + 10000 + ); + } + }, [messages.length, toast]); + + // 當 threadId 改變時,重置警告狀態 + useEffect(() => { + warningShownRef.current = {}; + }, [threadId]); + useEffect(() => { onStreamingChange?.(isAnyStreaming); }, [isAnyStreaming, onStreamingChange]); @@ -477,26 +512,28 @@ export function ChatPanel({ }; return ( - +{/* Messages */} -- {loadingHistory && ( -+-- )} - {!loadingHistory && messages.length === 0 ? ( -- Loading history... - } /> - ) : ( - messages.map((msg) => ) - )} - - + {/* Input */}+ {loadingHistory && ( ++++ )} + {!loadingHistory && messages.length === 0 ? ( ++ Loading history... + } /> + ) : ( + messages.map((msg) => ) + )} + + -); } @@ -574,10 +618,10 @@ function MessageEntry({ message, isStreaming }: { message: ChatMessage; isStream if (role === "user") { return (+{isAnyStreaming && ( -{/* Textarea */} @@ -530,7 +567,7 @@ export function ChatPanel({- {isReconnecting && 重新連線中...} ++ + {/* Toast 通知 */} ++ {isReconnecting && 重新連線中...} {!isReconnecting && streamHint && ( {streamHint} ({elapsedText} · {formatTokens(tokenUsage)} tokens) @@ -549,6 +586,13 @@ export function ChatPanel({)}+ {toast.toasts.map((t) => ( ++ ))} + -+); } @@ -608,26 +652,26 @@ function MessageEntry({ message, isStreaming }: { message: ChatMessage; isStream-{content}
+ {displayName}- {displayMeta &&{displayMeta}} + {displayMeta &&{displayMeta}} {subagentDescription && ( -{subagentDescription}+{subagentDescription})} {displayArgs && Object.keys(displayArgs).length > 0 && (-)} {toolOutput ? ( -+
Args
-+{JSON.stringify(displayArgs, null, 2)}+{toolOutput}) : ( -+@@ -640,14 +684,14 @@ function MessageEntry({ message, isStreaming }: { message: ChatMessage; isStream // Assistant message return (Running... -+ {content ? ( -diff --git a/frontend/src/components/chat/ChatSessionList.tsx b/frontend/src/components/chat/ChatSessionList.tsx index 0372b3c..6eac689 100644 --- a/frontend/src/components/chat/ChatSessionList.tsx +++ b/frontend/src/components/chat/ChatSessionList.tsx @@ -1,4 +1,5 @@ import type { ChatSessionSummary } from '@/types/chat.types' +import { ScrollArea } from '@/components/ui/scroll-area' interface ChatSessionListProps { sessions: ChatSessionSummary[] @@ -16,40 +17,42 @@ export function ChatSessionList({ onNew, }: ChatSessionListProps) { return ( -+) : ( - isStreaming && + isStreaming && )}{content} +) } diff --git a/frontend/src/components/common/ModelSelector.tsx b/frontend/src/components/common/ModelSelector.tsx index fdac6e0..8037142 100644 --- a/frontend/src/components/common/ModelSelector.tsx +++ b/frontend/src/components/common/ModelSelector.tsx @@ -2,6 +2,7 @@ import { useState, useEffect, useRef, useMemo } from 'react' import { getAvailableModelsAPI } from '@/services/models.service' import type { ModelInfo } from '@/types/model.types' import { ChevronDown } from 'lucide-react' +import { ScrollArea } from '@/components/ui/scroll-area' interface Props { value?: string @@ -67,17 +68,17 @@ export function ModelSelector({ value, onChange, disabled }: Props) { type="button" onClick={() => !disabled && setOpen(!open)} disabled={disabled} - className="flex items-center gap-1 px-2 py-0.5 text-xs text-gray-400 hover:text-gray-200 rounded hover:bg-gray-700/50 transition-colors disabled:opacity-40 disabled:cursor-not-allowed" + className="flex items-center gap-1 px-2 py-0.5 text-xs text-muted-foreground hover:text-secondary-foreground rounded hover:bg-secondary/50 transition-colors disabled:opacity-40 disabled:cursor-not-allowed" > {displayName}- Sessions + Sessions-- {sessions.length === 0 ? ( -+No sessions- ) : ( - sessions.map((session) => ( - - )) - )} -+ + {sessions.length === 0 ? ( ++No sessions+ ) : ( + sessions.map((session) => ( + + )) + )} +{open && ( - +) diff --git a/frontend/src/components/file/FileTree.tsx b/frontend/src/components/file/FileTree.tsx index d03a1f4..a962b88 100644 --- a/frontend/src/components/file/FileTree.tsx +++ b/frontend/src/components/file/FileTree.tsx @@ -3,6 +3,7 @@ import { ChevronRight, ChevronDown, Folder, FolderOpen } from 'lucide-react' import { FileIcon } from './FileIcon' import type { FileTreeNode } from '@/types/file.types' import { EmptyState } from '@/components/ui/EmptyState' +import { ScrollArea } from '@/components/ui/scroll-area' interface FileTreeProps { tree: FileTreeNode[] @@ -12,21 +13,23 @@ interface FileTreeProps { export const FileTree = memo(function FileTree({ tree, onFileSelect, selectedPath }: FileTreeProps) { return ( -{Object.entries(grouped).map(([provider, providerModels]) => ( )}-++))} -{provider}{providerModels.map((model) => ( @@ -88,17 +89,17 @@ export function ModelSelector({ value, onChange, disabled }: Props) { onChange(model.id) setOpen(false) }} - className={`w-full text-left px-3 py-1.5 hover:bg-gray-700/60 transition-colors ${ - model.id === value ? 'bg-gray-700/40' : '' + className={`w-full text-left px-3 py-1.5 hover:bg-secondary/60 transition-colors ${ + model.id === value ? 'bg-secondary/40' : '' }`} > -{model.display_name}-{model.description}+{model.display_name}+{model.description}))}- {tree.length === 0 ? ( -+- ) : ( - tree.map((node) => ( - - )) - )} - + ) }) @@ -55,8 +58,8 @@ function TreeNode({ node, depth, onFileSelect, selectedPath }: TreeNodeProps) { return (+ {tree.length === 0 ? ( +++ ) : ( + tree.map((node) => ( + + )) + )} + {isExpanded ? ( -+ ) : ( - + )} {isExpanded ? ( diff --git a/frontend/src/components/file/FileViewer.tsx b/frontend/src/components/file/FileViewer.tsx index f1e5765..07a6f04 100644 --- a/frontend/src/components/file/FileViewer.tsx +++ b/frontend/src/components/file/FileViewer.tsx @@ -96,23 +96,23 @@ export const FileViewer = memo(function FileViewer({ if (files.length === 0) { return ( - +) } return ( - +{/* Tabs */} -+{files.map((file) => (onTabSelect(file.path)} > @@ -122,7 +122,7 @@ export const FileViewer = memo(function FileViewer({) : (