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 @@
-
+
[](https://python.org)
[](https://fastapi.tiangolo.com)
[](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 (
{item.label}}
))}
+
+
+ {!collapsed && 退出登录 }
+
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