diff --git a/backend/Dockerfile b/backend/Dockerfile index 25a79a2..e013b92 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -3,24 +3,31 @@ FROM python:3.11-slim WORKDIR /app -# Install system dependencies +# Install system dependencies (fonts for matplotlib charts in Docker) RUN apt-get update && apt-get install -y \ gcc \ libpq-dev \ + fonts-wqy-zenhei \ && rm -rf /var/lib/apt/lists/* # Install Python dependencies -COPY requirements.txt . +COPY backend/requirements.txt . RUN pip install --no-cache-dir -r requirements.txt -# Copy source -COPY . . +# Backend source +COPY backend/ /app/ -# Create uploads directory -RUN mkdir -p /app/uploads +# Repo assets required by Skill runtime +COPY skills/ /repo/skills/ +COPY config/ /repo/config/ +COPY static/ /repo/static/ + +ENV AGENTFLOW_REPO_ROOT=/repo +ENV AGENTFLOW_UPLOADS_DIR=/repo/uploads +ENV PYTHONPATH=/app + +RUN mkdir -p /repo/uploads /repo/static/generated -# Expose port EXPOSE 8000 -# Run CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/backend/app/core/paths.py b/backend/app/core/paths.py new file mode 100644 index 0000000..b0203c5 --- /dev/null +++ b/backend/app/core/paths.py @@ -0,0 +1,55 @@ +"""Repository path resolution for local dev and Docker.""" +from __future__ import annotations + +import os +from functools import lru_cache +from pathlib import Path + + +@lru_cache +def repo_root() -> Path: + """AgentFlow repo root (contains skills/, config/, static/).""" + env = (os.environ.get("AGENTFLOW_REPO_ROOT") or "").strip() + if env: + return Path(env).resolve() + + # backend/app/core/paths.py -> parents[2] == backend/ + backend_dir = Path(__file__).resolve().parents[2] + candidate = backend_dir.parent + if (candidate / "skills").is_dir(): + return candidate + + # Docker: skills/config/static mounted under /repo + docker_repo = Path("/repo") + if docker_repo.is_dir(): + return docker_repo + + return candidate + + +def skills_root() -> Path: + return repo_root() / "skills" + + +def static_root() -> Path: + return repo_root() / "static" + + +def generated_root() -> Path: + path = static_root() / "generated" + path.mkdir(parents=True, exist_ok=True) + return path + + +def uploads_root() -> Path: + env = (os.environ.get("AGENTFLOW_UPLOADS_DIR") or "").strip() + if env: + path = Path(env).resolve() + else: + path = repo_root() / "uploads" + path.mkdir(parents=True, exist_ok=True) + return path + + +def models_catalog_path() -> Path: + return repo_root() / "config" / "models.json" diff --git a/backend/app/main.py b/backend/app/main.py index a696738..2623635 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -2,9 +2,7 @@ AgentFlow - AI Agent 部署平台 生产级架构,支持 WebSocket、缓存、监控、日志 """ -from pathlib import Path - -from fastapi import FastAPI, Request +from app.core.paths import generated_root, static_root from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import JSONResponse from fastapi.staticfiles import StaticFiles @@ -116,9 +114,8 @@ async def log_requests(request: Request, call_next): app.include_router(skills.router, prefix="/api/v1/skills", tags=["Skill 广场"]) # Skill 工具产出(Excel/PDF/PPT 等) -_static_root = Path(__file__).resolve().parents[3] / "static" -_static_root.mkdir(parents=True, exist_ok=True) -(_static_root / "generated").mkdir(parents=True, exist_ok=True) +_static_root = static_root() +generated_root() app.mount("/static", StaticFiles(directory=str(_static_root)), name="static") # WebSocket 路由 diff --git a/backend/app/services/model_catalog.py b/backend/app/services/model_catalog.py index c114358..d2aa7d9 100644 --- a/backend/app/services/model_catalog.py +++ b/backend/app/services/model_catalog.py @@ -5,7 +5,9 @@ from pathlib import Path from typing import Any -CATALOG_PATH = Path(__file__).resolve().parents[4] / "config" / "models.json" +from app.core.paths import models_catalog_path + +CATALOG_PATH = models_catalog_path() @lru_cache diff --git a/backend/app/services/skill/doc_parser.py b/backend/app/services/skill/doc_parser.py index e468cc1..e32f1f6 100644 --- a/backend/app/services/skill/doc_parser.py +++ b/backend/app/services/skill/doc_parser.py @@ -16,8 +16,9 @@ logger = get_logger("docparse") -ROOT = Path(__file__).resolve().parents[4] -GENERATED_DIR = ROOT / "static" / "generated" +from app.core.paths import generated_root + +GENERATED_DIR = generated_root() DEFAULT_JAR = ROOT / "demo-1.1-SNAPSHOT.jar" PARSEABLE_SUFFIX = { diff --git a/backend/app/services/skill/excel_engine.py b/backend/app/services/skill/excel_engine.py index 145ad53..80d4bba 100644 --- a/backend/app/services/skill/excel_engine.py +++ b/backend/app/services/skill/excel_engine.py @@ -11,8 +11,9 @@ logger = get_logger("excel") -ROOT = Path(__file__).resolve().parents[4] -GENERATED_DIR = ROOT / "static" / "generated" +from app.core.paths import generated_root + +GENERATED_DIR = generated_root() TABULAR_SUFFIX = {".xlsx", ".xls", ".csv", ".tsv"} _SQL_FORBIDDEN = re.compile( r"\b(insert|update|delete|drop|alter|attach|copy|pragma|install|export|create\s+or)\b", diff --git a/backend/app/services/skill/pdf_pipeline.py b/backend/app/services/skill/pdf_pipeline.py index 9fd56bf..fc37759 100644 --- a/backend/app/services/skill/pdf_pipeline.py +++ b/backend/app/services/skill/pdf_pipeline.py @@ -12,8 +12,9 @@ logger = get_logger("pdf") -ROOT = Path(__file__).resolve().parents[4] -GENERATED_DIR = ROOT / "static" / "generated" +from app.core.paths import generated_root + +GENERATED_DIR = generated_root() _WM_FONT: str | None = None _CJK_FONT_CANDIDATES = ( Path(r"C:\Windows\Fonts\msyh.ttc"), diff --git a/backend/app/services/skill/pdf_word.py b/backend/app/services/skill/pdf_word.py index 3ec10c3..82fd288 100644 --- a/backend/app/services/skill/pdf_word.py +++ b/backend/app/services/skill/pdf_word.py @@ -12,8 +12,9 @@ logger = get_logger("pdf-word") -ROOT = Path(__file__).resolve().parents[4] -GENERATED_DIR = ROOT / "static" / "generated" +from app.core.paths import generated_root + +GENERATED_DIR = generated_root() _IMG_RE = re.compile(r"!\[([^\]]*)\]\(([^)]+)\)") _HEADING_RE = re.compile(r"^(#{1,6})\s+(.*)$") diff --git a/backend/app/services/skill/ppt_pipeline.py b/backend/app/services/skill/ppt_pipeline.py index 0309fca..fe26234 100644 --- a/backend/app/services/skill/ppt_pipeline.py +++ b/backend/app/services/skill/ppt_pipeline.py @@ -11,8 +11,9 @@ logger = get_logger("ppt") -ROOT = Path(__file__).resolve().parents[4] -GENERATED_DIR = ROOT / "static" / "generated" +from app.core.paths import generated_root + +GENERATED_DIR = generated_root() PPT_FONT = "Microsoft YaHei" diff --git a/backend/app/services/skill/store.py b/backend/app/services/skill/store.py index db5a5d0..b6737ce 100644 --- a/backend/app/services/skill/store.py +++ b/backend/app/services/skill/store.py @@ -14,7 +14,8 @@ logger = get_logger("skillstore") -SKILLS_ROOT = Path(__file__).resolve().parents[4] / "skills" +from app.core.paths import skills_root as _skills_root_path + SKILL_FILENAME = "SKILL.md" _CACHE: dict[str, tuple[float, "FileSkill"]] = {} @@ -75,8 +76,10 @@ def scripts_dir(self) -> Path | None: def skills_root() -> Path: - SKILLS_ROOT.mkdir(parents=True, exist_ok=True) - return SKILLS_ROOT + root = _skills_root_path() + root.mkdir(parents=True, exist_ok=True) + return root + def slugify(name: str, *, fallback: str = "skill") -> str: diff --git a/backend/app/services/uploads.py b/backend/app/services/uploads.py index eaf3e53..973bfcb 100644 --- a/backend/app/services/uploads.py +++ b/backend/app/services/uploads.py @@ -7,8 +7,9 @@ from fastapi import UploadFile -ROOT = Path(__file__).resolve().parents[4] -UPLOAD_ROOT = ROOT / "uploads" +from app.core.paths import uploads_root + +UPLOAD_ROOT = uploads_root() ALLOWED_SUFFIX = { ".csv", ".json", diff --git a/docker-compose.yml b/docker-compose.yml index efd402a..0dfda7a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,5 +1,3 @@ -version: '3.8' - services: postgres: image: pgvector/pgvector:pg16 @@ -29,24 +27,34 @@ services: backend: build: - context: ./backend - dockerfile: Dockerfile + context: . + dockerfile: backend/Dockerfile environment: + AGENTFLOW_REPO_ROOT: /repo + AGENTFLOW_UPLOADS_DIR: /repo/uploads DATABASE_URL: postgresql+asyncpg://postgres:postgres@postgres:5432/agentflow REDIS_URL: redis://redis:6379/0 JWT_SECRET_KEY: ${JWT_SECRET_KEY:-change-me-in-production} - OPENAI_API_KEY: ${OPENAI_API_KEY} - ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY} - DEEPSEEK_API_KEY: ${DEEPSEEK_API_KEY} + OPENAI_API_KEY: ${OPENAI_API_KEY:-} + ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY:-} + DEEPSEEK_API_KEY: ${DEEPSEEK_API_KEY:-} + CHATZOC_API_KEY: ${CHATZOC_API_KEY:-} + CHATZOC_BASE_URL: ${CHATZOC_BASE_URL:-} + DASHSCOPE_API_KEY: ${DASHSCOPE_API_KEY:-} + MODEL_BASE_URL: ${MODEL_BASE_URL:-} + CORS_ORIGINS: http://localhost:3000,http://localhost:5173,http://localhost:3001 depends_on: postgres: condition: service_healthy redis: condition: service_healthy ports: - - "8000:8000" + - "8001:8000" volumes: - - ./uploads:/app/uploads + - ./skills:/repo/skills + - ./config:/repo/config + - ./static:/repo/static + - ./uploads:/repo/uploads frontend: build: @@ -55,19 +63,33 @@ services: depends_on: - backend ports: - - "3001:3000" + - "3000:3000" celery-worker: build: - context: ./backend - dockerfile: Dockerfile + context: . + dockerfile: backend/Dockerfile command: celery -A app.celery_app worker --loglevel=info environment: + AGENTFLOW_REPO_ROOT: /repo + AGENTFLOW_UPLOADS_DIR: /repo/uploads DATABASE_URL: postgresql+asyncpg://postgres:postgres@postgres:5432/agentflow REDIS_URL: redis://redis:6379/0 + OPENAI_API_KEY: ${OPENAI_API_KEY:-} + ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY:-} + DEEPSEEK_API_KEY: ${DEEPSEEK_API_KEY:-} + CHATZOC_API_KEY: ${CHATZOC_API_KEY:-} + CHATZOC_BASE_URL: ${CHATZOC_BASE_URL:-} + DASHSCOPE_API_KEY: ${DASHSCOPE_API_KEY:-} + MODEL_BASE_URL: ${MODEL_BASE_URL:-} depends_on: - postgres - redis + volumes: + - ./skills:/repo/skills + - ./config:/repo/config + - ./static:/repo/static + - ./uploads:/repo/uploads volumes: postgres_data: diff --git a/frontend/nginx.conf b/frontend/nginx.conf index eb151d4..db1fe74 100644 --- a/frontend/nginx.conf +++ b/frontend/nginx.conf @@ -5,6 +5,8 @@ server { root /usr/share/nginx/html; index index.html; + client_max_body_size 50m; + location / { try_files $uri $uri/ /index.html; } @@ -14,4 +16,18 @@ server { proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } + + location /static { + proxy_pass http://backend:8000; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + } + + location /ws { + proxy_pass http://backend:8000; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + } } diff --git a/frontend/src/Router.tsx b/frontend/src/Router.tsx index 586b85e..6ae3201 100644 --- a/frontend/src/Router.tsx +++ b/frontend/src/Router.tsx @@ -15,6 +15,8 @@ import HelpPage from './pages/HelpPage' import KnowledgePage from './pages/KnowledgePage' import WorkflowPage from './pages/WorkflowPage' import TemplateMarket from './pages/TemplateMarket' +import SkillPlazaPage from './pages/SkillPlazaPage' +import SkillChatPage from './pages/SkillChatPage' // Pages wrapper components function LandingPage() { @@ -45,9 +47,13 @@ export default function Router() { } /> } /> } /> + } /> } /> } /> + {/* Skill Chat - Full Screen */} + } /> + {/* Chat Page - Full Screen */} } /> diff --git a/frontend/src/components/layout/Sidebar.tsx b/frontend/src/components/layout/Sidebar.tsx index 17ae3ef..05ef776 100644 --- a/frontend/src/components/layout/Sidebar.tsx +++ b/frontend/src/components/layout/Sidebar.tsx @@ -22,6 +22,7 @@ import { Button } from '../ui/Button' const navItems = [ { icon: LayoutDashboard, label: '控制台', href: '/dashboard' }, { icon: MessageSquare, label: 'Agent', href: '/agents' }, + { icon: Sparkles, label: 'Skill 广场', href: '/skills' }, { icon: Workflow, label: '工作流', href: '/workflow/new' }, { icon: FileText, label: '知识库', href: '/knowledge' }, ] diff --git a/frontend/src/pages/SkillChatPage.tsx b/frontend/src/pages/SkillChatPage.tsx new file mode 100644 index 0000000..2503b59 --- /dev/null +++ b/frontend/src/pages/SkillChatPage.tsx @@ -0,0 +1,274 @@ +import { useEffect, useRef, useState } from 'react' +import { useNavigate, useParams } from 'react-router-dom' +import { + ArrowLeft, + Send, + Bot, + User, + Paperclip, + Loader2, + Download, + X, +} from 'lucide-react' +import { skills as skillsApi, type SkillCallResponse } from '../services/api' +import { Button } from '../components/ui/Button' +import { Card } from '../components/ui/Card' + +interface Message { + id: string + role: 'user' | 'assistant' + content: string +} + +interface SkillInfo { + id: string + name: string + description: string + specialty?: string +} + +function renderMarkdownLinks(text: string) { + const urlRe = /(https?:\/\/[^\s)]+|\/static\/[^\s)]+)/g + const parts = text.split(urlRe) + return parts.map((part, i) => { + if (urlRe.test(part) || part.startsWith('/static/')) { + return ( + + {part} + + ) + } + return {part} + }) +} + +export default function SkillChatPage() { + const { id } = useParams<{ id: string }>() + const navigate = useNavigate() + const messagesEndRef = useRef(null) + const fileInputRef = useRef(null) + + const [skill, setSkill] = useState(null) + const [messages, setMessages] = useState([]) + const [input, setInput] = useState('') + const [files, setFiles] = useState([]) + const [loading, setLoading] = useState(false) + const [sessionId, setSessionId] = useState() + const [downloads, setDownloads] = useState([]) + + useEffect(() => { + if (!id) return + skillsApi + .get(id) + .then((data) => + setSkill({ + id: data.id, + name: data.name, + description: data.description || '', + specialty: data.specialty, + }) + ) + .catch(() => navigate('/skills')) + }, [id, navigate]) + + useEffect(() => { + messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }) + }, [messages, loading]) + + const collectDownloads = (res: SkillCallResponse) => { + const urls: string[] = [] + const trace = res.toolTrace + if (trace?.downloadUrl) urls.push(trace.downloadUrl) + if (trace?.downloadUrls) urls.push(...trace.downloadUrls) + if (trace?.imageUrl) urls.push(trace.imageUrl) + setDownloads(Array.from(new Set(urls))) + } + + const sendMessage = async () => { + if (!id || (!input.trim() && files.length === 0) || loading) return + + const userText = input.trim() || '请处理上传的文件' + setMessages((prev) => [ + ...prev, + { id: `u-${Date.now()}`, role: 'user', content: userText }, + ]) + setInput('') + setLoading(true) + + try { + const res = + files.length > 0 + ? await skillsApi.callWithFiles(id, userText, files, sessionId) + : await skillsApi.call(id, { input: userText }, sessionId) + + if (res.sessionId) setSessionId(res.sessionId) + collectDownloads(res) + setMessages((prev) => [ + ...prev, + { + id: `a-${Date.now()}`, + role: 'assistant', + content: res.output || '(无回复)', + }, + ]) + setFiles([]) + } catch (e: any) { + setMessages((prev) => [ + ...prev, + { + id: `e-${Date.now()}`, + role: 'assistant', + content: `调用失败:${e?.message || '未知错误'}`, + }, + ]) + } finally { + setLoading(false) + } + } + + return ( +
+
+ +
+

+ {skill?.name || 'Skill 对话'} +

+ {skill?.specialty && ( +

{skill.specialty}

+ )} +
+
+ +
+ {skill?.description && messages.length === 0 && ( + + {skill.description} + + )} + + {messages.map((m) => ( +
+ {m.role === 'assistant' && ( +
+ +
+ )} +
+ {m.role === 'assistant' ? renderMarkdownLinks(m.content) : m.content} +
+ {m.role === 'user' && ( +
+ +
+ )} +
+ ))} + + {loading && ( +
+ + Skill 正在处理... +
+ )} +
+
+ + {downloads.length > 0 && ( +
+ + 生成文件: + {downloads.map((url) => ( + + + {url.split('/').pop()} + + ))} + +
+ )} + + {files.length > 0 && ( +
+ {files.map((f) => ( + + {f.name} + + + ))} +
+ )} + +
+
+ { + const picked = Array.from(e.target.files || []) + if (picked.length) setFiles((prev) => [...prev, ...picked]) + e.target.value = '' + }} + /> + +