From c45936752e57f30667fb72731e11edc6dec5e87e Mon Sep 17 00:00:00 2001 From: 23kaka6 <121601339+23kaka6@users.noreply.github.com> Date: Wed, 26 Aug 2026 17:08:58 +0800 Subject: [PATCH 1/2] feat: internal-ready AgentFlow v2.0.2 with auth, Docker paths, and tests --- LICENSE | 21 ++++ README.md | 14 ++- backend/app/api/agent_routes.py | 107 ++++++++++------- backend/app/api/knowledge.py | 11 +- backend/app/api/workflow.py | 3 +- backend/app/config.py | 27 ++--- backend/app/core/config.py | 22 +++- backend/app/main.py | 7 +- .../skill/academic_search_pipeline.py | 4 +- backend/app/services/skill/excel_engine.py | 25 +++- backend/app/services/skill/pdf_pipeline.py | 1 + backend/app/services/skill/ppt_html.py | 9 +- backend/app/services/skill/ppt_wizard.py | 6 +- backend/app/services/skill/stat_pipeline.py | 5 +- backend/app/services/skill/store.py | 10 +- backend/app/services/skill_service.py | 3 +- backend/requirements.txt | 1 + backend/tests/test_knowledge.py | 7 +- backend/tests/test_paths.py | 22 ++++ backend/tests/test_skills.py | 68 +++++++++++ frontend/package.json | 2 +- frontend/src/Router.tsx | 33 +++-- .../src/components/auth/ProtectedRoute.tsx | 14 +++ frontend/src/components/layout/Sidebar.tsx | 21 +++- frontend/src/pages/KnowledgePage.tsx | 22 +++- frontend/src/pages/LoginPage.tsx | 2 +- frontend/src/pages/SkillChatPage.tsx | 28 ++++- frontend/src/pages/SkillPlazaPage.tsx | 13 +- frontend/src/pages/WorkflowPage.tsx | 6 +- frontend/src/services/api.ts | 25 +++- skills/skill-1934395/SKILL.md | 113 ------------------ 31 files changed, 416 insertions(+), 236 deletions(-) create mode 100644 LICENSE create mode 100644 backend/tests/test_paths.py create mode 100644 backend/tests/test_skills.py create mode 100644 frontend/src/components/auth/ProtectedRoute.tsx delete mode 100644 skills/skill-1934395/SKILL.md diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..bf5aa29 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 AgentFlow Contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index bfdec41..e9dc3b9 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@
-![AgentFlow](https://img.shields.io/badge/AgentFlow-v2.0.1-6366F1?style=for-the-badge&logo=robot&logoColor=white) +![AgentFlow](https://img.shields.io/badge/AgentFlow-v2.0.2-6366F1?style=for-the-badge&logo=robot&logoColor=white) [![Python](https://img.shields.io/badge/Python-3.11+-3776AB.svg?style=flat-square&logo=python&logoColor=white)](https://python.org) [![FastAPI](https://img.shields.io/badge/FastAPI-0.109-009688.svg?style=flat-square&logo=fastapi&logoColor=white)](https://fastapi.tiangolo.com) [![React](https://img.shields.io/badge/React-18-61DAFB.svg?style=flat-square&logo=react&logoColor=white)](https://react.dev) @@ -127,16 +127,20 @@ Skill 运行时支持 OpenAI 兼容网关,可在 `.env` 中配置 `CHATZOC_*` | Redis | 7+ | | Docker(推荐) | 20+ | -### Docker 一键部署(推荐) +### Docker 一键部署(推荐,内网演示) ```bash git clone https://github.com/PHclaw/agentflow.git cd agentflow -cp .env.example .env # 编辑填入 API Keys -docker-compose up -d -open http://localhost:3000 +cp .env.example .env # 必填:JWT_SECRET_KEY、OPENAI_API_KEY(或 CHATZOC_* / DASHSCOPE_*) +docker compose up --build +# 前端 http://localhost:3000 后端 http://localhost:8001 ``` +**内网可用流程**:注册账号 → 登录(自动进入 `/skills`)→ 选择 Skill → 上传/粘贴内容 → 下载 `/static/generated/*` 生成物。 + +> **规划中(当前版本未完整实现)**:Stripe 订阅计费、Google OAuth、Browser-Use、Ollama 本地模型。 + ### 本地开发 ```bash diff --git a/backend/app/api/agent_routes.py b/backend/app/api/agent_routes.py index bf43f72..76ef947 100644 --- a/backend/app/api/agent_routes.py +++ b/backend/app/api/agent_routes.py @@ -11,10 +11,19 @@ from app.core.database import get_db from app.api.auth import get_current_user_id -router = APIRouter() +router = APIRouter(dependencies=[Depends(get_current_user_id)]) logger = get_logger("agent_routes") +async def _require_agent(agent_id: str, user_id: str, db: AsyncSession) -> Agent: + agent = await db.get(Agent, agent_id) + if not agent: + raise HTTPException(status_code=404, detail="Agent 不存在") + if agent.user_id != user_id: + raise HTTPException(status_code=403, detail="无权访问此 Agent") + return agent + + # ===== 请求/响应模型 ===== class AgentStatsResponse(BaseModel): @@ -50,11 +59,13 @@ class AgentExportResponse(BaseModel): # ===== Agent 统计 API ===== @router.get("/{agent_id}/stats", response_model=AgentStatsResponse) -async def get_agent_stats(agent_id: str, db: AsyncSession = Depends(get_db)): +async def get_agent_stats( + agent_id: str, + db: AsyncSession = Depends(get_db), + user_id: str = Depends(get_current_user_id), +): """获取 Agent 使用统计""" - agent = await db.get(Agent, agent_id) - if not agent: - raise HTTPException(status_code=404, detail="Agent 不存在") + agent = await _require_agent(agent_id, user_id, db) # 查询会话统计 stats = await db.execute( @@ -91,11 +102,13 @@ async def get_agent_stats(agent_id: str, db: AsyncSession = Depends(get_db)): @router.get("/{agent_id}/versions", response_model=AgentVersionResponse) -async def get_agent_versions(agent_id: str, db: AsyncSession = Depends(get_db)): +async def get_agent_versions( + agent_id: str, + db: AsyncSession = Depends(get_db), + user_id: str = Depends(get_current_user_id), +): """获取 Agent 版本历史""" - agent = await db.get(Agent, agent_id) - if not agent: - raise HTTPException(status_code=404, detail="Agent 不存在") + agent = await _require_agent(agent_id, user_id, db) # 从 settings.versions 读取版本历史 agent_settings = agent.settings or {} @@ -124,12 +137,13 @@ async def get_agent_versions(agent_id: str, db: AsyncSession = Depends(get_db)): @router.post("/{agent_id}/versions/{version}/rollback") async def rollback_agent_version( - agent_id: str, version: str, db: AsyncSession = Depends(get_db) + agent_id: str, + version: str, + db: AsyncSession = Depends(get_db), + user_id: str = Depends(get_current_user_id), ): """回滚到指定版本""" - agent = await db.get(Agent, agent_id) - if not agent: - raise HTTPException(status_code=404, detail="Agent 不存在") + agent = await _require_agent(agent_id, user_id, db) agent_settings = agent.settings or {} versions_data = agent_settings.get("versions", []) @@ -168,15 +182,16 @@ async def rollback_agent_version( @router.post("/{agent_id}/duplicate") async def duplicate_agent( - agent_id: str, request: AgentDuplicateRequest, db: AsyncSession = Depends(get_db) + agent_id: str, + request: AgentDuplicateRequest, + db: AsyncSession = Depends(get_db), + user_id: str = Depends(get_current_user_id), ): """复制 Agent""" - original = await db.get(Agent, agent_id) - if not original: - raise HTTPException(status_code=404, detail="Agent 不存在") + original = await _require_agent(agent_id, user_id, db) new_agent = Agent( - user_id=original.user_id, + user_id=user_id, name=request.name, description=request.description or f"{request.name} 的副本", workflow_definition=original.workflow_definition, @@ -203,11 +218,10 @@ async def export_agent( agent_id: str, include_knowledge: bool = True, db: AsyncSession = Depends(get_db), + user_id: str = Depends(get_current_user_id), ): """导出 Agent 配置""" - agent = await db.get(Agent, agent_id) - if not agent: - raise HTTPException(status_code=404, detail="Agent 不存在") + agent = await _require_agent(agent_id, user_id, db) export_data = { "agent_id": agent.id, @@ -241,13 +255,12 @@ async def import_agent( agent_id: str, data: dict, db: AsyncSession = Depends(get_db), + user_id: str = Depends(get_current_user_id), ): """导入 Agent 配置""" logger.info(f"Importing agent data for {agent_id}") - agent = await db.get(Agent, agent_id) - if not agent: - raise HTTPException(status_code=404, detail="Agent 不存在") + agent = await _require_agent(agent_id, user_id, db) config = data.get("config", {}) agent.name = config.get("name", agent.name) @@ -268,11 +281,13 @@ async def import_agent( @router.get("/{agent_id}/health") -async def check_agent_health(agent_id: str, db: AsyncSession = Depends(get_db)): +async def check_agent_health( + agent_id: str, + db: AsyncSession = Depends(get_db), + user_id: str = Depends(get_current_user_id), +): """检查 Agent 健康状态""" - agent = await db.get(Agent, agent_id) - if not agent: - raise HTTPException(status_code=404, detail="Agent 不存在") + agent = await _require_agent(agent_id, user_id, db) is_valid = bool(agent.workflow_definition and agent.workflow_definition.get("nodes")) @@ -290,11 +305,13 @@ async def check_agent_health(agent_id: str, db: AsyncSession = Depends(get_db)): @router.post("/{agent_id}/validate") -async def validate_agent(agent_id: str, db: AsyncSession = Depends(get_db)): +async def validate_agent( + agent_id: str, + db: AsyncSession = Depends(get_db), + user_id: str = Depends(get_current_user_id), +): """验证 Agent 配置""" - agent = await db.get(Agent, agent_id) - if not agent: - raise HTTPException(status_code=404, detail="Agent 不存在") + agent = await _require_agent(agent_id, user_id, db) issues = [] warnings = [] @@ -332,13 +349,13 @@ async def validate_agent(agent_id: str, db: AsyncSession = Depends(get_db)): @router.get("/{agent_id}/performance/tokens") async def get_token_usage( - agent_id: str, days: int = Query(default=7, ge=1, le=90), + agent_id: str, + days: int = Query(default=7, ge=1, le=90), db: AsyncSession = Depends(get_db), + user_id: str = Depends(get_current_user_id), ): """获取 Token 使用统计""" - agent = await db.get(Agent, agent_id) - if not agent: - raise HTTPException(status_code=404, detail="Agent 不存在") + agent = await _require_agent(agent_id, user_id, db) total_msgs = agent.message_count or 0 avg_tokens_per_msg = 400 @@ -366,11 +383,13 @@ async def get_token_usage( @router.get("/{agent_id}/performance/latency") -async def get_latency_stats(agent_id: str, db: AsyncSession = Depends(get_db)): +async def get_latency_stats( + agent_id: str, + db: AsyncSession = Depends(get_db), + user_id: str = Depends(get_current_user_id), +): """获取响应延迟统计""" - agent = await db.get(Agent, agent_id) - if not agent: - raise HTTPException(status_code=404, detail="Agent 不存在") + agent = await _require_agent(agent_id, user_id, db) # 从 settings 读取真实延迟数据 agent_settings = agent.settings or {} @@ -392,13 +411,13 @@ async def get_latency_stats(agent_id: str, db: AsyncSession = Depends(get_db)): @router.get("/{agent_id}/performance/errors") async def get_error_stats( - agent_id: str, days: int = Query(default=7, ge=1, le=90), + agent_id: str, + days: int = Query(default=7, ge=1, le=90), db: AsyncSession = Depends(get_db), + user_id: str = Depends(get_current_user_id), ): """获取错误统计""" - agent = await db.get(Agent, agent_id) - if not agent: - raise HTTPException(status_code=404, detail="Agent 不存在") + agent = await _require_agent(agent_id, user_id, db) agent_settings = agent.settings or {} error_stats = agent_settings.get("errors", {}) diff --git a/backend/app/api/knowledge.py b/backend/app/api/knowledge.py index 185d819..f499eb5 100644 --- a/backend/app/api/knowledge.py +++ b/backend/app/api/knowledge.py @@ -13,14 +13,19 @@ from ..core.config import settings from ..core.database import get_db +from ..core.paths import uploads_root from ..models.agent import KnowledgeBase from ..models.document import Document from ..services.knowledge import KnowledgeService +from app.api.auth import get_current_user_id -router = APIRouter(prefix="/api/v1/knowledge", tags=["知识库"]) +router = APIRouter( + prefix="/api/v1/knowledge", + tags=["知识库"], + dependencies=[Depends(get_current_user_id)], +) -UPLOAD_DIR = Path(settings.UPLOAD_DIR) -UPLOAD_DIR.mkdir(exist_ok=True) +UPLOAD_DIR = uploads_root() class CreateKBRequest(BaseModel): diff --git a/backend/app/api/workflow.py b/backend/app/api/workflow.py index f0c9aca..3051eac 100644 --- a/backend/app/api/workflow.py +++ b/backend/app/api/workflow.py @@ -11,8 +11,9 @@ from ..core.database import get_db from ..services.workflow_runtime import workflow_runtime, NodeStatus +from app.api.auth import get_current_user_id -router = APIRouter() +router = APIRouter(dependencies=[Depends(get_current_user_id)]) class WorkflowExecutionRequest(BaseModel): diff --git a/backend/app/config.py b/backend/app/config.py index 400199c..6dded89 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -37,22 +37,17 @@ class Settings(BaseSettings): @lru_cache def get_settings() -> Settings: - s = Settings() - if not s.dashscope_api_key and core.OPENAI_API_KEY: - s.dashscope_api_key = core.OPENAI_API_KEY - if not s.model_base_url and core.DEEPSEEK_API_KEY: - s.model_base_url = core.DEEPSEEK_BASE_URL - if not s.chatzoc_api_key: - s.chatzoc_api_key = s.dashscope_api_key - if not s.chatzoc_base_url and s.model_base_url: - s.chatzoc_base_url = s.model_base_url - if not s.default_model or s.default_model == "chatzoc_9b_B": - if core.OPENAI_API_KEY: - s.default_model = core.OPENAI_MODEL or "gpt-4o-mini" - if not s.optimize_model or s.optimize_model == "chatzoc_9b_B": - s.optimize_model = s.default_model - s.log_level = core.LOG_LEVEL or s.log_level - return s + return Settings( + dashscope_api_key=core.DASHSCOPE_API_KEY or core.OPENAI_API_KEY, + model_base_url=core.MODEL_BASE_URL or core.DEEPSEEK_BASE_URL, + chatzoc_api_key=core.CHATZOC_API_KEY or core.DASHSCOPE_API_KEY or core.OPENAI_API_KEY, + chatzoc_base_url=core.CHATZOC_BASE_URL or core.MODEL_BASE_URL or core.DEEPSEEK_BASE_URL, + chatzoc_agent_type=core.CHATZOC_AGENT_TYPE, + default_model=core.DEFAULT_MODEL or core.OPENAI_MODEL or "gpt-4o-mini", + optimize_model=core.OPTIMIZE_MODEL or core.DEFAULT_MODEL or core.OPENAI_MODEL or "gpt-4o-mini", + log_level=core.LOG_LEVEL, + semantic_scholar_api_key=core.SEMANTIC_SCHOLAR_API_KEY, + ) def reload_settings() -> Settings: diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 36fc57a..a1ddd88 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -1,8 +1,9 @@ """ 核心配置 """ +from pydantic import field_validator from pydantic_settings import BaseSettings -from typing import List +from typing import Any, List from functools import lru_cache @@ -29,12 +30,29 @@ class Settings(BaseSettings): ANTHROPIC_MODEL: str = "claude-3-haiku" DEEPSEEK_API_KEY: str = "" DEEPSEEK_BASE_URL: str = "https://api.deepseek.com" + + # Skill 运行时(Model Lab 兼容) + DASHSCOPE_API_KEY: str = "" + MODEL_BASE_URL: str = "" + CHATZOC_API_KEY: str = "" + CHATZOC_BASE_URL: str = "" + CHATZOC_AGENT_TYPE: str = "chatzoc_9b_B" + DEFAULT_MODEL: str = "" + OPTIMIZE_MODEL: str = "" + SEMANTIC_SCHOLAR_API_KEY: str = "" # Embedding EMBEDDING_MODEL: str = "sentence-transformers/all-MiniLM-L6-v2" - # CORS + # CORS(支持逗号分隔字符串,便于 docker-compose 注入) CORS_ORIGINS: List[str] = ["http://localhost:3000", "http://localhost:5173"] + + @field_validator("CORS_ORIGINS", mode="before") + @classmethod + def parse_cors_origins(cls, v: Any) -> list[str]: + if isinstance(v, str): + return [x.strip() for x in v.split(",") if x.strip()] + return v # 文件存储 UPLOAD_DIR: str = "./uploads" diff --git a/backend/app/main.py b/backend/app/main.py index 2623635..7d25407 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -3,6 +3,7 @@ 生产级架构,支持 WebSocket、缓存、监控、日志 """ from app.core.paths import generated_root, static_root +from fastapi import FastAPI, Request from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import JSONResponse from fastapi.staticfiles import StaticFiles @@ -50,7 +51,7 @@ async def lifespan(app: FastAPI): app = FastAPI( title="AgentFlow API", description="🚀 AI Agent 部署平台 | 可视化工作流 | RAG 知识库 | 多模型集成", - version="2.1.0", + version="2.0.2", lifespan=lifespan, docs_url="/docs", redoc_url="/redoc", @@ -130,7 +131,7 @@ async def root(): """根路径""" return { "name": "AgentFlow API", - "version": "2.1.0", + "version": "2.0.2", "docs": "/docs", "health": "/api/v1/monitoring/health", "features": [ @@ -150,7 +151,7 @@ async def health(): return { "status": "healthy", "service": "AgentFlow", - "version": "2.1.0", + "version": "2.0.2", "timestamp": time.time() } diff --git a/backend/app/services/skill/academic_search_pipeline.py b/backend/app/services/skill/academic_search_pipeline.py index 4ac01c5..94fab0c 100644 --- a/backend/app/services/skill/academic_search_pipeline.py +++ b/backend/app/services/skill/academic_search_pipeline.py @@ -15,11 +15,11 @@ import httpx from app.logging_setup import get_logger +from app.core.paths import generated_root logger = get_logger("academic") -ROOT = Path(__file__).resolve().parents[4] -SEARCH_STATE_DIR = ROOT / "static" / "generated" / "academic-search" +SEARCH_STATE_DIR = generated_root() / "academic-search" ATOM = "{http://www.w3.org/2005/Atom}" ARXIV_NS = "{http://arxiv.org/schemas/atom}" diff --git a/backend/app/services/skill/excel_engine.py b/backend/app/services/skill/excel_engine.py index 80d4bba..9d4da08 100644 --- a/backend/app/services/skill/excel_engine.py +++ b/backend/app/services/skill/excel_engine.py @@ -19,7 +19,18 @@ r"\b(insert|update|delete|drop|alter|attach|copy|pragma|install|export|create\s+or)\b", re.I, ) -_CJK_FONT = Path(r"C:\Windows\Fonts\msyh.ttc") +_CJK_FONT_CANDIDATES = ( + Path(r"C:\Windows\Fonts\msyh.ttc"), + Path("/usr/share/fonts/truetype/wqy/wqy-zenhei.ttc"), + Path("/usr/share/fonts/truetype/noto/NotoSansCJK-Regular.ttc"), +) + + +def _resolve_cjk_font() -> Path | None: + for candidate in _CJK_FONT_CANDIDATES: + if candidate.is_file(): + return candidate + return None def _public_url(name: str) -> str: @@ -1429,8 +1440,9 @@ def wants_split_series(text: str) -> bool: def _cjk_fontprops(): from matplotlib.font_manager import FontProperties - if _CJK_FONT.is_file(): - return FontProperties(fname=str(_CJK_FONT)) + font = _resolve_cjk_font() + if font is not None: + return FontProperties(fname=str(font)) return None @@ -1716,12 +1728,13 @@ def _setup_matplotlib() -> None: matplotlib.use("Agg") import matplotlib.pyplot as plt - if _CJK_FONT.is_file(): + font = _resolve_cjk_font() + if font is not None: try: from matplotlib import font_manager - font_manager.fontManager.addfont(str(_CJK_FONT)) - plt.rcParams["font.sans-serif"] = ["Microsoft YaHei", "SimHei", "Arial"] + font_manager.fontManager.addfont(str(font)) + plt.rcParams["font.sans-serif"] = ["Microsoft YaHei", "WenQuanYi Zen Hei", "SimHei", "Arial"] except Exception: # noqa: BLE001 plt.rcParams["font.sans-serif"] = ["SimHei", "Arial"] plt.rcParams["axes.unicode_minus"] = False diff --git a/backend/app/services/skill/pdf_pipeline.py b/backend/app/services/skill/pdf_pipeline.py index fc37759..2e942b7 100644 --- a/backend/app/services/skill/pdf_pipeline.py +++ b/backend/app/services/skill/pdf_pipeline.py @@ -21,6 +21,7 @@ Path(r"C:\Windows\Fonts\msyhbd.ttc"), Path(r"C:\Windows\Fonts\simhei.ttf"), Path(r"C:\Windows\Fonts\simsun.ttc"), + Path("/usr/share/fonts/truetype/wqy/wqy-zenhei.ttc"), Path("/usr/share/fonts/truetype/noto/NotoSansCJK-Regular.ttc"), ) diff --git a/backend/app/services/skill/ppt_html.py b/backend/app/services/skill/ppt_html.py index 767952e..85b3669 100644 --- a/backend/app/services/skill/ppt_html.py +++ b/backend/app/services/skill/ppt_html.py @@ -8,13 +8,14 @@ from typing import Any from app.logging_setup import get_logger +from app.core.paths import generated_root, repo_root, skills_root, static_root logger = get_logger("ppt.html") -ROOT = Path(__file__).resolve().parents[4] -SRC_ASSETS = ROOT / "skills" / "html-ppt-skill-main" / "assets" -STATIC_ASSETS = ROOT / "static" / "html-ppt" -GENERATED_DIR = ROOT / "static" / "generated" +ROOT = repo_root() +SRC_ASSETS = skills_root() / "html-ppt-skill-main" / "assets" +STATIC_ASSETS = static_root() / "html-ppt" +GENERATED_DIR = generated_root() THEME_ALIAS = { "keynote": "corporate-clean", diff --git a/backend/app/services/skill/ppt_wizard.py b/backend/app/services/skill/ppt_wizard.py index 72baa73..10935a7 100644 --- a/backend/app/services/skill/ppt_wizard.py +++ b/backend/app/services/skill/ppt_wizard.py @@ -13,10 +13,10 @@ resolve_theme, write_html, ) +from app.core.paths import generated_root -ROOT = Path(__file__).resolve().parents[4] -WIZARD_DIR = ROOT / "static" / "generated" / "ppt-wizard" -GENERATED_DIR = ROOT / "static" / "generated" +WIZARD_DIR = generated_root() / "ppt-wizard" +GENERATED_DIR = generated_root() SKIP_WIZARD_RE = re.compile(r"(直接生成|直接出稿|跳过问卷|不用问|马上生成|立刻生成)") NEW_DECK_RE = re.compile(r"(生成|做|制作|创建).{0,12}(ppt|pptx|演示|幻灯片)", re.I) diff --git a/backend/app/services/skill/stat_pipeline.py b/backend/app/services/skill/stat_pipeline.py index e86b914..ec58eb3 100644 --- a/backend/app/services/skill/stat_pipeline.py +++ b/backend/app/services/skill/stat_pipeline.py @@ -9,12 +9,11 @@ from typing import Any from app.logging_setup import get_logger +from app.core.paths import generated_root, skills_root logger = get_logger("stat") -SCRIPTS_DIR = ( - Path(__file__).resolve().parents[4] / "skills" / "statistical-analyst" / "scripts" -) +SCRIPTS_DIR = skills_root() / "statistical-analyst" / "scripts" WHITELIST = { "hypothesis": "hypothesis_tester.py", "sample_size": "sample_size_calculator.py", diff --git a/backend/app/services/skill/store.py b/backend/app/services/skill/store.py index b6737ce..d9f26a6 100644 --- a/backend/app/services/skill/store.py +++ b/backend/app/services/skill/store.py @@ -255,10 +255,18 @@ def invalidate_cache(slug: str | None = None) -> None: _CACHE.pop(slug, None) +def _is_valid_skill_id(sid: str) -> bool: + if not sid: + return False + if ".." in sid or "/" in sid or "\\" in sid or "\x00" in sid: + return False + return True + + def get_skill(skill_id: str) -> FileSkill | None: """Load by folder slug (= id).""" sid = (skill_id or "").strip() - if not sid: + if not _is_valid_skill_id(sid): return None folder = skills_root() / sid md_path = folder / SKILL_FILENAME diff --git a/backend/app/services/skill_service.py b/backend/app/services/skill_service.py index d934953..3442ec0 100644 --- a/backend/app/services/skill_service.py +++ b/backend/app/services/skill_service.py @@ -841,9 +841,8 @@ async def call_agent( wall_ms = int((time.perf_counter() - started) * 1000) result.latency_ms = wall_ms - # 调用计数写回 SKILL.md;usage 可选记库(无 FK) + # 内网演示:不在每次调用时写回 SKILL.md,避免 git 脏文件 agent.total_calls = int(agent.total_calls or 0) + 1 - save_skill(agent) logger.info( "call skill=%s skillId=%s specialty=%s version=%s model=%s caller=%s " "latencyMs=%s tokensIn=%s tokensOut=%s outChars=%s sysChars=%s userChars=%s", diff --git a/backend/requirements.txt b/backend/requirements.txt index 3bc46d5..b7eb4ec 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -84,4 +84,5 @@ browser-use==0.1.11 pytest==8.0.0 pytest-asyncio==0.23.3 pytest-cov==4.1.0 +pytest-timeout==2.2.0 aiosqlite==0.19.0 diff --git a/backend/tests/test_knowledge.py b/backend/tests/test_knowledge.py index c14d3d7..cb60b45 100644 --- a/backend/tests/test_knowledge.py +++ b/backend/tests/test_knowledge.py @@ -24,7 +24,9 @@ async def test_list_knowledge_bases_empty( ) assert response.status_code == 200 data = response.json() - assert isinstance(data, list) + assert isinstance(data, dict) + assert "knowledge_bases" in data + assert isinstance(data["knowledge_bases"], list) @pytest.mark.asyncio async def test_create_knowledge_base( @@ -111,5 +113,4 @@ async def test_unauthorized_access( ): """测试未授权访问""" response = await async_client.get("/api/v1/knowledge") - # knowledge 可能不需要认证,检查返回格式 - assert response.status_code in [200, 401] + assert response.status_code == 401 diff --git a/backend/tests/test_paths.py b/backend/tests/test_paths.py new file mode 100644 index 0000000..79a3beb --- /dev/null +++ b/backend/tests/test_paths.py @@ -0,0 +1,22 @@ +"""Tests for repository path helpers.""" +import os +from pathlib import Path + +import pytest + +from app.core import paths + + +def test_repo_root_from_env(monkeypatch: pytest.MonkeyPatch, tmp_path: Path): + repo = tmp_path / "repo" + (repo / "skills").mkdir(parents=True) + monkeypatch.setenv("AGENTFLOW_REPO_ROOT", str(repo)) + paths.repo_root.cache_clear() + try: + assert paths.repo_root() == repo.resolve() + assert paths.skills_root() == repo / "skills" + assert paths.generated_root() == repo / "static" / "generated" + assert paths.uploads_root() == repo / "uploads" + finally: + paths.repo_root.cache_clear() + monkeypatch.delenv("AGENTFLOW_REPO_ROOT", raising=False) diff --git a/backend/tests/test_skills.py b/backend/tests/test_skills.py new file mode 100644 index 0000000..d45d656 --- /dev/null +++ b/backend/tests/test_skills.py @@ -0,0 +1,68 @@ +"""Skill API smoke tests.""" +from unittest.mock import AsyncMock, patch + +import pytest +from httpx import AsyncClient + + +class TestSkillsAPI: + @pytest.mark.asyncio + async def test_plaza_unauthorized(self, async_client: AsyncClient): + response = await async_client.get("/api/v1/skills/plaza") + assert response.status_code == 401 + + @pytest.mark.asyncio + async def test_plaza_returns_curated_skills( + self, + async_client: AsyncClient, + auth_headers: dict, + ): + response = await async_client.get( + "/api/v1/skills/plaza", + headers=auth_headers, + ) + assert response.status_code == 200 + data = response.json() + assert isinstance(data, list) + assert len(data) == 8 + assert all(item.get("id") for item in data) + + @pytest.mark.asyncio + async def test_get_skill_rejects_invalid_slug( + self, + async_client: AsyncClient, + auth_headers: dict, + ): + response = await async_client.get( + "/api/v1/skills/../etc/passwd", + headers=auth_headers, + ) + assert response.status_code in (404, 422) + + @pytest.mark.asyncio + async def test_call_intro_without_tools( + self, + async_client: AsyncClient, + auth_headers: dict, + ): + from app.schemas import AgentCallResponse + + mock_response = AgentCallResponse( + output="我是会议纪要助手,可以帮你整理会议要点。", + latency_ms=42, + agent_version="v1.0.0", + tool_trace={"intent": "intro"}, + ) + with patch( + "app.api.skills.skill_service.call_agent", + new=AsyncMock(return_value=mock_response), + ): + response = await async_client.post( + "/api/v1/skills/meeting-minutes/call", + headers=auth_headers, + json={"variables": {"input": "你能做什么?"}}, + ) + assert response.status_code == 200 + body = response.json() + assert body.get("output") + assert body.get("toolTrace", {}).get("intent") == "intro" diff --git a/frontend/package.json b/frontend/package.json index 85cb1f0..72d3323 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "agentflow-frontend", - "version": "1.0.0", + "version": "2.0.2", "scripts": { "dev": "vite", "build": "tsc && vite build", diff --git a/frontend/src/Router.tsx b/frontend/src/Router.tsx index 6ae3201..7bed4fe 100644 --- a/frontend/src/Router.tsx +++ b/frontend/src/Router.tsx @@ -1,6 +1,7 @@ import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom' import { Toast } from './components/ui/Toast' import { AppLayout, AuthLayout } from './components/layout' +import { ProtectedRoute } from './components/auth/ProtectedRoute' // Pages import HomePage from './pages/HomePage' @@ -27,6 +28,14 @@ function DashboardLayout({ children }: { children: React.ReactNode }) { return {children} } +function ProtectedDashboard({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ) +} + export default function Router() { return ( @@ -41,24 +50,24 @@ export default function Router() { } /> {/* Protected Routes with Sidebar */} - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> {/* Skill Chat - Full Screen */} - } /> + } /> {/* Chat Page - Full Screen */} - } /> + } /> {/* Workflow Editor Page - Full Screen */} - } /> + } /> {/* Fallback */} } /> diff --git a/frontend/src/components/auth/ProtectedRoute.tsx b/frontend/src/components/auth/ProtectedRoute.tsx new file mode 100644 index 0000000..108e251 --- /dev/null +++ b/frontend/src/components/auth/ProtectedRoute.tsx @@ -0,0 +1,14 @@ +import { Navigate, useLocation } from 'react-router-dom' +import { useAuthStore } from '../../stores' + +export function ProtectedRoute({ children }: { children: React.ReactNode }) { + const { isAuthenticated, token } = useAuthStore() + const location = useLocation() + const hasToken = isAuthenticated || !!token || !!localStorage.getItem('token') + + if (!hasToken) { + return + } + + return <>{children} +} diff --git a/frontend/src/components/layout/Sidebar.tsx b/frontend/src/components/layout/Sidebar.tsx index 05ef776..1a6d4cd 100644 --- a/frontend/src/components/layout/Sidebar.tsx +++ b/frontend/src/components/layout/Sidebar.tsx @@ -1,5 +1,5 @@ import { useState } from 'react' -import { NavLink, useLocation } from 'react-router-dom' +import { NavLink, useLocation, useNavigate } from 'react-router-dom' import { LayoutDashboard, Users, @@ -15,9 +15,12 @@ import { HelpCircle, Sparkles, FileText, + LogOut, } from 'lucide-react' import { Logo } from './Logo' import { Button } from '../ui/Button' +import { auth } from '../../services/api' +import { useAuthStore } from '../../stores' const navItems = [ { icon: LayoutDashboard, label: '控制台', href: '/dashboard' }, @@ -35,6 +38,14 @@ const bottomNavItems = [ export function Sidebar() { const [collapsed, setCollapsed] = useState(false) const location = useLocation() + const navigate = useNavigate() + const logout = useAuthStore((s) => s.logout) + + const handleLogout = () => { + auth.logout() + logout() + navigate('/login') + } return (
diff --git a/frontend/src/pages/KnowledgePage.tsx b/frontend/src/pages/KnowledgePage.tsx index 1c62e90..ef2d815 100644 --- a/frontend/src/pages/KnowledgePage.tsx +++ b/frontend/src/pages/KnowledgePage.tsx @@ -86,7 +86,27 @@ export default function KnowledgePage() { const loadDocuments = async (kbId: string) => { try { const data = await api.get(`/knowledge/${kbId}/documents`) - setDocuments(Array.isArray(data?.documents) ? data.documents : []) + const raw = Array.isArray(data) + ? data + : Array.isArray(data?.documents) + ? data.documents + : [] + setDocuments( + raw.map((d: Record) => ({ + id: d.id as number, + name: String(d.filename ?? d.name ?? ''), + type: String(d.file_type ?? d.type ?? ''), + size: Number(d.file_size ?? d.size ?? 0), + chunks: Number(d.chunk_count ?? d.chunks ?? 0), + status: + d.status === 'failed' + ? 'failed' + : d.status === 'processing' || d.status === 'indexing' + ? 'processing' + : 'completed', + created_at: String(d.created_at ?? ''), + })) + ) } catch (error) { console.error('Failed to load documents:', error) setDocuments([]) diff --git a/frontend/src/pages/LoginPage.tsx b/frontend/src/pages/LoginPage.tsx index b328737..7fc4495 100644 --- a/frontend/src/pages/LoginPage.tsx +++ b/frontend/src/pages/LoginPage.tsx @@ -26,7 +26,7 @@ export default function LoginPage() { if (response.user) { setUser(response.user) } - navigate('/dashboard') + navigate('/skills') } catch (err: any) { setError(err.message || '登录失败,请检查邮箱和密码') } finally { diff --git a/frontend/src/pages/SkillChatPage.tsx b/frontend/src/pages/SkillChatPage.tsx index 2503b59..105e953 100644 --- a/frontend/src/pages/SkillChatPage.tsx +++ b/frontend/src/pages/SkillChatPage.tsx @@ -59,11 +59,13 @@ export default function SkillChatPage() { const [input, setInput] = useState('') const [files, setFiles] = useState([]) const [loading, setLoading] = useState(false) + const [loadError, setLoadError] = useState('') const [sessionId, setSessionId] = useState() const [downloads, setDownloads] = useState([]) useEffect(() => { if (!id) return + setLoadError('') skillsApi .get(id) .then((data) => @@ -74,7 +76,13 @@ export default function SkillChatPage() { specialty: data.specialty, }) ) - .catch(() => navigate('/skills')) + .catch((e: { message?: string }) => { + const msg = e?.message || '加载 Skill 失败' + setLoadError(msg) + if (msg.includes('登录') || msg.includes('401') || msg.includes('未登录')) { + setTimeout(() => navigate('/login'), 1500) + } + }) }, [id, navigate]) useEffect(() => { @@ -148,6 +156,14 @@ export default function SkillChatPage() { + {loadError && ( +
+ + {loadError} + +
+ )} +
{skill?.description && messages.length === 0 && ( @@ -193,20 +209,22 @@ export default function SkillChatPage() { {downloads.length > 0 && (
- - 生成文件: + +

生成文件

+
)} diff --git a/frontend/src/pages/SkillPlazaPage.tsx b/frontend/src/pages/SkillPlazaPage.tsx index 5a22ffb..41abc7d 100644 --- a/frontend/src/pages/SkillPlazaPage.tsx +++ b/frontend/src/pages/SkillPlazaPage.tsx @@ -1,5 +1,5 @@ import { useEffect, useRef, useState } from 'react' -import { useNavigate } from 'react-router-dom' +import { Link, useNavigate } from 'react-router-dom' import { Search, Sparkles, @@ -74,7 +74,16 @@ export default function SkillPlazaPage() {
{error && ( - {error} + + {error} + {(error.includes('登录') || error.includes('401') || error.includes('未登录')) && ( + + + + )} + )} {loading ? ( diff --git a/frontend/src/pages/WorkflowPage.tsx b/frontend/src/pages/WorkflowPage.tsx index bff3ad3..d6420cc 100644 --- a/frontend/src/pages/WorkflowPage.tsx +++ b/frontend/src/pages/WorkflowPage.tsx @@ -349,9 +349,13 @@ export default function WorkflowPage() { setNodeExecutionStates(newStates) try { + const token = localStorage.getItem('token') const response = await fetch('/api/v1/workflow/execute', { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: { + 'Content-Type': 'application/json', + ...(token ? { Authorization: `Bearer ${token}` } : {}), + }, body: JSON.stringify({ workflow_definition: { nodes: nodes.map(n => ({ id: n.id, type: n.type, data: n.data })), diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 50fcb62..cfc85db 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -35,6 +35,18 @@ class ApiClient { headers, }) + if (response.status === 401) { + this.clearToken() + if ( + typeof window !== 'undefined' && + !window.location.pathname.startsWith('/login') && + !window.location.pathname.startsWith('/register') + ) { + window.location.href = '/login' + } + throw new Error('未登录或登录已过期,请重新登录') + } + if (!response.ok) { let errorMsg = '请求失败' try { @@ -96,6 +108,17 @@ class ApiClient { body: formData, }) + if (response.status === 401) { + this.clearToken() + if ( + typeof window !== 'undefined' && + !window.location.pathname.startsWith('/login') + ) { + window.location.href = '/login' + } + throw new Error('未登录或登录已过期,请重新登录') + } + if (!response.ok) { throw new Error('上传失败') } @@ -117,7 +140,7 @@ export const auth = { api.clearToken() }, getCurrentUser: () => { - return api.get('/auth/me') + return api.get('/users/me') }, } diff --git a/skills/skill-1934395/SKILL.md b/skills/skill-1934395/SKILL.md deleted file mode 100644 index bbd0b9a..0000000 --- a/skills/skill-1934395/SKILL.md +++ /dev/null @@ -1,113 +0,0 @@ ---- -{ - "id": "skill-1934395", - "name": "数据分析", - "description": "从当前上传的表格做清洗、统计与异常识别,输出数据概览 / 核心发现 / 趋势解读 / 行动建议。\n\n【怎么开始】上传 csv/xlsx 并说明分析目的。只分析本轮文件,不沿用历史旧表。A/B 的 p 值请改用「统计分析助手」。", - "specialty": "数据", - "routerBlurb": "适用于需要处理结构化表格数据、提取商业洞察并生成标准化分析报告的场景。", - "triggers": [ - "分析这份销售数据表", - "生成月度业绩分析报告", - "解读用户行为数据趋势", - "计算并分析财务指标", - "从表格中提取关键洞察", - "数据异常值检测与报告", - "基于数据给出行动建议" - ], - "tags": [ - "数据" - ], - "version": "v1.0.0", - "model": "chatzoc_9b_B", - "params": { - "temperature": 0.3, - "maxTokens": 2048 - }, - "authorId": "u-test-10001", - "authorName": "Demo User", - "visibility": "team", - "status": "published", - "changelog": "初始版本", - "systemPrompt": "你是一名资深数据分析师,专注于从原始表格数据中提取关键洞察、发现趋势并生成结构化报告。核心能力包括数据清洗、统计计算(均值、增长率、极值)、异常值识别及深度解读。若用户问「你会干什么/怎么用」:按能力卡片介绍(需要上传表格、四段报告结构、示例),禁止编造数据或套空报告。严禁仅罗列数据或执行简单读取任务,必须提供实质性分析。有数据时输出必须严格遵循 Markdown 格式,包含‘数据概览’、‘核心发现’、‘趋势解读’及‘行动建议’四个章节,并使用领域术语(如 KPI、同比、环比、离群点)。", - "userPrompt": "请基于以下输入数据执行深度分析:{{input}}。首先识别关键指标与维度,计算必要统计量并识别显著趋势或异常值。随后生成专业 Markdown 报告,必须包含‘数据概览’、‘核心发现’、‘趋势解读’及‘行动建议’章节,确保结论具有可执行性。\n\n## 当前上传文件\n{{file_summary}}\n必须只分析上述文件;忽略历史对话中的其它文件名与旧报告。", - "variables": [ - { - "name": "input", - "type": "string" - }, - { - "name": "file_summary", - "type": "string" - } - ], - "examples": [ - { - "title": "试例 1", - "note": "", - "variables": { - "input": "分析一下这份数据的内容 有什么规律" - } - } - ], - "workflow": { - "agentId": "skill-1934395", - "version": "1.0.0", - "steps": [ - { - "name": "变量注入", - "action": "injectVariables" - }, - { - "name": "模型调用", - "action": "callModel", - "model": "chatzoc_9b_B" - }, - { - "name": "结果返回", - "action": "formatOutput" - } - ] - }, - "totalCalls": 7, - "avgRating": 0.0, - "publishedAt": "2026-08-13T03:27:53", - "createdAt": "2026-08-13T03:27:53", - "updatedAt": "2026-08-20T09:46:37" -} ---- - -# 数据分析 -> 专业 Skill Executable Specification | 专业: 数据 | 发布人: Demo User - -## 专业方向 -数据 - -## 目标 -他会生成数据分析报告 - -## 输入变量 -- `input` -- `file_summary` - -## 调用 Prompt(实际发给模型) - -### System -```text -你是一名资深数据分析师,专注于从原始表格数据中提取关键洞察、发现趋势并生成结构化报告。核心能力包括数据清洗、统计计算(均值、增长率、极值)、异常值识别及深度解读。严禁仅罗列数据或执行简单读取任务,必须提供实质性分析。输出必须严格遵循 Markdown 格式,包含‘数据概览’、‘核心发现’、‘趋势解读’及‘行动建议’四个章节,并使用领域术语(如 KPI、同比、环比、离群点)。 -``` - -### User -```text -请基于以下输入数据执行深度分析:{{input}}。首先识别关键指标与维度,计算必要统计量并识别显著趋势或异常值。随后生成专业 Markdown 报告,必须包含‘数据概览’、‘核心发现’、‘趋势解读’及‘行动建议’章节,确保结论具有可执行性。 - -## 当前上传文件 -{{file_summary}} -必须只分析上述文件;忽略历史对话中的其它文件名与旧报告。 -``` - -## 约束与验收 -1. 禁止输出原始数据罗列,必须包含计算结果与洞察。 -2. 报告结构必须包含:数据概览、核心发现、趋势解读、行动建议。 -3. 必须使用 Markdown 格式,标题层级清晰。 -4. 分析需涵盖统计量计算、异常值检测及趋势解释。 -5. 行动建议必须具体且基于数据结论。 From 934909ec178016e29f4649ee6d3d17c895546fc5 Mon Sep 17 00:00:00 2001 From: 23kaka6 <121601339+23kaka6@users.noreply.github.com> Date: Wed, 26 Aug 2026 17:20:19 +0800 Subject: [PATCH 2/2] fix: register redirect to skills, remove stale skill refs, stop call-time SKILL writes --- backend/app/services/skill/intro_cards.py | 18 ------------------ backend/app/services/skill_service.py | 1 - frontend/src/pages/RegisterPage.tsx | 2 +- 3 files changed, 1 insertion(+), 20 deletions(-) diff --git a/backend/app/services/skill/intro_cards.py b/backend/app/services/skill/intro_cards.py index 2f58f09..24581fe 100644 --- a/backend/app/services/skill/intro_cards.py +++ b/backend/app/services/skill/intro_cards.py @@ -183,24 +183,6 @@ "地图/桑基等 AntV 远程图种未接入,常用统计图在本地生成", ], }, - "skill-1934395": { - "name": "数据分析", - "blurb": "从表格数据里做清洗、统计与异常识别,并写成带行动建议的分析报告。", - "can": [ - "读取当前上传的表格(csv / xlsx 等)", - "计算关键指标(均值、增长率、极值等)并找异常", - "输出:数据概览、核心发现、趋势解读、行动建议", - ], - "how": "上传数据表并说明分析目的(例如「看本月销售同比」)。只分析本轮上传文件,不沿用历史里的旧表。", - "examples": [ - "分析这份销售表,给出月度业绩报告", - "找出异常订单并给出行动建议", - ], - "cannot": [ - "没有数据时不编造样本", - "不是假设检验专用工具(A/B 的 p 值请用「统计分析助手」)", - ], - }, } diff --git a/backend/app/services/skill_service.py b/backend/app/services/skill_service.py index 3442ec0..5ecaab9 100644 --- a/backend/app/services/skill_service.py +++ b/backend/app/services/skill_service.py @@ -792,7 +792,6 @@ async def call_agent( ) agent.model_name = model agent.workflow = _sync_workflow_model(agent.workflow, model) - save_skill(agent) if agent_loop_output and not skip_tools: result = ChatResult(output=agent_loop_output, latency_ms=extra_latency) diff --git a/frontend/src/pages/RegisterPage.tsx b/frontend/src/pages/RegisterPage.tsx index bc4ae45..ecf8c93 100644 --- a/frontend/src/pages/RegisterPage.tsx +++ b/frontend/src/pages/RegisterPage.tsx @@ -29,7 +29,7 @@ export default function RegisterPage() { setUser(response.user) } addToast({ type: 'success', message: '注册成功!' }) - navigate('/dashboard') + navigate('/skills') } catch (err: any) { setError(err.message || '注册失败,请重试') } finally {