Merge branch 'feature/skills' into try-merge-remote-main
This commit is contained in:
commit
ca2a47a29c
@ -29,4 +29,8 @@ TAVILY_API_KEY= # 获取搜索服务的 api key 请访问 https://app.tavily.co
|
||||
# YUXI_URL_WHITELIST=github.com,docs.example.com,gitlab.example.com,127.0.0.1
|
||||
|
||||
# # MinerU
|
||||
# MINERU_API_KEY=
|
||||
# MINERU_API_KEY=
|
||||
|
||||
# LightRag llm 并发限制
|
||||
# MAX_ASYNC=5
|
||||
# EMBEDDING_FUNC_MAX_ASYNC=8
|
||||
@ -44,6 +44,7 @@ export default defineConfig({
|
||||
{ text: '配置系统详解', link: '/latest/advanced/configuration' },
|
||||
{ text: '文档解析', link: '/latest/advanced/document-processing' },
|
||||
{ text: '智能体', link: '/latest/advanced/agents-config' },
|
||||
{ text: 'Skills 管理', link: '/latest/advanced/skills-management' },
|
||||
{ text: '品牌自定义', link: '/latest/advanced/branding' },
|
||||
{ text: '其他配置', link: '/latest/advanced/misc' },
|
||||
{ text: '生产部署', link: '/latest/advanced/deployment' }
|
||||
|
||||
@ -61,6 +61,7 @@ async def get_graph(self, **kwargs):
|
||||
| `tools` | list[str] | 启用的内置工具列表 |
|
||||
| `knowledges` | list[str] | 关联的知识库列表 |
|
||||
| `mcps` | list[str] | 启用的 MCP 服务器名称 |
|
||||
| `skills` | list[str] | 关联的 Skills(运行时只读挂载到 `/skills`) |
|
||||
|
||||
```python
|
||||
from src.agents.common import BaseContext
|
||||
@ -98,6 +99,14 @@ class ReporterContext(BaseContext):
|
||||
|
||||
更多动态工具选择与 MCP 注册的例子,见 `src/agents/chatbot/graph.py` 中的中间件组合。
|
||||
|
||||
### Skills 只读挂载
|
||||
|
||||
`BaseContext.skills` 用于声明当前智能体可访问的技能目录(slug 列表)。运行时会将这些目录只读挂载到 `/skills/<slug>/...`:
|
||||
|
||||
1. 仅显示配置中选中的 skills,未选中的 slug 在运行时不可见。
|
||||
2. `/skills` 仅支持读取能力(`ls/read/glob/grep`),写入和编辑会被拒绝。
|
||||
3. skills 元数据来自数据库索引,内容目录来自共享存储 `/app/saves/skills`。
|
||||
|
||||
### 拓展现有智能体
|
||||
|
||||
智能体保持为 LangGraph 的标准节点组合,因此可以在原有 `graph.py` 中添加节点、条件与消息转换器。复用现成上下文时,只需扩展当前 `context_schema` 的字段;若功能差异较大,可以创建新的上下文类并替换 `context_schema`。
|
||||
|
||||
42
docs/latest/advanced/skills-management.md
Normal file
42
docs/latest/advanced/skills-management.md
Normal file
@ -0,0 +1,42 @@
|
||||
# Skills 管理
|
||||
|
||||
Skills 管理模块用于集中维护可供 Agent 只读引用的技能包。
|
||||
本期采用“文件系统存内容,数据库存索引”模式:
|
||||
|
||||
1. 技能目录存储在 `/app/saves/skills`(本地 `save_dir/skills`)。
|
||||
2. 技能元数据(slug/name/description/dir_path)存储在 `skills` 表。
|
||||
3. Agent 配置通过 `context.skills` 选择技能,运行时挂载到 `/skills` 且只读。
|
||||
|
||||
## 权限与入口
|
||||
|
||||
1. 系统设置中新增 `Skills 管理` 页签(仅 `superadmin` 可见)。
|
||||
2. `admin` 仅可调用列表接口(用于 Agent 配置选择 skills)。
|
||||
3. `user` 无 skills 管理权限。
|
||||
|
||||
## 导入规范(ZIP)
|
||||
|
||||
1. 单包单技能,且必须包含一个 `SKILL.md`。
|
||||
2. `SKILL.md` 必须包含 frontmatter,且 `name`、`description` 必填。
|
||||
3. `name` 需满足 slug 规则:小写字母/数字/短横线。
|
||||
4. 导入时执行路径安全校验,拒绝绝对路径与 `..` 路径穿越。
|
||||
5. slug 冲突时自动追加 `-v2/-v3...`,并自动改写 `SKILL.md` 中 `name` 为最终 slug。
|
||||
6. 导入采用临时目录 + 原子替换,避免半成品落盘。
|
||||
|
||||
## 在线管理能力
|
||||
|
||||
1. Skills 列表:来自数据库,避免全量目录扫描。
|
||||
2. 目录树:按原生目录结构展示。
|
||||
3. 文件级 CRUD:支持新建文件/目录、编辑文本文件、删除文件/目录。
|
||||
4. 文件编辑仅允许文本类型(如 md/py/js/ts/json/yaml/toml/txt 等)。
|
||||
5. `SKILL.md` 保存后会重新解析,并同步更新数据库中的 `name/description`。
|
||||
6. 支持导出单个 skill 为 ZIP。
|
||||
7. 删除 skill 时会同时删除目录与数据库记录(硬删除)。
|
||||
|
||||
## Agent 运行时行为
|
||||
|
||||
1. `context.skills` 用于配置技能 slug 列表。
|
||||
2. 运行时按会话构建 `SkillResolver` 快照(同一会话首次构建,后续复用)。
|
||||
3. 运行时仅暴露快照中的可见 skills 到 `/skills/<slug>/...`。
|
||||
4. `/skills` 路径只读,不允许写入、编辑、上传。
|
||||
5. 同会话内若 `context.skills` 变化会触发快照重建。
|
||||
6. 后台修改 skills 内容后,已有会话不会自动刷新,需新会话或调整 `context.skills` 才生效。
|
||||
@ -658,6 +658,7 @@ class MigrationRunner:
|
||||
url=sqlite_server.url,
|
||||
command=sqlite_server.command,
|
||||
args=sqlite_server.args,
|
||||
env=getattr(sqlite_server, "env", None),
|
||||
headers=sqlite_server.headers,
|
||||
timeout=sqlite_server.timeout,
|
||||
sse_read_timeout=sqlite_server.sse_read_timeout,
|
||||
|
||||
@ -532,6 +532,7 @@ async def migrate_mcp_servers(sqlite_reader: SQLiteReader, dry_run: bool, execut
|
||||
url=sqlite_server.url,
|
||||
command=sqlite_server.command,
|
||||
args=sqlite_server.args,
|
||||
env=getattr(sqlite_server, "env", None),
|
||||
headers=sqlite_server.headers,
|
||||
timeout=sqlite_server.timeout,
|
||||
sse_read_timeout=sqlite_server.sse_read_timeout,
|
||||
|
||||
@ -9,6 +9,7 @@ from server.routers.knowledge_router import knowledge
|
||||
from server.routers.evaluation_router import evaluation
|
||||
from server.routers.mcp_router import mcp
|
||||
from server.routers.mindmap_router import mindmap
|
||||
from server.routers.skill_router import skills
|
||||
from server.routers.system_router import system
|
||||
from server.routers.task_router import tasks
|
||||
|
||||
@ -26,3 +27,4 @@ router.include_router(mindmap) # /api/mindmap/*
|
||||
router.include_router(graph) # /api/graph/*
|
||||
router.include_router(tasks) # /api/tasks/*
|
||||
router.include_router(mcp) # /api/system/mcp-servers/*
|
||||
router.include_router(skills) # /api/system/skills/*
|
||||
|
||||
@ -75,7 +75,7 @@ async def get_default_agent(current_user: User = Depends(get_required_user)):
|
||||
default_agent_id = conf.default_agent_id
|
||||
# 如果没有设置默认智能体,尝试获取第一个可用的智能体
|
||||
if not default_agent_id:
|
||||
agents = await agent_manager.get_agents_info()
|
||||
agents = await agent_manager.get_agents_info(include_configurable_items=False)
|
||||
if agents:
|
||||
default_agent_id = agents[0].get("id", "")
|
||||
|
||||
@ -94,7 +94,7 @@ async def set_default_agent(request_data: dict = Body(...), current_user=Depends
|
||||
raise HTTPException(status_code=422, detail="缺少必需的 agent_id 字段")
|
||||
|
||||
# 验证智能体是否存在
|
||||
agents = await agent_manager.get_agents_info()
|
||||
agents = await agent_manager.get_agents_info(include_configurable_items=False)
|
||||
agent_ids = [agent.get("id", "") for agent in agents]
|
||||
|
||||
if agent_id not in agent_ids:
|
||||
@ -137,7 +137,7 @@ async def call(query: str = Body(...), meta: dict = Body(None), current_user: Us
|
||||
@chat.get("/agent")
|
||||
async def get_agent(current_user: User = Depends(get_required_user)):
|
||||
"""获取所有可用智能体的基本信息(需要登录)"""
|
||||
agents_info = await agent_manager.get_agents_info()
|
||||
agents_info = await agent_manager.get_agents_info(include_configurable_items=False)
|
||||
|
||||
# Return agents with basic information (without configurable_items for performance)
|
||||
agents = [
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import traceback
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Depends, File, Form, Body, UploadFile
|
||||
from fastapi.responses import FileResponse
|
||||
from src.storage.postgres.models_business import User
|
||||
from server.utils.auth_middleware import get_admin_user
|
||||
from src.utils import logger
|
||||
@ -53,6 +54,29 @@ async def delete_evaluation_benchmark(benchmark_id: str, current_user: User = De
|
||||
raise HTTPException(status_code=500, detail=f"删除评估基准失败: {str(e)}")
|
||||
|
||||
|
||||
@evaluation.get("/benchmarks/{benchmark_id}/download")
|
||||
async def download_evaluation_benchmark(benchmark_id: str, current_user: User = Depends(get_admin_user)):
|
||||
"""下载评估基准文件"""
|
||||
from src.services.evaluation_service import EvaluationService
|
||||
|
||||
try:
|
||||
service = EvaluationService()
|
||||
download_info = await service.get_benchmark_download_info(benchmark_id)
|
||||
return FileResponse(
|
||||
path=download_info["file_path"],
|
||||
filename=download_info["filename"],
|
||||
media_type="application/x-ndjson",
|
||||
)
|
||||
except ValueError as e:
|
||||
if "not found" in str(e).lower():
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
logger.error(f"下载评估基准失败: {e}, {traceback.format_exc()}")
|
||||
raise HTTPException(status_code=500, detail=f"下载评估基准失败: {str(e)}")
|
||||
except Exception as e:
|
||||
logger.error(f"下载评估基准失败: {e}, {traceback.format_exc()}")
|
||||
raise HTTPException(status_code=500, detail=f"下载评估基准失败: {str(e)}")
|
||||
|
||||
|
||||
@evaluation.get("/databases/{db_id}/results/{task_id}")
|
||||
async def get_evaluation_results_by_db(
|
||||
db_id: str,
|
||||
|
||||
@ -13,6 +13,7 @@ from starlette.responses import StreamingResponse
|
||||
from src.services.task_service import TaskContext, tasker
|
||||
from server.utils.auth_middleware import get_admin_user, get_required_user
|
||||
from src import config, knowledge_base
|
||||
from src.knowledge.chunking.ragflow_like.presets import ensure_chunk_defaults_in_additional_params
|
||||
from src.knowledge.indexing import SUPPORTED_FILE_EXTENSIONS, is_supported_file_extension, process_file_to_markdown
|
||||
from src.knowledge.utils import calculate_content_hash
|
||||
from src.models.embed import test_all_embedding_models_status, test_embedding_model_status
|
||||
@ -116,6 +117,7 @@ async def create_database(
|
||||
params.pop("reranker_config", None)
|
||||
|
||||
remove_reranker_config(kb_type, additional_params)
|
||||
additional_params = ensure_chunk_defaults_in_additional_params(additional_params)
|
||||
|
||||
embed_info = config.embed_model_names[embed_model_name]
|
||||
# 将Pydantic模型转换为字典以便JSON序列化
|
||||
@ -180,7 +182,7 @@ async def update_database_info(
|
||||
name: str = Body(...),
|
||||
description: str = Body(...),
|
||||
llm_info: dict = Body(None),
|
||||
additional_params: dict = Body({}),
|
||||
additional_params: dict | None = Body(None),
|
||||
share_config: dict = Body(None),
|
||||
current_user: User = Depends(get_admin_user),
|
||||
):
|
||||
@ -190,6 +192,9 @@ async def update_database_info(
|
||||
f"additional_params={additional_params}, share_config={share_config}"
|
||||
)
|
||||
try:
|
||||
if additional_params is not None:
|
||||
additional_params = ensure_chunk_defaults_in_additional_params(additional_params)
|
||||
|
||||
database = await knowledge_base.update_database(
|
||||
db_id,
|
||||
name,
|
||||
@ -267,7 +272,13 @@ async def add_documents(
|
||||
"chunk_size": params.get("chunk_size", 1000),
|
||||
"chunk_overlap": params.get("chunk_overlap", 200),
|
||||
"qa_separator": params.get("qa_separator", ""),
|
||||
"chunk_preset_id": params.get("chunk_preset_id"),
|
||||
"chunk_parser_config": params.get("chunk_parser_config"),
|
||||
}
|
||||
if not indexing_params.get("chunk_preset_id"):
|
||||
indexing_params.pop("chunk_preset_id", None)
|
||||
if not isinstance(indexing_params.get("chunk_parser_config"), dict):
|
||||
indexing_params.pop("chunk_parser_config", None)
|
||||
|
||||
# URL 解析与入库(需白名单验证)
|
||||
if content_type == "url":
|
||||
|
||||
@ -33,6 +33,7 @@ class CreateMcpServerRequest(BaseModel):
|
||||
url: str | None = Field(None, description="服务器 URL(sse/streamable_http)")
|
||||
command: str | None = Field(None, description="命令(stdio)")
|
||||
args: list | None = Field(None, description="命令参数数组(stdio)")
|
||||
env: dict | None = Field(None, description="环境变量(stdio)")
|
||||
description: str | None = Field(None, description="描述")
|
||||
headers: dict | None = Field(None, description="HTTP 请求头")
|
||||
timeout: int | None = Field(None, description="HTTP 超时时间(秒)")
|
||||
@ -46,6 +47,7 @@ class UpdateMcpServerRequest(BaseModel):
|
||||
url: str | None = Field(None, description="服务器 URL")
|
||||
command: str | None = Field(None, description="命令(stdio)")
|
||||
args: list | None = Field(None, description="命令参数数组(stdio)")
|
||||
env: dict | None = Field(None, description="环境变量(stdio)")
|
||||
description: str | None = Field(None, description="描述")
|
||||
headers: dict | None = Field(None, description="HTTP 请求头")
|
||||
timeout: int | None = Field(None, description="HTTP 超时时间(秒)")
|
||||
@ -112,6 +114,7 @@ async def create_mcp_server_route(
|
||||
url=request.url,
|
||||
command=request.command,
|
||||
args=request.args,
|
||||
env=request.env,
|
||||
description=request.description,
|
||||
headers=request.headers,
|
||||
timeout=request.timeout,
|
||||
@ -159,6 +162,11 @@ async def update_mcp_server_route(
|
||||
raise HTTPException(status_code=400, detail=f"传输类型必须是 {', '.join(valid_transports)} 之一")
|
||||
|
||||
try:
|
||||
fields_set = getattr(request, "model_fields_set", getattr(request, "__fields_set__", set()))
|
||||
update_kwargs = {}
|
||||
if "env" in fields_set:
|
||||
update_kwargs["env"] = request.env
|
||||
|
||||
server = await update_mcp_server(
|
||||
db,
|
||||
name=name,
|
||||
@ -173,6 +181,7 @@ async def update_mcp_server_route(
|
||||
tags=request.tags,
|
||||
icon=request.icon,
|
||||
updated_by=current_user.username,
|
||||
**update_kwargs,
|
||||
)
|
||||
return {"success": True, "data": server.to_dict()}
|
||||
except ValueError as ve:
|
||||
|
||||
294
server/routers/skill_router.py
Normal file
294
server/routers/skill_router.py
Normal file
@ -0,0 +1,294 @@
|
||||
"""Skills 管理路由"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, BackgroundTasks, Depends, File, HTTPException, Query, UploadFile
|
||||
from fastapi.responses import FileResponse
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from server.utils.auth_middleware import get_admin_user, get_db, get_superadmin_user
|
||||
from src.services.skill_service import (
|
||||
create_skill_node,
|
||||
delete_skill,
|
||||
delete_skill_node,
|
||||
export_skill_zip,
|
||||
get_skill_dependency_options,
|
||||
get_skill_tree,
|
||||
import_skill_zip,
|
||||
list_skills,
|
||||
read_skill_file,
|
||||
update_skill_dependencies,
|
||||
update_skill_file,
|
||||
)
|
||||
from src.storage.postgres.models_business import User
|
||||
from src.utils.logging_config import logger
|
||||
|
||||
skills = APIRouter(prefix="/system/skills", tags=["skills"])
|
||||
|
||||
|
||||
class SkillNodeCreateRequest(BaseModel):
|
||||
path: str = Field(..., description="相对 skill 根目录的路径")
|
||||
is_dir: bool = Field(False, description="是否创建目录")
|
||||
content: str | None = Field("", description="文件内容(仅文件创建时生效)")
|
||||
|
||||
|
||||
class SkillFileUpdateRequest(BaseModel):
|
||||
path: str = Field(..., description="相对 skill 根目录的路径")
|
||||
content: str = Field(..., description="文件内容")
|
||||
|
||||
|
||||
class SkillDependenciesUpdateRequest(BaseModel):
|
||||
tool_dependencies: list[str] = Field(default_factory=list, description="依赖的内置工具列表")
|
||||
mcp_dependencies: list[str] = Field(default_factory=list, description="依赖的 MCP 服务列表")
|
||||
skill_dependencies: list[str] = Field(default_factory=list, description="依赖的其他 skill slug 列表")
|
||||
|
||||
|
||||
def _raise_from_value_error(e: ValueError) -> None:
|
||||
message = str(e)
|
||||
status_code = 404 if "不存在" in message else 400
|
||||
raise HTTPException(status_code=status_code, detail=message)
|
||||
|
||||
|
||||
def _cleanup_export_file(path: str) -> None:
|
||||
try:
|
||||
Path(path).unlink(missing_ok=True)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to cleanup exported skill archive '{path}': {e}")
|
||||
|
||||
|
||||
@skills.get("")
|
||||
async def list_skills_route(
|
||||
_current_user: User = Depends(get_admin_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""获取技能列表(管理员可读)。"""
|
||||
try:
|
||||
items = await list_skills(db)
|
||||
return {"success": True, "data": [item.to_dict() for item in items]}
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to list skills: {e}")
|
||||
raise HTTPException(status_code=500, detail="获取技能列表失败")
|
||||
|
||||
|
||||
@skills.get("/dependency-options")
|
||||
async def get_skill_dependency_options_route(
|
||||
_current_user: User = Depends(get_superadmin_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""获取 skill 依赖项可选列表(仅超级管理员)。"""
|
||||
try:
|
||||
return {"success": True, "data": await get_skill_dependency_options(db)}
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get skill dependency options: {e}")
|
||||
raise HTTPException(status_code=500, detail="获取 skill 依赖选项失败")
|
||||
|
||||
|
||||
@skills.post("/import")
|
||||
async def import_skill_route(
|
||||
file: UploadFile = File(...),
|
||||
current_user: User = Depends(get_superadmin_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""导入技能压缩包(仅超级管理员)。"""
|
||||
try:
|
||||
file_bytes = await file.read()
|
||||
item = await import_skill_zip(
|
||||
db,
|
||||
filename=file.filename or "",
|
||||
file_bytes=file_bytes,
|
||||
created_by=current_user.username,
|
||||
)
|
||||
return {"success": True, "data": item.to_dict()}
|
||||
except ValueError as e:
|
||||
_raise_from_value_error(e)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to import skill zip: {e}")
|
||||
raise HTTPException(status_code=500, detail="导入技能失败")
|
||||
|
||||
|
||||
@skills.get("/{slug}/tree")
|
||||
async def get_skill_tree_route(
|
||||
slug: str,
|
||||
_current_user: User = Depends(get_superadmin_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""获取技能目录树(仅超级管理员)。"""
|
||||
try:
|
||||
tree = await get_skill_tree(db, slug)
|
||||
return {"success": True, "data": tree}
|
||||
except ValueError as e:
|
||||
_raise_from_value_error(e)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get skill tree '{slug}': {e}")
|
||||
raise HTTPException(status_code=500, detail="获取技能目录树失败")
|
||||
|
||||
|
||||
@skills.get("/{slug}/file")
|
||||
async def get_skill_file_route(
|
||||
slug: str,
|
||||
path: str = Query(..., description="相对 skill 根目录路径"),
|
||||
_current_user: User = Depends(get_superadmin_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""读取技能文本文件(仅超级管理员)。"""
|
||||
try:
|
||||
data = await read_skill_file(db, slug, path)
|
||||
return {"success": True, "data": data}
|
||||
except ValueError as e:
|
||||
_raise_from_value_error(e)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to read skill file '{slug}/{path}': {e}")
|
||||
raise HTTPException(status_code=500, detail="读取技能文件失败")
|
||||
|
||||
|
||||
@skills.post("/{slug}/file")
|
||||
async def create_skill_file_route(
|
||||
slug: str,
|
||||
payload: SkillNodeCreateRequest,
|
||||
current_user: User = Depends(get_superadmin_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""创建技能文件或目录(仅超级管理员)。"""
|
||||
try:
|
||||
await create_skill_node(
|
||||
db,
|
||||
slug=slug,
|
||||
relative_path=payload.path,
|
||||
is_dir=payload.is_dir,
|
||||
content=payload.content,
|
||||
updated_by=current_user.username,
|
||||
)
|
||||
return {"success": True}
|
||||
except ValueError as e:
|
||||
_raise_from_value_error(e)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to create skill node '{slug}/{payload.path}': {e}")
|
||||
raise HTTPException(status_code=500, detail="创建技能文件失败")
|
||||
|
||||
|
||||
@skills.put("/{slug}/file")
|
||||
async def update_skill_file_route(
|
||||
slug: str,
|
||||
payload: SkillFileUpdateRequest,
|
||||
current_user: User = Depends(get_superadmin_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""更新技能文本文件(仅超级管理员)。"""
|
||||
try:
|
||||
await update_skill_file(
|
||||
db,
|
||||
slug=slug,
|
||||
relative_path=payload.path,
|
||||
content=payload.content,
|
||||
updated_by=current_user.username,
|
||||
)
|
||||
return {"success": True}
|
||||
except ValueError as e:
|
||||
_raise_from_value_error(e)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to update skill file '{slug}/{payload.path}': {e}")
|
||||
raise HTTPException(status_code=500, detail="更新技能文件失败")
|
||||
|
||||
|
||||
@skills.put("/{slug}/dependencies")
|
||||
async def update_skill_dependencies_route(
|
||||
slug: str,
|
||||
payload: SkillDependenciesUpdateRequest,
|
||||
current_user: User = Depends(get_superadmin_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""更新 skill 依赖(仅超级管理员)。"""
|
||||
try:
|
||||
item = await update_skill_dependencies(
|
||||
db,
|
||||
slug=slug,
|
||||
tool_dependencies=payload.tool_dependencies,
|
||||
mcp_dependencies=payload.mcp_dependencies,
|
||||
skill_dependencies=payload.skill_dependencies,
|
||||
updated_by=current_user.username,
|
||||
)
|
||||
return {"success": True, "data": item.to_dict()}
|
||||
except ValueError as e:
|
||||
_raise_from_value_error(e)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to update skill dependencies '{slug}': {e}")
|
||||
raise HTTPException(status_code=500, detail="更新 skill 依赖失败")
|
||||
|
||||
|
||||
@skills.delete("/{slug}/file")
|
||||
async def delete_skill_file_route(
|
||||
slug: str,
|
||||
path: str = Query(..., description="相对 skill 根目录路径"),
|
||||
_current_user: User = Depends(get_superadmin_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""删除技能文件或目录(仅超级管理员)。"""
|
||||
try:
|
||||
await delete_skill_node(db, slug=slug, relative_path=path)
|
||||
return {"success": True}
|
||||
except ValueError as e:
|
||||
_raise_from_value_error(e)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to delete skill file '{slug}/{path}': {e}")
|
||||
raise HTTPException(status_code=500, detail="删除技能文件失败")
|
||||
|
||||
|
||||
@skills.get("/{slug}/export")
|
||||
async def export_skill_route(
|
||||
slug: str,
|
||||
background_tasks: BackgroundTasks,
|
||||
_current_user: User = Depends(get_superadmin_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""导出技能压缩包(仅超级管理员)。"""
|
||||
try:
|
||||
export_path, download_name = await export_skill_zip(db, slug)
|
||||
background_tasks.add_task(_cleanup_export_file, export_path)
|
||||
return FileResponse(
|
||||
path=export_path,
|
||||
media_type="application/zip",
|
||||
filename=download_name,
|
||||
)
|
||||
except ValueError as e:
|
||||
_raise_from_value_error(e)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to export skill '{slug}': {e}")
|
||||
raise HTTPException(status_code=500, detail="导出技能失败")
|
||||
|
||||
|
||||
@skills.delete("/{slug}")
|
||||
async def delete_skill_route(
|
||||
slug: str,
|
||||
_current_user: User = Depends(get_superadmin_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""删除技能(目录 + 数据库记录,仅超级管理员)。"""
|
||||
try:
|
||||
await delete_skill(db, slug=slug)
|
||||
return {"success": True}
|
||||
except ValueError as e:
|
||||
_raise_from_value_error(e)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to delete skill '{slug}': {e}")
|
||||
raise HTTPException(status_code=500, detail="删除技能失败")
|
||||
@ -4,6 +4,7 @@ from fastapi import FastAPI
|
||||
|
||||
from src.services.task_service import tasker
|
||||
from src.services.mcp_service import init_mcp_servers
|
||||
from src.services.run_queue_service import close_queue_clients, get_redis_client
|
||||
from src.storage.postgres.manager import pg_manager
|
||||
from src.knowledge import knowledge_base
|
||||
from src.utils import logger
|
||||
@ -16,6 +17,7 @@ async def lifespan(app: FastAPI):
|
||||
try:
|
||||
pg_manager.initialize()
|
||||
await pg_manager.create_business_tables()
|
||||
await pg_manager.ensure_business_schema()
|
||||
await pg_manager.ensure_knowledge_schema()
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to initialize database during startup: {e}")
|
||||
|
||||
@ -39,9 +39,9 @@ class AgentManager(metaclass=SingletonMeta):
|
||||
for agent_id in self._classes.keys():
|
||||
self.get_agent(agent_id, reload=True)
|
||||
|
||||
async def get_agents_info(self):
|
||||
async def get_agents_info(self, include_configurable_items: bool = True):
|
||||
agents = self.get_agents()
|
||||
return await asyncio.gather(*[a.get_info() for a in agents])
|
||||
return await asyncio.gather(*[a.get_info(include_configurable_items=include_configurable_items) for a in agents])
|
||||
|
||||
def auto_discover_agents(self):
|
||||
"""自动发现并注册 src/agents/ 下的所有智能体。
|
||||
|
||||
@ -1,9 +1,9 @@
|
||||
from deepagents.backends import StateBackend
|
||||
from deepagents.middleware.filesystem import FilesystemMiddleware
|
||||
from langchain.agents import create_agent
|
||||
from langchain.agents.middleware import ModelRetryMiddleware
|
||||
|
||||
from src.agents.common import BaseAgent, load_chat_model
|
||||
from src.agents.common.backends import create_agent_composite_backend
|
||||
from src.agents.common.middlewares import (
|
||||
RuntimeConfigMiddleware,
|
||||
save_attachments_to_fs,
|
||||
@ -13,7 +13,7 @@ from src.services.mcp_service import get_tools_from_all_servers
|
||||
|
||||
def _create_fs_backend(rt):
|
||||
"""创建文件存储后端"""
|
||||
return StateBackend(rt)
|
||||
return create_agent_composite_backend(rt)
|
||||
|
||||
|
||||
class ChatbotAgent(BaseAgent):
|
||||
|
||||
6
src/agents/common/backends/__init__.py
Normal file
6
src/agents/common/backends/__init__.py
Normal file
@ -0,0 +1,6 @@
|
||||
from .skills_backend import SelectedSkillsReadonlyBackend, create_agent_composite_backend
|
||||
|
||||
__all__ = [
|
||||
"SelectedSkillsReadonlyBackend",
|
||||
"create_agent_composite_backend",
|
||||
]
|
||||
144
src/agents/common/backends/skills_backend.py
Normal file
144
src/agents/common/backends/skills_backend.py
Normal file
@ -0,0 +1,144 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import PurePosixPath
|
||||
from typing import Any
|
||||
|
||||
from deepagents.backends import CompositeBackend, FilesystemBackend, StateBackend
|
||||
from deepagents.backends.protocol import EditResult, FileDownloadResponse, FileUploadResponse, WriteResult
|
||||
|
||||
from src.services.skill_resolver import normalize_selected_skills
|
||||
from src.services.skill_service import get_skills_root_dir, is_valid_skill_slug
|
||||
|
||||
|
||||
class SelectedSkillsReadonlyBackend(FilesystemBackend):
|
||||
"""只读 skills backend,仅暴露选中的技能目录。"""
|
||||
|
||||
def __init__(self, *, selected_slugs: list[str] | None):
|
||||
super().__init__(root_dir=get_skills_root_dir(), virtual_mode=True)
|
||||
self._selected_slugs = {
|
||||
str(slug).strip()
|
||||
for slug in (selected_slugs or [])
|
||||
if isinstance(slug, str) and is_valid_skill_slug(str(slug))
|
||||
}
|
||||
|
||||
def _extract_slug(self, path: str | None) -> str | None:
|
||||
if not path:
|
||||
return None
|
||||
normalized = (path or "").strip()
|
||||
if not normalized or normalized == "/":
|
||||
return None
|
||||
pure = PurePosixPath(normalized if normalized.startswith("/") else f"/{normalized}")
|
||||
parts = [p for p in pure.parts if p not in ("/", "")]
|
||||
return parts[0] if parts else None
|
||||
|
||||
def _is_allowed_path(self, path: str | None) -> bool:
|
||||
slug = self._extract_slug(path)
|
||||
if slug is None:
|
||||
return True
|
||||
return slug in self._selected_slugs
|
||||
|
||||
def _is_allowed_file(self, file_path: str) -> bool:
|
||||
slug = self._extract_slug(file_path)
|
||||
return slug is not None and slug in self._selected_slugs
|
||||
|
||||
def ls_info(self, path: str) -> list[dict[str, Any]]:
|
||||
if not self._selected_slugs:
|
||||
return []
|
||||
|
||||
normalized = (path or "/").strip() or "/"
|
||||
if not self._is_allowed_path(normalized):
|
||||
return []
|
||||
|
||||
infos = super().ls_info(normalized)
|
||||
if normalized == "/":
|
||||
result = []
|
||||
for item in infos:
|
||||
slug = self._extract_slug(item.get("path", ""))
|
||||
if slug in self._selected_slugs:
|
||||
result.append(item)
|
||||
return result
|
||||
return infos
|
||||
|
||||
def read(self, file_path: str, offset: int = 0, limit: int = 2000) -> str:
|
||||
if not self._is_allowed_file(file_path):
|
||||
return "Access denied: file is outside selected skills."
|
||||
return super().read(file_path, offset=offset, limit=limit)
|
||||
|
||||
def grep_raw(
|
||||
self,
|
||||
pattern: str,
|
||||
path: str | None = None,
|
||||
glob: str | None = None,
|
||||
) -> list[dict[str, Any]] | str:
|
||||
if not self._selected_slugs:
|
||||
return []
|
||||
|
||||
if path is not None:
|
||||
if not self._is_allowed_path(path):
|
||||
return "Access denied: path is outside selected skills."
|
||||
return super().grep_raw(pattern=pattern, path=path, glob=glob)
|
||||
|
||||
matches: list[dict[str, Any]] = []
|
||||
for slug in sorted(self._selected_slugs):
|
||||
result = super().grep_raw(pattern=pattern, path=f"/{slug}", glob=glob)
|
||||
if isinstance(result, str):
|
||||
continue
|
||||
matches.extend(result)
|
||||
return matches
|
||||
|
||||
def glob_info(self, pattern: str, path: str = "/") -> list[dict[str, Any]]:
|
||||
if not self._selected_slugs:
|
||||
return []
|
||||
if not self._is_allowed_path(path):
|
||||
return []
|
||||
infos = super().glob_info(pattern=pattern, path=path)
|
||||
return [item for item in infos if self._extract_slug(item.get("path", "")) in self._selected_slugs]
|
||||
|
||||
def write(self, file_path: str, content: str) -> WriteResult:
|
||||
return WriteResult(error="Skills path is read-only.")
|
||||
|
||||
def edit(self, file_path: str, old_string: str, new_string: str, replace_all: bool = False) -> EditResult:
|
||||
return EditResult(error="Skills path is read-only.")
|
||||
|
||||
def upload_files(self, files: list[tuple[str, bytes]]) -> list[FileUploadResponse]:
|
||||
return [FileUploadResponse(path=p, error="permission_denied") for p, _ in files]
|
||||
|
||||
def download_files(self, paths: list[str]) -> list[FileDownloadResponse]:
|
||||
responses: list[FileDownloadResponse] = []
|
||||
for path in paths:
|
||||
if not self._is_allowed_file(path):
|
||||
responses.append(FileDownloadResponse(path=path, content=None, error="invalid_path"))
|
||||
continue
|
||||
target = self._resolve_path(path)
|
||||
if not target.exists():
|
||||
responses.append(FileDownloadResponse(path=path, content=None, error="file_not_found"))
|
||||
continue
|
||||
if target.is_dir():
|
||||
responses.append(FileDownloadResponse(path=path, content=None, error="is_directory"))
|
||||
continue
|
||||
responses.append(FileDownloadResponse(path=path, content=target.read_bytes(), error=None))
|
||||
return responses
|
||||
|
||||
|
||||
def create_agent_composite_backend(runtime) -> CompositeBackend:
|
||||
"""为 agent 构建 backend:默认 StateBackend + /skills 路由只读 backend。"""
|
||||
visible_skills = _get_visible_skills_from_runtime(runtime)
|
||||
return CompositeBackend(
|
||||
default=StateBackend(runtime),
|
||||
routes={
|
||||
"/skills/": SelectedSkillsReadonlyBackend(selected_slugs=visible_skills),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _get_visible_skills_from_runtime(runtime) -> list[str]:
|
||||
state = getattr(runtime, "state", None)
|
||||
if isinstance(state, dict):
|
||||
snapshot = state.get("skill_session_snapshot")
|
||||
if isinstance(snapshot, dict):
|
||||
visible = snapshot.get("visible_skills")
|
||||
if isinstance(visible, list):
|
||||
return [slug for slug in visible if isinstance(slug, str) and is_valid_skill_slug(slug)]
|
||||
|
||||
selected = getattr(runtime.context, "skills", None) or []
|
||||
return normalize_selected_skills(selected)
|
||||
@ -12,6 +12,7 @@ from langgraph.graph.state import CompiledStateGraph
|
||||
|
||||
from src import config as sys_config
|
||||
from src.agents.common.context import BaseContext
|
||||
from src.services.skill_resolver import get_skill_options_from_db
|
||||
from src.utils import logger
|
||||
|
||||
|
||||
@ -43,9 +44,15 @@ class BaseAgent:
|
||||
"""Get the agent's class name."""
|
||||
return self.__class__.__name__
|
||||
|
||||
async def get_info(self):
|
||||
async def get_info(self, include_configurable_items: bool = True):
|
||||
# Load metadata from file
|
||||
metadata = self.load_metadata()
|
||||
configurable_items = {}
|
||||
if include_configurable_items:
|
||||
configurable_items = self.context_schema.get_configurable_items()
|
||||
if "skills" in configurable_items:
|
||||
configurable_items["skills"] = dict(configurable_items["skills"])
|
||||
configurable_items["skills"]["options"] = await get_skill_options_from_db()
|
||||
|
||||
# Merge metadata with class attributes, metadata takes precedence
|
||||
return {
|
||||
@ -53,7 +60,7 @@ class BaseAgent:
|
||||
"name": metadata.get("name", getattr(self, "name", "Unknown")),
|
||||
"description": metadata.get("description", getattr(self, "description", "Unknown")),
|
||||
"examples": metadata.get("examples", []),
|
||||
"configurable_items": self.context_schema.get_configurable_items(),
|
||||
"configurable_items": configurable_items,
|
||||
"has_checkpointer": await self.check_checkpointer(),
|
||||
"capabilities": getattr(self, "capabilities", []), # 智能体能力列表
|
||||
}
|
||||
|
||||
@ -91,6 +91,16 @@ class BaseContext:
|
||||
},
|
||||
)
|
||||
|
||||
skills: Annotated[list[str], {"__template_metadata__": {"kind": "skills"}}] = field(
|
||||
default_factory=list,
|
||||
metadata={
|
||||
"name": "Skills",
|
||||
"options": [],
|
||||
"description": "可选技能列表(由超级管理员维护)。运行时仅挂载并只读暴露选中的 skills。",
|
||||
"type": "list",
|
||||
},
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_file(cls, module_name: str, input_context: dict = None) -> "BaseContext":
|
||||
"""Load configuration from a YAML file. 用于持久化配置"""
|
||||
|
||||
@ -10,15 +10,17 @@ from typing import NotRequired
|
||||
|
||||
from langchain.agents import AgentState
|
||||
from langchain.agents.middleware import AgentMiddleware, ModelRequest, ModelResponse
|
||||
from langchain_core.messages import SystemMessage
|
||||
|
||||
from src.utils import logger
|
||||
|
||||
ATTACHMENT_PROMPT_MARKER = "<!-- attachment_context -->"
|
||||
|
||||
|
||||
class AttachmentState(AgentState):
|
||||
"""扩展 AgentState 以支持附件"""
|
||||
|
||||
attachments: NotRequired[list[dict]]
|
||||
files: NotRequired[dict[str, str]] # {"/attachments/xxx/file.md": content}
|
||||
|
||||
|
||||
def _build_attachment_prompt(attachments: Sequence[dict]) -> str | None:
|
||||
@ -61,7 +63,7 @@ class AttachmentMiddleware(AgentMiddleware[AttachmentState]):
|
||||
LangChain 标准中间件:从 State 中读取附件并注入提示词。
|
||||
|
||||
LangGraph 会自动从 checkpointer 恢复 state,包括 attachments。
|
||||
从 request.state 中读取附件,将其转换为 SystemMessage 并注入到消息列表开头。
|
||||
从 request.state 中读取附件,将其转换为上下文块 并注入到系统提示词中。
|
||||
"""
|
||||
|
||||
state_schema = AttachmentState
|
||||
@ -79,23 +81,21 @@ class AttachmentMiddleware(AgentMiddleware[AttachmentState]):
|
||||
|
||||
if attachment_prompt:
|
||||
logger.info("AttachmentMiddleware: injecting attachment prompt")
|
||||
existing_blocks = list(request.system_message.content_blocks) if request.system_message else []
|
||||
existing_text = "\n".join(
|
||||
block.get("text", "")
|
||||
for block in existing_blocks
|
||||
if isinstance(block, dict) and block.get("type") == "text"
|
||||
)
|
||||
|
||||
messages = list(request.messages)
|
||||
insert_idx = 0
|
||||
for idx, msg in enumerate(messages):
|
||||
if isinstance(msg, dict):
|
||||
role = msg.get("role") or msg.get("type")
|
||||
is_system = role == "system"
|
||||
else:
|
||||
msg_type = getattr(msg, "type", None) or getattr(msg, "role", None)
|
||||
is_system = msg_type == "system"
|
||||
if ATTACHMENT_PROMPT_MARKER in existing_text:
|
||||
logger.info("AttachmentMiddleware: attachment prompt already injected, skip")
|
||||
return await handler(request)
|
||||
|
||||
if not is_system:
|
||||
break
|
||||
insert_idx = idx + 1
|
||||
|
||||
messages.insert(insert_idx, {"role": "system", "content": attachment_prompt})
|
||||
request = request.override(messages=messages)
|
||||
merged_blocks = existing_blocks + [
|
||||
{"type": "text", "text": f"{ATTACHMENT_PROMPT_MARKER}\n{attachment_prompt}"}
|
||||
]
|
||||
request = request.override(system_message=SystemMessage(content=merged_blocks))
|
||||
|
||||
return await handler(request)
|
||||
|
||||
|
||||
@ -1,18 +1,52 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
from pathlib import PurePosixPath
|
||||
from typing import Annotated, Any, NotRequired
|
||||
|
||||
from deepagents.middleware.skills import SKILLS_SYSTEM_PROMPT
|
||||
from langchain.agents import AgentState
|
||||
from langchain.agents.middleware import AgentMiddleware, ModelRequest, ModelResponse
|
||||
from langchain_core.messages import SystemMessage
|
||||
from langchain.tools.tool_node import ToolCallRequest
|
||||
from langchain_core.messages import SystemMessage, ToolMessage
|
||||
from langgraph.types import Command
|
||||
|
||||
from src.agents.common import load_chat_model
|
||||
from src.agents.common.tools import get_buildin_tools, get_kb_based_tools
|
||||
from src.services.mcp_service import get_enabled_mcp_tools
|
||||
from src.services.skill_resolver import (
|
||||
SkillSessionSnapshot,
|
||||
build_dependency_bundle,
|
||||
collect_prompt_metadata,
|
||||
is_snapshot_match_selected_skills,
|
||||
normalize_selected_skills,
|
||||
resolve_session_snapshot,
|
||||
)
|
||||
from src.services.skill_service import is_valid_skill_slug
|
||||
from src.utils.datetime_utils import shanghai_now
|
||||
from src.utils.logging_config import logger
|
||||
|
||||
|
||||
def _activated_skills_reducer(left: list[str] | None, right: list[str] | None) -> list[str]:
|
||||
merged: list[str] = []
|
||||
seen: set[str] = set()
|
||||
for group in (left or [], right or []):
|
||||
for value in group:
|
||||
if not isinstance(value, str):
|
||||
continue
|
||||
slug = value.strip()
|
||||
if not slug or slug in seen:
|
||||
continue
|
||||
seen.add(slug)
|
||||
merged.append(slug)
|
||||
return merged
|
||||
|
||||
|
||||
class RuntimeConfigState(AgentState):
|
||||
activated_skills: NotRequired[Annotated[list[str], _activated_skills_reducer]]
|
||||
skill_session_snapshot: NotRequired[SkillSessionSnapshot]
|
||||
|
||||
|
||||
class RuntimeConfigMiddleware(AgentMiddleware):
|
||||
"""运行时配置中间件 - 应用模型/工具/知识库/MCP/提示词配置
|
||||
|
||||
@ -21,6 +55,7 @@ class RuntimeConfigMiddleware(AgentMiddleware):
|
||||
|
||||
支持自定义上下文字段名称,以便在不同场景(如主智能体/子智能体)使用不同的配置字段
|
||||
"""
|
||||
state_schema = RuntimeConfigState
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@ -31,9 +66,12 @@ class RuntimeConfigMiddleware(AgentMiddleware):
|
||||
tools_context_name: str = "tools",
|
||||
knowledges_context_name: str = "knowledges",
|
||||
mcps_context_name: str = "mcps",
|
||||
skills_context_name: str = "skills",
|
||||
enable_model_override: bool = True,
|
||||
enable_system_prompt_override: bool = True,
|
||||
enable_tools_override: bool = True,
|
||||
enable_skills_prompt_override: bool = True,
|
||||
skills_sources_for_prompt: list[str] | None = None,
|
||||
):
|
||||
"""初始化中间件
|
||||
|
||||
@ -44,9 +82,12 @@ class RuntimeConfigMiddleware(AgentMiddleware):
|
||||
tools_context_name: 上下文中的工具列表字段名称(默认 "tools")
|
||||
knowledges_context_name: 上下文中的知识库列表字段名称(默认 "knowledges")
|
||||
mcps_context_name: 上下文中的 MCP 服务器列表字段名称(默认 "mcps")
|
||||
skills_context_name: 上下文中的 skills 列表字段名称(默认 "skills")
|
||||
enable_model_override: 是否允许覆盖模型配置(默认 True)
|
||||
enable_system_prompt_override: 是否允许覆盖系统提示词(默认 True)
|
||||
enable_tools_override: 是否允许覆盖工具列表(默认 True)
|
||||
enable_skills_prompt_override: 是否启用 skills 提示段注入(默认 True)
|
||||
skills_sources_for_prompt: skills 来源路径(用于提示词展示,默认 ["/skills/"])
|
||||
"""
|
||||
super().__init__()
|
||||
# 存储自定义字段名称
|
||||
@ -55,10 +96,13 @@ class RuntimeConfigMiddleware(AgentMiddleware):
|
||||
self.tools_context_name = tools_context_name
|
||||
self.knowledges_context_name = knowledges_context_name
|
||||
self.mcps_context_name = mcps_context_name
|
||||
self.skills_context_name = skills_context_name
|
||||
# 存储覆盖配置
|
||||
self.enable_model_override = enable_model_override
|
||||
self.enable_system_prompt_override = enable_system_prompt_override
|
||||
self.enable_tools_override = enable_tools_override
|
||||
self.enable_skills_prompt_override = enable_skills_prompt_override
|
||||
self.skills_sources_for_prompt = skills_sources_for_prompt or ["/skills/"]
|
||||
|
||||
self.tools: list[Any] = []
|
||||
# 预加载工具列表(仅当启用工具覆盖时)
|
||||
@ -75,13 +119,15 @@ class RuntimeConfigMiddleware(AgentMiddleware):
|
||||
logger.debug(
|
||||
f"Initialized RuntimeConfigMiddleware with custom field names: model={model_context_name}, "
|
||||
f"system_prompt={system_prompt_context_name}, tools={tools_context_name}, "
|
||||
f"knowledges={knowledges_context_name}, mcps={mcps_context_name}"
|
||||
f"knowledges={knowledges_context_name}, mcps={mcps_context_name}, "
|
||||
f"skills={skills_context_name}"
|
||||
)
|
||||
|
||||
async def awrap_model_call(
|
||||
self, request: ModelRequest, handler: Callable[[ModelRequest], ModelResponse]
|
||||
) -> ModelResponse:
|
||||
runtime_context = request.runtime.context
|
||||
snapshot, request = await self._ensure_skill_snapshot(request)
|
||||
overrides: dict[str, Any] = {}
|
||||
|
||||
# 1. 模型覆盖(可选)
|
||||
@ -91,13 +137,24 @@ class RuntimeConfigMiddleware(AgentMiddleware):
|
||||
|
||||
# 2. 工具覆盖(可选)
|
||||
if self.enable_tools_override:
|
||||
enabled_tools = await self.get_tools_from_context(runtime_context)
|
||||
state = request.state if isinstance(request.state, dict) else {}
|
||||
activated_skills = state.get("activated_skills", [])
|
||||
if not isinstance(activated_skills, list):
|
||||
activated_skills = []
|
||||
deps_bundle = build_dependency_bundle(snapshot, activated_skills)
|
||||
enabled_tools = await self.get_tools_from_context(
|
||||
runtime_context,
|
||||
extra_tool_names=deps_bundle["tools"],
|
||||
extra_mcps=deps_bundle["mcps"],
|
||||
)
|
||||
existing_tools = list(request.tools or [])
|
||||
enabled_tool_names = {t.name for t in enabled_tools}
|
||||
managed_tool_names = {t.name for t in self.tools}
|
||||
merged_tools = []
|
||||
for t_bind in existing_tools:
|
||||
# (1) 已启用的工具保留
|
||||
# (2) 非本中间件管理的工具保留
|
||||
if t_bind.name in [t.name for t in enabled_tools] or t_bind.name not in [t.name for t in self.tools]:
|
||||
if t_bind.name in enabled_tool_names or t_bind.name not in managed_tool_names:
|
||||
merged_tools.append(t_bind)
|
||||
overrides["tools"] = merged_tools
|
||||
logger.debug(f"RuntimeConfigMiddleware selected tools: {[t.name for t in merged_tools]}")
|
||||
@ -106,9 +163,24 @@ class RuntimeConfigMiddleware(AgentMiddleware):
|
||||
if self.enable_system_prompt_override:
|
||||
cur_datetime = f"当前时间:{shanghai_now().strftime('%Y-%m-%d %H:%M:%S')} UTC"
|
||||
system_prompt = getattr(runtime_context, self.system_prompt_context_name, "") or ""
|
||||
new_content = list(request.system_message.content_blocks) + [
|
||||
{"type": "text", "text": f"{cur_datetime}\n\n{system_prompt}"}
|
||||
]
|
||||
merged_system_prompt = f"{cur_datetime}\n\n{system_prompt}"
|
||||
|
||||
configured_skills = getattr(runtime_context, self.skills_context_name, None) or []
|
||||
if self.enable_skills_prompt_override and configured_skills:
|
||||
if self._supports_skill_prompt(request):
|
||||
skills_for_prompt = configured_skills
|
||||
if snapshot and isinstance(snapshot.get("visible_skills"), list):
|
||||
skills_for_prompt = snapshot.get("visible_skills") or []
|
||||
skills_meta = collect_prompt_metadata(snapshot, skills_for_prompt)
|
||||
skills_section = self._build_skills_section(skills_meta)
|
||||
merged_system_prompt = f"{merged_system_prompt}\n\n{skills_section}"
|
||||
else:
|
||||
logger.warning(
|
||||
"RuntimeConfigMiddleware: skills configured but read_file unavailable, skip skills prompt"
|
||||
)
|
||||
|
||||
content_blocks = list(request.system_message.content_blocks) if request.system_message else []
|
||||
new_content = content_blocks + [{"type": "text", "text": merged_system_prompt}]
|
||||
new_system_message = SystemMessage(content=new_content)
|
||||
overrides["system_message"] = new_system_message
|
||||
|
||||
@ -117,17 +189,36 @@ class RuntimeConfigMiddleware(AgentMiddleware):
|
||||
|
||||
return await handler(request)
|
||||
|
||||
async def get_tools_from_context(self, context) -> list:
|
||||
async def get_tools_from_context(
|
||||
self,
|
||||
context,
|
||||
*,
|
||||
extra_tool_names: list[str] | None = None,
|
||||
extra_mcps: list[str] | None = None,
|
||||
) -> list:
|
||||
"""从上下文配置中获取工具列表"""
|
||||
selected_tools = []
|
||||
selected_tool_names: set[str] = set()
|
||||
|
||||
# 1. 基础工具 (从 context.tools 中筛选)
|
||||
tools = getattr(context, self.tools_context_name, None)
|
||||
if tools:
|
||||
tools_map = {t.name: t for t in self.tools}
|
||||
for tool_name in tools:
|
||||
if tool_name in tools_map:
|
||||
selected_tools.append(tools_map[tool_name])
|
||||
tools = getattr(context, self.tools_context_name, None) or []
|
||||
all_tool_names = []
|
||||
for tool_name in tools:
|
||||
if isinstance(tool_name, str):
|
||||
all_tool_names.append(tool_name)
|
||||
for tool_name in extra_tool_names or []:
|
||||
if isinstance(tool_name, str):
|
||||
all_tool_names.append(tool_name)
|
||||
|
||||
tools_map = {t.name: t for t in self.tools}
|
||||
for tool_name in all_tool_names:
|
||||
if tool_name in selected_tool_names:
|
||||
continue
|
||||
if tool_name in tools_map:
|
||||
selected_tools.append(tools_map[tool_name])
|
||||
selected_tool_names.add(tool_name)
|
||||
continue
|
||||
logger.warning(f"RuntimeConfigMiddleware: tool dependency not found, skip: {tool_name}")
|
||||
|
||||
# 2. 知识库工具
|
||||
knowledges = getattr(context, self.knowledges_context_name, None)
|
||||
@ -136,10 +227,175 @@ class RuntimeConfigMiddleware(AgentMiddleware):
|
||||
selected_tools.extend(kb_tools)
|
||||
|
||||
# 3. MCP 工具(使用统一入口,自动过滤 disabled_tools)
|
||||
mcps = getattr(context, self.mcps_context_name, None)
|
||||
if mcps:
|
||||
for server_name in mcps:
|
||||
mcps = getattr(context, self.mcps_context_name, None) or []
|
||||
all_mcp_names: list[str] = []
|
||||
for server_name in mcps:
|
||||
if isinstance(server_name, str):
|
||||
all_mcp_names.append(server_name)
|
||||
for server_name in extra_mcps or []:
|
||||
if isinstance(server_name, str):
|
||||
all_mcp_names.append(server_name)
|
||||
|
||||
selected_mcp_servers: set[str] = set()
|
||||
for server_name in all_mcp_names:
|
||||
if server_name in selected_mcp_servers:
|
||||
continue
|
||||
selected_mcp_servers.add(server_name)
|
||||
try:
|
||||
mcp_tools = await get_enabled_mcp_tools(server_name)
|
||||
if not mcp_tools:
|
||||
logger.warning(f"RuntimeConfigMiddleware: mcp dependency unavailable, skip: {server_name}")
|
||||
selected_tools.extend(mcp_tools)
|
||||
except Exception as e:
|
||||
logger.warning(f"RuntimeConfigMiddleware: failed to load mcp dependency '{server_name}': {e}")
|
||||
|
||||
return selected_tools
|
||||
|
||||
async def awrap_tool_call(
|
||||
self,
|
||||
request: ToolCallRequest,
|
||||
handler: Callable[[ToolCallRequest], Any],
|
||||
):
|
||||
result = await handler(request)
|
||||
if request.tool_call.get("name") != "read_file":
|
||||
return result
|
||||
|
||||
args = request.tool_call.get("args") or {}
|
||||
file_path = args.get("file_path") if isinstance(args, dict) else None
|
||||
slug = self._extract_skill_slug_from_skill_md_path(file_path)
|
||||
if not slug:
|
||||
return result
|
||||
if not self._is_visible_skill_slug(request, slug):
|
||||
logger.warning(f"RuntimeConfigMiddleware: deny skill activation for invisible slug: {slug}")
|
||||
return result
|
||||
logger.debug(f"RuntimeConfigMiddleware: activated skill by read_file: {slug}")
|
||||
return self._merge_activated_skill_update(result, slug)
|
||||
|
||||
def wrap_tool_call(
|
||||
self,
|
||||
request: ToolCallRequest,
|
||||
handler: Callable[[ToolCallRequest], Any],
|
||||
):
|
||||
result = handler(request)
|
||||
if request.tool_call.get("name") != "read_file":
|
||||
return result
|
||||
|
||||
args = request.tool_call.get("args") or {}
|
||||
file_path = args.get("file_path") if isinstance(args, dict) else None
|
||||
slug = self._extract_skill_slug_from_skill_md_path(file_path)
|
||||
if not slug:
|
||||
return result
|
||||
if not self._is_visible_skill_slug(request, slug):
|
||||
logger.warning(f"RuntimeConfigMiddleware: deny skill activation for invisible slug: {slug}")
|
||||
return result
|
||||
logger.debug(f"RuntimeConfigMiddleware: activated skill by read_file: {slug}")
|
||||
return self._merge_activated_skill_update(result, slug)
|
||||
|
||||
async def _ensure_skill_snapshot(self, request: ModelRequest) -> tuple[SkillSessionSnapshot | None, ModelRequest]:
|
||||
runtime_context = request.runtime.context
|
||||
configured_skills = getattr(runtime_context, self.skills_context_name, None) or []
|
||||
normalized_skills = normalize_selected_skills(configured_skills)
|
||||
state = request.state if isinstance(request.state, dict) else {}
|
||||
|
||||
snapshot = self._get_skill_snapshot_from_state(state)
|
||||
if is_snapshot_match_selected_skills(snapshot, normalized_skills):
|
||||
return snapshot, request
|
||||
|
||||
try:
|
||||
snapshot = await resolve_session_snapshot(normalized_skills)
|
||||
except Exception as e:
|
||||
logger.warning(f"RuntimeConfigMiddleware: failed to resolve skill snapshot, fallback empty: {e}")
|
||||
snapshot = None
|
||||
|
||||
if isinstance(request.state, dict):
|
||||
if snapshot:
|
||||
request.state["skill_session_snapshot"] = snapshot
|
||||
else:
|
||||
request.state.pop("skill_session_snapshot", None)
|
||||
|
||||
return snapshot, request
|
||||
|
||||
def _extract_skill_slug_from_skill_md_path(self, file_path: Any) -> str | None:
|
||||
if not isinstance(file_path, str):
|
||||
return None
|
||||
raw = file_path.strip()
|
||||
if not raw:
|
||||
return None
|
||||
pure = PurePosixPath(raw if raw.startswith("/") else f"/{raw}")
|
||||
parts = [p for p in pure.parts if p not in ("/", "")]
|
||||
if len(parts) != 3:
|
||||
return None
|
||||
if parts[0] != "skills" or parts[2] != "SKILL.md":
|
||||
return None
|
||||
slug = parts[1]
|
||||
if not is_valid_skill_slug(slug):
|
||||
return None
|
||||
return slug
|
||||
|
||||
def _merge_activated_skill_update(self, result: Any, slug: str):
|
||||
if isinstance(result, Command):
|
||||
update = dict(result.update or {})
|
||||
current = update.get("activated_skills") or []
|
||||
update["activated_skills"] = _activated_skills_reducer(current, [slug])
|
||||
return Command(graph=result.graph, update=update, resume=result.resume, goto=result.goto)
|
||||
|
||||
if isinstance(result, ToolMessage):
|
||||
return Command(update={"messages": [result], "activated_skills": [slug]})
|
||||
|
||||
return result
|
||||
|
||||
def _is_visible_skill_slug(self, request: ToolCallRequest, slug: str) -> bool:
|
||||
snapshot = self._get_skill_snapshot_from_state(request.state)
|
||||
if snapshot:
|
||||
visible_skills = snapshot.get("visible_skills")
|
||||
if isinstance(visible_skills, list):
|
||||
return slug in visible_skills
|
||||
|
||||
configured_skills = getattr(request.runtime.context, self.skills_context_name, None) or []
|
||||
normalized = normalize_selected_skills(configured_skills)
|
||||
return slug in normalized
|
||||
|
||||
def _get_skill_snapshot_from_state(self, state: Any) -> SkillSessionSnapshot | None:
|
||||
if not isinstance(state, dict):
|
||||
return None
|
||||
snapshot = state.get("skill_session_snapshot")
|
||||
if not isinstance(snapshot, dict):
|
||||
return None
|
||||
visible_skills = snapshot.get("visible_skills")
|
||||
selected_skills = snapshot.get("selected_skills")
|
||||
if not isinstance(visible_skills, list) or not isinstance(selected_skills, list):
|
||||
return None
|
||||
return snapshot
|
||||
|
||||
def _supports_skill_prompt(self, request: ModelRequest) -> bool:
|
||||
"""仅当请求工具中包含 read_file 时,才注入 skills 指引。"""
|
||||
for tool in request.tools or []:
|
||||
if getattr(tool, "name", None) == "read_file":
|
||||
return True
|
||||
return False
|
||||
|
||||
def _format_skills_locations(self, sources: list[str]) -> str:
|
||||
locations = []
|
||||
for i, source_path in enumerate(sources):
|
||||
name = PurePosixPath(source_path.rstrip("/")).name.capitalize()
|
||||
suffix = " (higher priority)" if i == len(sources) - 1 else ""
|
||||
locations.append(f"**{name} Skills**: `{source_path}`{suffix}")
|
||||
return "\n".join(locations)
|
||||
|
||||
def _format_skills_list(self, skills_meta: list[dict[str, str]]) -> str:
|
||||
if not skills_meta:
|
||||
return f"(No skills available yet. You can create skills in {' or '.join(self.skills_sources_for_prompt)})"
|
||||
|
||||
lines = []
|
||||
for skill in skills_meta:
|
||||
lines.append(f"- **{skill['name']}**: {skill['description']}")
|
||||
lines.append(f" -> Read `{skill['path']}` for full instructions")
|
||||
return "\n".join(lines)
|
||||
|
||||
def _build_skills_section(self, skills_meta: list[dict[str, str]]) -> str:
|
||||
skills_locations = self._format_skills_locations(self.skills_sources_for_prompt)
|
||||
skills_list = self._format_skills_list(skills_meta)
|
||||
return SKILLS_SYSTEM_PROMPT.format(
|
||||
skills_locations=skills_locations,
|
||||
skills_list=skills_list,
|
||||
)
|
||||
|
||||
@ -1,6 +1,5 @@
|
||||
"""Deep Agent - 基于create_deep_agent的深度分析智能体"""
|
||||
|
||||
from deepagents.backends import StateBackend
|
||||
from deepagents.middleware.filesystem import FilesystemMiddleware
|
||||
from deepagents.middleware.patch_tool_calls import PatchToolCallsMiddleware
|
||||
from deepagents.middleware.subagents import SubAgentMiddleware
|
||||
@ -10,16 +9,18 @@ from langchain.agents.middleware import (
|
||||
)
|
||||
|
||||
from src.agents.common import BaseAgent, load_chat_model
|
||||
from src.agents.common.backends import create_agent_composite_backend
|
||||
from src.agents.common.middlewares import RuntimeConfigMiddleware, SummaryOffloadMiddleware, save_attachments_to_fs
|
||||
from src.agents.common.tools import get_tavily_search
|
||||
from src.services.mcp_service import get_tools_from_all_servers
|
||||
|
||||
|
||||
from .context import DeepContext
|
||||
|
||||
|
||||
def _create_fs_backend(rt):
|
||||
"""创建文件存储后端"""
|
||||
return StateBackend(rt)
|
||||
return create_agent_composite_backend(rt)
|
||||
|
||||
|
||||
def _get_research_sub_agent(search_tools: list) -> dict:
|
||||
|
||||
@ -3,6 +3,10 @@ import os
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Any
|
||||
|
||||
from src.knowledge.chunking.ragflow_like.presets import (
|
||||
ensure_chunk_defaults_in_additional_params,
|
||||
resolve_chunk_processing_params,
|
||||
)
|
||||
from src.utils import logger
|
||||
from src.utils.datetime_utils import coerce_any_to_utc_datetime, utc_isoformat
|
||||
|
||||
@ -76,6 +80,7 @@ class KnowledgeBase(ABC):
|
||||
self.databases_meta = {}
|
||||
for db_id, meta in global_databases_meta.items():
|
||||
if meta.get("kb_type") == self.kb_type:
|
||||
normalized_additional_params = ensure_chunk_defaults_in_additional_params(meta.get("additional_params"))
|
||||
self.databases_meta[db_id] = {
|
||||
"name": meta.get("name"),
|
||||
"description": meta.get("description"),
|
||||
@ -83,7 +88,7 @@ class KnowledgeBase(ABC):
|
||||
"embed_info": meta.get("embed_info"),
|
||||
"llm_info": meta.get("llm_info"),
|
||||
"query_params": meta.get("query_params"),
|
||||
"metadata": meta.get("additional_params", {}),
|
||||
"metadata": normalized_additional_params,
|
||||
"created_at": meta.get("created_at"),
|
||||
}
|
||||
|
||||
@ -91,7 +96,14 @@ class KnowledgeBase(ABC):
|
||||
self.files_meta = {}
|
||||
for file_id, meta in files_meta.items():
|
||||
if meta.get("database_id") in self.databases_meta:
|
||||
self.files_meta[file_id] = meta
|
||||
db_id = meta.get("database_id")
|
||||
kb_additional_params = self.databases_meta.get(db_id, {}).get("metadata") or {}
|
||||
normalized_meta = dict(meta)
|
||||
normalized_meta["processing_params"] = resolve_chunk_processing_params(
|
||||
kb_additional_params=kb_additional_params,
|
||||
file_processing_params=meta.get("processing_params"),
|
||||
)
|
||||
self.files_meta[file_id] = normalized_meta
|
||||
|
||||
# 过滤评估基准
|
||||
self.benchmarks_meta = {}
|
||||
@ -199,6 +211,11 @@ class KnowledgeBase(ABC):
|
||||
# Prepare metadata
|
||||
metadata = await prepare_item_metadata(item, content_type, db_id, params=params)
|
||||
file_id = metadata["file_id"]
|
||||
kb_additional_params = self.databases_meta.get(db_id, {}).get("metadata") or {}
|
||||
metadata["processing_params"] = resolve_chunk_processing_params(
|
||||
kb_additional_params=kb_additional_params,
|
||||
file_processing_params=metadata.get("processing_params"),
|
||||
)
|
||||
|
||||
# Initial status
|
||||
metadata["status"] = FileStatus.UPLOADED
|
||||
@ -323,13 +340,16 @@ class KnowledgeBase(ABC):
|
||||
if not params:
|
||||
return
|
||||
|
||||
# Merge or overwrite? Usually merge is safer, or replace.
|
||||
# User might want to change chunk size.
|
||||
current_params = self.files_meta[file_id].get("processing_params", {}) or {}
|
||||
kb_additional_params = self.databases_meta.get(db_id, {}).get("metadata") or {}
|
||||
|
||||
logger.debug(f"[update_file_params] file_id={file_id}, current_params={current_params}, new_params={params}")
|
||||
|
||||
current_params.update(params)
|
||||
current_params = resolve_chunk_processing_params(
|
||||
kb_additional_params=kb_additional_params,
|
||||
file_processing_params=current_params,
|
||||
request_params=params,
|
||||
)
|
||||
|
||||
self.files_meta[file_id]["processing_params"] = current_params
|
||||
self.files_meta[file_id]["updated_at"] = utc_isoformat()
|
||||
@ -414,6 +434,8 @@ class KnowledgeBase(ABC):
|
||||
"""
|
||||
from src.utils import hashstr
|
||||
|
||||
kwargs = ensure_chunk_defaults_in_additional_params(kwargs)
|
||||
|
||||
# 从 kwargs 中获取 is_private 配置
|
||||
is_private = kwargs.get("is_private", False)
|
||||
prefix = "kb_private_" if is_private else "kb_"
|
||||
@ -982,7 +1004,7 @@ class KnowledgeBase(ABC):
|
||||
"embed_info": kb.embed_info,
|
||||
"llm_info": kb.llm_info,
|
||||
"query_params": kb.query_params,
|
||||
"metadata": kb.additional_params or {},
|
||||
"metadata": ensure_chunk_defaults_in_additional_params(kb.additional_params),
|
||||
"created_at": utc_isoformat(kb.created_at) if kb.created_at else utc_isoformat(),
|
||||
}
|
||||
for kb in databases
|
||||
@ -990,6 +1012,7 @@ class KnowledgeBase(ABC):
|
||||
|
||||
self.files_meta = {}
|
||||
for kb in databases:
|
||||
kb_additional_params = self.databases_meta.get(kb.db_id, {}).get("metadata") or {}
|
||||
for record in await file_repo.list_by_db_id(kb.db_id):
|
||||
self.files_meta[record.file_id] = {
|
||||
"file_id": record.file_id,
|
||||
@ -1003,7 +1026,10 @@ class KnowledgeBase(ABC):
|
||||
"content_hash": record.content_hash,
|
||||
"size": record.file_size,
|
||||
"content_type": record.content_type,
|
||||
"processing_params": record.processing_params,
|
||||
"processing_params": resolve_chunk_processing_params(
|
||||
kb_additional_params=kb_additional_params,
|
||||
file_processing_params=record.processing_params,
|
||||
),
|
||||
"is_folder": record.is_folder,
|
||||
"error": record.error_message,
|
||||
"created_by": record.created_by,
|
||||
|
||||
1
src/knowledge/chunking/__init__.py
Normal file
1
src/knowledge/chunking/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
__all__ = []
|
||||
3
src/knowledge/chunking/ragflow_like/__init__.py
Normal file
3
src/knowledge/chunking/ragflow_like/__init__.py
Normal file
@ -0,0 +1,3 @@
|
||||
from src.knowledge.chunking.ragflow_like.dispatcher import chunk_file, chunk_markdown
|
||||
|
||||
__all__ = ["chunk_file", "chunk_markdown"]
|
||||
58
src/knowledge/chunking/ragflow_like/dispatcher.py
Normal file
58
src/knowledge/chunking/ragflow_like/dispatcher.py
Normal file
@ -0,0 +1,58 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from src.knowledge.chunking.ragflow_like.parsers import book, general, laws, qa
|
||||
from src.knowledge.chunking.ragflow_like.presets import map_to_internal_parser_id, normalize_chunk_preset_id
|
||||
|
||||
|
||||
def _build_chunk_records(text_chunks: list[str], file_id: str, filename: str) -> list[dict[str, Any]]:
|
||||
records: list[dict[str, Any]] = []
|
||||
|
||||
for idx, chunk_content in enumerate(text_chunks):
|
||||
text = (chunk_content or "").strip()
|
||||
if not text:
|
||||
continue
|
||||
|
||||
records.append(
|
||||
{
|
||||
"id": f"{file_id}_chunk_{idx}",
|
||||
"content": text,
|
||||
"file_id": file_id,
|
||||
"filename": filename,
|
||||
"chunk_index": idx,
|
||||
"source": filename,
|
||||
"chunk_id": f"{file_id}_chunk_{idx}",
|
||||
}
|
||||
)
|
||||
|
||||
return records
|
||||
|
||||
|
||||
def _dispatch_markdown_parser(preset_id: str, filename: str, markdown_content: str, parser_config: dict[str, Any]) -> list[str]:
|
||||
parser_id = map_to_internal_parser_id(preset_id)
|
||||
|
||||
if parser_id == "naive":
|
||||
return general.chunk_markdown(markdown_content, parser_config)
|
||||
if parser_id == "qa":
|
||||
return qa.chunk_markdown(filename, markdown_content, parser_config)
|
||||
if parser_id == "book":
|
||||
return book.chunk_markdown(markdown_content, parser_config)
|
||||
if parser_id == "laws":
|
||||
return laws.chunk_markdown(filename, markdown_content, parser_config)
|
||||
|
||||
return general.chunk_markdown(markdown_content, parser_config)
|
||||
|
||||
|
||||
def chunk_markdown(markdown_content: str, file_id: str, filename: str, processing_params: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
params = dict(processing_params or {})
|
||||
preset_id = normalize_chunk_preset_id(params.get("chunk_preset_id"))
|
||||
parser_config = params.get("chunk_parser_config") if isinstance(params.get("chunk_parser_config"), dict) else {}
|
||||
|
||||
text_chunks = _dispatch_markdown_parser(preset_id, filename, markdown_content, parser_config)
|
||||
return _build_chunk_records(text_chunks, file_id, filename)
|
||||
|
||||
|
||||
def chunk_file(file_content: str, file_id: str, filename: str, processing_params: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
# 当前链路中入库前均已转换为 markdown,因此与 chunk_markdown 保持同实现。
|
||||
return chunk_markdown(file_content, file_id, filename, processing_params)
|
||||
536
src/knowledge/chunking/ragflow_like/nlp.py
Normal file
536
src/knowledge/chunking/ragflow_like/nlp.py
Normal file
@ -0,0 +1,536 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import random
|
||||
import re
|
||||
from collections import Counter
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
|
||||
BULLET_PATTERN = [
|
||||
[
|
||||
r"第[零一二三四五六七八九十百0-9]+(分?编|部分)",
|
||||
r"第[零一二三四五六七八九十百0-9]+章",
|
||||
r"第[零一二三四五六七八九十百0-9]+节",
|
||||
r"第[零一二三四五六七八九十百0-9]+条",
|
||||
r"[\((][零一二三四五六七八九十百]+[\))]",
|
||||
],
|
||||
[
|
||||
r"第[0-9]+章",
|
||||
r"第[0-9]+节",
|
||||
r"[0-9]{,2}[\. 、]",
|
||||
r"[0-9]{,2}\.[0-9]{,2}[^a-zA-Z/%~-]",
|
||||
r"[0-9]{,2}\.[0-9]{,2}\.[0-9]{,2}",
|
||||
r"[0-9]{,2}\.[0-9]{,2}\.[0-9]{,2}\.[0-9]{,2}",
|
||||
],
|
||||
[
|
||||
r"第[零一二三四五六七八九十百0-9]+章",
|
||||
r"第[零一二三四五六七八九十百0-9]+节",
|
||||
r"[零一二三四五六七八九十百]+[ 、]",
|
||||
r"[\((][零一二三四五六七八九十百]+[\))]",
|
||||
r"[\((][0-9]{,2}[\))]",
|
||||
],
|
||||
[
|
||||
r"PART (ONE|TWO|THREE|FOUR|FIVE|SIX|SEVEN|EIGHT|NINE|TEN)",
|
||||
r"Chapter (I+V?|VI*|XI|IX|X)",
|
||||
r"Section [0-9]+",
|
||||
r"Article [0-9]+",
|
||||
],
|
||||
[
|
||||
r"^#[^#]",
|
||||
r"^##[^#]",
|
||||
r"^###.*",
|
||||
r"^####.*",
|
||||
r"^#####.*",
|
||||
r"^######.*",
|
||||
],
|
||||
]
|
||||
|
||||
MARKDOWN_BULLET_GROUP_INDEX = 4
|
||||
|
||||
|
||||
def count_tokens(text: str) -> int:
|
||||
"""近似 token 计数,避免引入额外依赖。"""
|
||||
if not text:
|
||||
return 0
|
||||
# 英文单词 + 数字 + CJK 单字
|
||||
parts = re.findall(r"[A-Za-z0-9_]+|[\u4e00-\u9fff]", text)
|
||||
return max(1, len(parts)) if text.strip() else 0
|
||||
|
||||
|
||||
def random_choices(arr: list[str], k: int) -> list[str]:
|
||||
if not arr:
|
||||
return []
|
||||
return random.choices(arr, k=min(len(arr), k))
|
||||
|
||||
|
||||
def is_english(texts: str | list[str]) -> bool:
|
||||
if not texts:
|
||||
return False
|
||||
|
||||
patt = re.compile(r"[`a-zA-Z0-9\s.,':;/\"?<>!\(\)\-]+")
|
||||
if isinstance(texts, str):
|
||||
seq = [texts]
|
||||
else:
|
||||
seq = [t for t in texts if isinstance(t, str) and t.strip()]
|
||||
|
||||
if not seq:
|
||||
return False
|
||||
|
||||
hits = sum(1 for t in seq if patt.fullmatch(t.strip()))
|
||||
return (hits / len(seq)) > 0.8
|
||||
|
||||
|
||||
def not_bullet(line: str) -> bool:
|
||||
patt = [
|
||||
r"0",
|
||||
r"[0-9]+ +[0-9~个只-]",
|
||||
r"[0-9]+\.{2,}",
|
||||
]
|
||||
return any(re.match(p, line) for p in patt)
|
||||
|
||||
|
||||
def is_probable_heading_line(line: str) -> bool:
|
||||
text = (line or "").strip()
|
||||
if not text:
|
||||
return False
|
||||
|
||||
if re.match(r"^#{1,6}\s+\S", text):
|
||||
return True
|
||||
|
||||
# 表格/HTML 残留通常不是标题。
|
||||
if re.search(r"</?(table|tr|td|th|caption|tbody|thead)[^>]*>", text, flags=re.IGNORECASE):
|
||||
return False
|
||||
|
||||
# 超长行基本是正文或条款,不是章节标题。
|
||||
if len(text) > 96:
|
||||
return False
|
||||
if count_tokens(text) > 72:
|
||||
return False
|
||||
|
||||
# 标题前段通常不会出现明显句号/逗号;出现则大概率是正文。
|
||||
if re.search(r"[,。;!?!?::]", text[:24]):
|
||||
return False
|
||||
|
||||
if text.endswith(("。", ";", "!", "!", "?", "?")) and len(text) > 20:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def _is_mid_sentence_bullet(line: str) -> bool:
|
||||
text = (line or "").strip()
|
||||
if not text:
|
||||
return False
|
||||
|
||||
if re.match(r"^#{1,6}\s+\S", text):
|
||||
return False
|
||||
|
||||
marker = re.search(
|
||||
r"([一二三四五六七八九十百]+、|[\((][一二三四五六七八九十百]+[\))]|[0-9]{1,2}[\.、])",
|
||||
text,
|
||||
)
|
||||
if not marker:
|
||||
return False
|
||||
|
||||
if marker.start() == 0:
|
||||
return False
|
||||
|
||||
prev = text[marker.start() - 1]
|
||||
return prev not in {"#", "\n"}
|
||||
|
||||
|
||||
def bullets_category(sections: list[str]) -> int:
|
||||
hits: list[float] = [0.0] * len(BULLET_PATTERN)
|
||||
|
||||
def bullet_weight(group_idx: int, line: str) -> float:
|
||||
# 对 markdown 标题候选增加权重,避免“正文里的 一、/(一)”压过真正的 # 标题层级。
|
||||
if group_idx != MARKDOWN_BULLET_GROUP_INDEX:
|
||||
return 1.0
|
||||
|
||||
heading = line.strip()
|
||||
if not re.match(r"^#{1,6}\s+\S", heading):
|
||||
return 1.0
|
||||
|
||||
level = len(heading) - len(heading.lstrip("#"))
|
||||
if level <= 2:
|
||||
return 4.0
|
||||
if level <= 4:
|
||||
return 3.0
|
||||
return 2.0
|
||||
|
||||
for i, pro in enumerate(BULLET_PATTERN):
|
||||
for sec in sections:
|
||||
sec = sec.strip()
|
||||
for p in pro:
|
||||
if re.match(p, sec) and not not_bullet(sec):
|
||||
w = bullet_weight(i, sec)
|
||||
if _is_mid_sentence_bullet(sec):
|
||||
w *= 0.1
|
||||
if i != MARKDOWN_BULLET_GROUP_INDEX and not is_probable_heading_line(sec):
|
||||
w *= 0.2
|
||||
hits[i] += w
|
||||
break
|
||||
maximum = 0
|
||||
res = -1
|
||||
for i, hit in enumerate(hits):
|
||||
if hit <= maximum:
|
||||
continue
|
||||
res = i
|
||||
maximum = hit
|
||||
return res
|
||||
|
||||
|
||||
def _get_text(section: str | tuple[str, str]) -> str:
|
||||
if isinstance(section, str):
|
||||
return section.strip()
|
||||
return (section[0] or "").strip()
|
||||
|
||||
|
||||
def remove_contents_table(sections: list[str] | list[tuple[str, str]], eng: bool = False) -> None:
|
||||
i = 0
|
||||
while i < len(sections):
|
||||
line = re.sub(r"( | |\u3000)+", "", _get_text(sections[i]).split("@@")[0], flags=re.IGNORECASE)
|
||||
if not re.match(r"(contents|目录|目次|tableofcontents|致谢|acknowledge)$", line, flags=re.IGNORECASE):
|
||||
i += 1
|
||||
continue
|
||||
|
||||
sections.pop(i)
|
||||
if i >= len(sections):
|
||||
break
|
||||
|
||||
prefix = _get_text(sections[i])[:3] if not eng else " ".join(_get_text(sections[i]).split()[:2])
|
||||
while not prefix and i < len(sections):
|
||||
sections.pop(i)
|
||||
if i >= len(sections):
|
||||
break
|
||||
prefix = _get_text(sections[i])[:3] if not eng else " ".join(_get_text(sections[i]).split()[:2])
|
||||
|
||||
if i >= len(sections) or not prefix:
|
||||
break
|
||||
|
||||
sections.pop(i)
|
||||
if i >= len(sections):
|
||||
break
|
||||
|
||||
for j in range(i, min(i + 128, len(sections))):
|
||||
if not re.match(re.escape(prefix), _get_text(sections[j])):
|
||||
continue
|
||||
for _ in range(i, j):
|
||||
sections.pop(i)
|
||||
break
|
||||
|
||||
|
||||
def make_colon_as_title(sections: list[str] | list[tuple[str, str]]) -> list[str] | list[tuple[str, str]]:
|
||||
if not sections:
|
||||
return sections
|
||||
if isinstance(sections[0], str):
|
||||
return sections
|
||||
|
||||
i = 0
|
||||
while i < len(sections):
|
||||
text, layout = sections[i]
|
||||
i += 1
|
||||
text = text.split("@")[0].strip()
|
||||
if not text or text[-1] not in "::":
|
||||
continue
|
||||
|
||||
rev = text[::-1]
|
||||
arr = re.split(r"([。?!!?;;]| \.)", rev)
|
||||
if len(arr) < 2 or len(arr[1]) < 32:
|
||||
continue
|
||||
|
||||
sections.insert(i - 1, (arr[0][::-1], "title"))
|
||||
i += 1
|
||||
|
||||
return sections
|
||||
|
||||
|
||||
def not_title(text: str) -> bool:
|
||||
if re.match(r"第[零一二三四五六七八九十百0-9]+条", text):
|
||||
return False
|
||||
if len(text.split()) > 12 or (" " not in text and len(text) >= 32):
|
||||
return True
|
||||
return bool(re.search(r"[,;,。;!!]", text))
|
||||
|
||||
|
||||
def tree_merge(bull: int, sections: list[str] | list[tuple[str, str]], depth: int) -> list[str]:
|
||||
if not sections or bull < 0:
|
||||
return [s if isinstance(s, str) else s[0] for s in sections]
|
||||
|
||||
if isinstance(sections[0], str):
|
||||
typed_sections: list[tuple[str, str]] = [(s, "") for s in sections]
|
||||
else:
|
||||
typed_sections = sections # type: ignore[assignment]
|
||||
|
||||
typed_sections = [
|
||||
(t, o)
|
||||
for t, o in typed_sections
|
||||
if t and len(t.split("@")[0].strip()) > 1 and not re.match(r"[0-9]+$", t.split("@")[0].strip())
|
||||
]
|
||||
|
||||
def get_level(section: tuple[str, str]) -> tuple[int, str]:
|
||||
text, layout = section
|
||||
text = re.sub(r"\u3000", " ", text).strip()
|
||||
|
||||
for i, patt in enumerate(BULLET_PATTERN[bull]):
|
||||
if re.match(patt, text) and is_probable_heading_line(text):
|
||||
return i + 1, text
|
||||
|
||||
if re.search(r"(title|head)", layout) and not not_title(text):
|
||||
return len(BULLET_PATTERN[bull]) + 1, text
|
||||
|
||||
return len(BULLET_PATTERN[bull]) + 2, text
|
||||
|
||||
lines: list[tuple[int, str]] = []
|
||||
level_set: set[int] = set()
|
||||
for section in typed_sections:
|
||||
level, text = get_level(section)
|
||||
if not text.strip("\n"):
|
||||
continue
|
||||
lines.append((level, text))
|
||||
level_set.add(level)
|
||||
|
||||
if not lines:
|
||||
return []
|
||||
|
||||
sorted_levels = sorted(level_set)
|
||||
target_level = sorted_levels[depth - 1] if depth <= len(sorted_levels) else sorted_levels[-1]
|
||||
|
||||
max_body_level = len(BULLET_PATTERN[bull]) + 2
|
||||
if target_level == max_body_level:
|
||||
target_level = sorted_levels[-2] if len(sorted_levels) > 1 else sorted_levels[0]
|
||||
|
||||
root = Node(level=0, depth=target_level, texts=[])
|
||||
root.build_tree(lines)
|
||||
return [item for item in root.get_tree() if item]
|
||||
|
||||
|
||||
def hierarchical_merge(bull: int, sections: list[str] | list[tuple[str, str]], depth: int) -> list[list[str]]:
|
||||
if not sections or bull < 0:
|
||||
return []
|
||||
|
||||
if isinstance(sections[0], str):
|
||||
typed_sections: list[tuple[str, str]] = [(s, "") for s in sections]
|
||||
else:
|
||||
typed_sections = sections # type: ignore[assignment]
|
||||
|
||||
typed_sections = [
|
||||
(t, o)
|
||||
for t, o in typed_sections
|
||||
if t and len(t.split("@")[0].strip()) > 1 and not re.match(r"[0-9]+$", t.split("@")[0].strip())
|
||||
]
|
||||
|
||||
bullets_size = len(BULLET_PATTERN[bull])
|
||||
levels: list[list[int]] = [[] for _ in range(bullets_size + 2)]
|
||||
|
||||
for i, (text, layout) in enumerate(typed_sections):
|
||||
for j, patt in enumerate(BULLET_PATTERN[bull]):
|
||||
if re.match(patt, text.strip()) and is_probable_heading_line(text):
|
||||
levels[j].append(i)
|
||||
break
|
||||
else:
|
||||
if re.search(r"(title|head)", layout) and not not_title(text):
|
||||
levels[bullets_size].append(i)
|
||||
else:
|
||||
levels[bullets_size + 1].append(i)
|
||||
|
||||
pure_sections = [t for t, _ in typed_sections]
|
||||
|
||||
def binary_search(arr: list[int], target: int) -> int:
|
||||
if not arr:
|
||||
return -1
|
||||
if target > arr[-1]:
|
||||
return len(arr) - 1
|
||||
if target < arr[0]:
|
||||
return -1
|
||||
|
||||
s, e = 0, len(arr)
|
||||
while e - s > 1:
|
||||
mid = (e + s) // 2
|
||||
if target > arr[mid]:
|
||||
s = mid
|
||||
elif target < arr[mid]:
|
||||
e = mid
|
||||
else:
|
||||
return mid
|
||||
return s
|
||||
|
||||
cks: list[list[int]] = []
|
||||
readed = [False] * len(pure_sections)
|
||||
levels = list(reversed(levels))
|
||||
for i, arr in enumerate(levels[:depth]):
|
||||
for j in arr:
|
||||
if readed[j]:
|
||||
continue
|
||||
readed[j] = True
|
||||
cks.append([j])
|
||||
if i + 1 == len(levels) - 1:
|
||||
continue
|
||||
|
||||
for ii in range(i + 1, len(levels)):
|
||||
jj = binary_search(levels[ii], j)
|
||||
if jj < 0:
|
||||
continue
|
||||
if levels[ii][jj] > cks[-1][-1]:
|
||||
cks[-1].pop(-1)
|
||||
cks[-1].append(levels[ii][jj])
|
||||
|
||||
for ii in cks[-1]:
|
||||
readed[ii] = True
|
||||
|
||||
if not cks:
|
||||
return []
|
||||
|
||||
for i in range(len(cks)):
|
||||
cks[i] = [pure_sections[j] for j in reversed(cks[i])]
|
||||
|
||||
res: list[list[str]] = [[]]
|
||||
num = [0]
|
||||
for ck in cks:
|
||||
if len(ck) == 1:
|
||||
n = count_tokens(re.sub(r"@@[0-9]+.*", "", ck[0]))
|
||||
if n + num[-1] < 218:
|
||||
res[-1].append(ck[0])
|
||||
num[-1] += n
|
||||
continue
|
||||
res.append(ck)
|
||||
num.append(n)
|
||||
continue
|
||||
res.append(ck)
|
||||
num.append(218)
|
||||
|
||||
return [chunk for chunk in res if chunk]
|
||||
|
||||
|
||||
def _remove_pdf_tags(text: str) -> str:
|
||||
return re.sub(r"@@[0-9-]+\t[0-9.\t]+##", "", text or "")
|
||||
|
||||
|
||||
def _extract_custom_delimiters(delimiter: str) -> list[str]:
|
||||
return [m.group(1) for m in re.finditer(r"`([^`]+)`", delimiter or "")]
|
||||
|
||||
|
||||
def naive_merge(
|
||||
sections: str | list[str] | list[tuple[str, str]],
|
||||
chunk_token_num: int = 128,
|
||||
delimiter: str = "\n。;!?",
|
||||
overlapped_percent: int = 0,
|
||||
) -> list[str]:
|
||||
if not sections:
|
||||
return []
|
||||
|
||||
if isinstance(sections, str):
|
||||
typed_sections: list[tuple[str, str]] = [(sections, "")]
|
||||
elif isinstance(sections[0], str):
|
||||
typed_sections = [(s, "") for s in sections] # type: ignore[index]
|
||||
else:
|
||||
typed_sections = sections # type: ignore[assignment]
|
||||
|
||||
chunk_token_num = max(int(chunk_token_num or 0), 0)
|
||||
overlap = max(0, min(int(overlapped_percent or 0), 99))
|
||||
|
||||
custom_delimiters = _extract_custom_delimiters(delimiter)
|
||||
if custom_delimiters:
|
||||
pattern = "|".join(re.escape(t) for t in sorted(set(custom_delimiters), key=len, reverse=True))
|
||||
chunks: list[str] = []
|
||||
for sec, pos in typed_sections:
|
||||
split_sec = re.split(rf"({pattern})", sec, flags=re.DOTALL)
|
||||
for sub in split_sec:
|
||||
if re.fullmatch(pattern, sub or ""):
|
||||
continue
|
||||
text = "\n" + sub
|
||||
local_pos = pos if count_tokens(text) >= 8 else ""
|
||||
if local_pos and local_pos not in text:
|
||||
text += local_pos
|
||||
if text.strip():
|
||||
chunks.append(text)
|
||||
return chunks
|
||||
|
||||
if chunk_token_num <= 0:
|
||||
merged = "\n".join(sec for sec, _ in typed_sections if sec and sec.strip())
|
||||
return [merged] if merged.strip() else []
|
||||
|
||||
chunks = [""]
|
||||
token_nums = [0]
|
||||
|
||||
def add_chunk(text: str, pos: str) -> None:
|
||||
tnum = count_tokens(text)
|
||||
local_pos = pos or ""
|
||||
if tnum < 8:
|
||||
local_pos = ""
|
||||
|
||||
threshold = chunk_token_num * (100 - overlap) / 100.0
|
||||
if chunks[-1] == "" or token_nums[-1] > threshold:
|
||||
if chunks:
|
||||
prev = _remove_pdf_tags(chunks[-1])
|
||||
start = int(len(prev) * (100 - overlap) / 100.0)
|
||||
text = prev[start:] + text
|
||||
if local_pos and local_pos not in text:
|
||||
text += local_pos
|
||||
chunks.append(text)
|
||||
token_nums.append(tnum)
|
||||
else:
|
||||
if local_pos and local_pos not in chunks[-1]:
|
||||
text += local_pos
|
||||
chunks[-1] += text
|
||||
token_nums[-1] += tnum
|
||||
|
||||
for sec, pos in typed_sections:
|
||||
if not sec:
|
||||
continue
|
||||
add_chunk("\n" + sec, pos)
|
||||
|
||||
return [chunk for chunk in chunks if chunk.strip()]
|
||||
|
||||
|
||||
@dataclass
|
||||
class Node:
|
||||
level: int
|
||||
depth: int = -1
|
||||
texts: list[str] = field(default_factory=list)
|
||||
children: list["Node"] = field(default_factory=list)
|
||||
|
||||
def add_child(self, child_node: "Node") -> None:
|
||||
self.children.append(child_node)
|
||||
|
||||
def add_text(self, text: str) -> None:
|
||||
self.texts.append(text)
|
||||
|
||||
def build_tree(self, lines: list[tuple[int, str]]) -> "Node":
|
||||
stack: list[Node] = [self]
|
||||
for level, text in lines:
|
||||
if self.depth != -1 and level > self.depth:
|
||||
stack[-1].add_text(text)
|
||||
continue
|
||||
|
||||
while len(stack) > 1 and level <= stack[-1].level:
|
||||
stack.pop()
|
||||
|
||||
node = Node(level=level, texts=[text])
|
||||
stack[-1].add_child(node)
|
||||
stack.append(node)
|
||||
|
||||
return self
|
||||
|
||||
def get_tree(self) -> list[str]:
|
||||
tree_list: list[str] = []
|
||||
self._dfs(self, tree_list, [])
|
||||
return tree_list
|
||||
|
||||
def _dfs(self, node: "Node", tree_list: list[str], titles: list[str]) -> None:
|
||||
level = node.level
|
||||
texts = node.texts
|
||||
child = node.children
|
||||
|
||||
if level == 0 and texts:
|
||||
tree_list.append("\n".join(titles + texts))
|
||||
|
||||
path_titles = titles + texts if 1 <= level <= self.depth else titles
|
||||
|
||||
if level > self.depth and texts:
|
||||
tree_list.append("\n".join(path_titles + texts))
|
||||
elif not child and (1 <= level <= self.depth):
|
||||
tree_list.append("\n".join(path_titles))
|
||||
|
||||
for c in child:
|
||||
self._dfs(c, tree_list, path_titles)
|
||||
3
src/knowledge/chunking/ragflow_like/parsers/__init__.py
Normal file
3
src/knowledge/chunking/ragflow_like/parsers/__init__.py
Normal file
@ -0,0 +1,3 @@
|
||||
from src.knowledge.chunking.ragflow_like.parsers import book, general, laws, qa
|
||||
|
||||
__all__ = ["general", "qa", "book", "laws"]
|
||||
61
src/knowledge/chunking/ragflow_like/parsers/book.py
Normal file
61
src/knowledge/chunking/ragflow_like/parsers/book.py
Normal file
@ -0,0 +1,61 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from src.knowledge.chunking.ragflow_like import nlp
|
||||
|
||||
|
||||
def _unescape_delimiter(delimiter: str) -> str:
|
||||
return delimiter.replace("\\n", "\n").replace("\\r", "\r").replace("\\t", "\t").replace("\\\\", "\\")
|
||||
|
||||
|
||||
def _iter_sections(markdown_content: str) -> list[tuple[str, str]]:
|
||||
sections: list[tuple[str, str]] = []
|
||||
for line in (markdown_content or "").splitlines():
|
||||
text = line.strip()
|
||||
if not text:
|
||||
continue
|
||||
sections.append((text, ""))
|
||||
|
||||
if not sections and markdown_content and markdown_content.strip():
|
||||
sections.append((markdown_content.strip(), ""))
|
||||
|
||||
return sections
|
||||
|
||||
|
||||
def chunk_markdown(markdown_content: str, parser_config: dict[str, Any] | None = None) -> list[str]:
|
||||
parser_config = parser_config or {}
|
||||
|
||||
delimiter = _unescape_delimiter(str(parser_config.get("delimiter", "\n") or "\n"))
|
||||
chunk_token_num = int(parser_config.get("chunk_token_num", 512) or 512)
|
||||
overlapped_percent = int(parser_config.get("overlapped_percent", 0) or 0)
|
||||
|
||||
sections = _iter_sections(markdown_content)
|
||||
if not sections:
|
||||
return []
|
||||
|
||||
section_texts = [text for text, _ in sections]
|
||||
nlp.remove_contents_table(sections, eng=nlp.is_english(nlp.random_choices(section_texts, k=200)))
|
||||
nlp.make_colon_as_title(sections)
|
||||
|
||||
bull = nlp.bullets_category([t for t in nlp.random_choices([t for t, _ in sections], k=100)])
|
||||
|
||||
if bull >= 0:
|
||||
chunks = ["\n".join(ck) for ck in nlp.hierarchical_merge(bull, sections, depth=5)]
|
||||
else:
|
||||
chunks = nlp.naive_merge(
|
||||
sections,
|
||||
chunk_token_num=chunk_token_num,
|
||||
delimiter=delimiter,
|
||||
overlapped_percent=overlapped_percent,
|
||||
)
|
||||
|
||||
if chunks:
|
||||
return chunks
|
||||
|
||||
return nlp.naive_merge(
|
||||
sections,
|
||||
chunk_token_num=chunk_token_num,
|
||||
delimiter=delimiter,
|
||||
overlapped_percent=overlapped_percent,
|
||||
)
|
||||
46
src/knowledge/chunking/ragflow_like/parsers/general.py
Normal file
46
src/knowledge/chunking/ragflow_like/parsers/general.py
Normal file
@ -0,0 +1,46 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from src.knowledge.chunking.ragflow_like import nlp
|
||||
|
||||
|
||||
def _unescape_delimiter(delimiter: str) -> str:
|
||||
return delimiter.replace("\\n", "\n").replace("\\r", "\r").replace("\\t", "\t").replace("\\\\", "\\")
|
||||
|
||||
|
||||
def _iter_sections(markdown_content: str, delimiter: str) -> list[tuple[str, str]]:
|
||||
sections: list[tuple[str, str]] = []
|
||||
text = markdown_content or ""
|
||||
if delimiter and delimiter not in {"\n", "\r\n"} and "`" not in delimiter:
|
||||
for part in text.split(delimiter):
|
||||
block = part.strip()
|
||||
if block:
|
||||
sections.append((block, ""))
|
||||
else:
|
||||
for line in text.splitlines():
|
||||
block = line.strip()
|
||||
if not block:
|
||||
continue
|
||||
sections.append((block, ""))
|
||||
|
||||
if not sections and text.strip():
|
||||
sections.append((text.strip(), ""))
|
||||
|
||||
return sections
|
||||
|
||||
|
||||
def chunk_markdown(markdown_content: str, parser_config: dict[str, Any] | None = None) -> list[str]:
|
||||
parser_config = parser_config or {}
|
||||
|
||||
delimiter = _unescape_delimiter(str(parser_config.get("delimiter", "\n") or "\n"))
|
||||
chunk_token_num = int(parser_config.get("chunk_token_num", 512) or 512)
|
||||
overlapped_percent = int(parser_config.get("overlapped_percent", 0) or 0)
|
||||
|
||||
sections = _iter_sections(markdown_content, delimiter)
|
||||
return nlp.naive_merge(
|
||||
sections,
|
||||
chunk_token_num=chunk_token_num,
|
||||
delimiter=delimiter,
|
||||
overlapped_percent=overlapped_percent,
|
||||
)
|
||||
85
src/knowledge/chunking/ragflow_like/parsers/laws.py
Normal file
85
src/knowledge/chunking/ragflow_like/parsers/laws.py
Normal file
@ -0,0 +1,85 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from src.knowledge.chunking.ragflow_like import nlp
|
||||
|
||||
|
||||
def _unescape_delimiter(delimiter: str) -> str:
|
||||
return delimiter.replace("\\n", "\n").replace("\\r", "\r").replace("\\t", "\t").replace("\\\\", "\\")
|
||||
|
||||
|
||||
def _iter_lines(markdown_content: str) -> list[str]:
|
||||
return [line.strip() for line in (markdown_content or "").splitlines() if line.strip()]
|
||||
|
||||
|
||||
def _docx_heading_tree(markdown_content: str) -> list[str]:
|
||||
lines: list[tuple[int, str]] = []
|
||||
level_set: set[int] = set()
|
||||
|
||||
for raw in (markdown_content or "").splitlines():
|
||||
text = raw.strip()
|
||||
if not text:
|
||||
continue
|
||||
|
||||
heading_match = re.match(r"^(#{1,6})\s+(.*)$", text)
|
||||
if heading_match:
|
||||
level = len(heading_match.group(1))
|
||||
value = heading_match.group(2).strip()
|
||||
else:
|
||||
level = 99
|
||||
value = text
|
||||
|
||||
if not value:
|
||||
continue
|
||||
|
||||
lines.append((level, value))
|
||||
level_set.add(level)
|
||||
|
||||
if not lines:
|
||||
return []
|
||||
|
||||
sorted_levels = sorted(level_set)
|
||||
h2_level = sorted_levels[1] if len(sorted_levels) > 1 else 1
|
||||
h2_level = sorted_levels[-2] if h2_level == sorted_levels[-1] and len(sorted_levels) > 2 else h2_level
|
||||
|
||||
root = nlp.Node(level=0, depth=h2_level, texts=[])
|
||||
root.build_tree(lines)
|
||||
return [element for element in root.get_tree() if element]
|
||||
|
||||
|
||||
def chunk_markdown(filename: str, markdown_content: str, parser_config: dict[str, Any] | None = None) -> list[str]:
|
||||
parser_config = parser_config or {}
|
||||
|
||||
delimiter = _unescape_delimiter(str(parser_config.get("delimiter", "\n") or "\n"))
|
||||
chunk_token_num = int(parser_config.get("chunk_token_num", 512) or 512)
|
||||
overlapped_percent = int(parser_config.get("overlapped_percent", 0) or 0)
|
||||
|
||||
if re.search(r"\.docx$", filename or "", re.IGNORECASE):
|
||||
chunks = _docx_heading_tree(markdown_content)
|
||||
if chunks:
|
||||
return chunks
|
||||
|
||||
sections = _iter_lines(markdown_content)
|
||||
if not sections:
|
||||
return []
|
||||
|
||||
eng = nlp.is_english(sections)
|
||||
nlp.remove_contents_table(sections, eng=eng)
|
||||
|
||||
typed_sections = [(s, "") for s in sections]
|
||||
nlp.make_colon_as_title(typed_sections)
|
||||
|
||||
bull = nlp.bullets_category([s for s, _ in typed_sections])
|
||||
merged = nlp.tree_merge(bull, typed_sections, depth=2)
|
||||
|
||||
if merged:
|
||||
return merged
|
||||
|
||||
return nlp.naive_merge(
|
||||
typed_sections,
|
||||
chunk_token_num=chunk_token_num,
|
||||
delimiter=delimiter,
|
||||
overlapped_percent=overlapped_percent,
|
||||
)
|
||||
261
src/knowledge/chunking/ragflow_like/parsers/qa.py
Normal file
261
src/knowledge/chunking/ragflow_like/parsers/qa.py
Normal file
@ -0,0 +1,261 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import re
|
||||
from io import StringIO
|
||||
from typing import Any
|
||||
|
||||
|
||||
def _rm_prefix(text: str) -> str:
|
||||
return re.sub(
|
||||
r"^(问题|答案|回答|user|assistant|Q|A|Question|Answer|问|答)[\t:: ]+",
|
||||
"",
|
||||
(text or "").strip(),
|
||||
flags=re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def _to_qa_chunk(question: str, answer: str, eng: bool = False) -> str:
|
||||
qprefix = "Question: " if eng else "问题:"
|
||||
aprefix = "Answer: " if eng else "回答:"
|
||||
return "\t".join([qprefix + _rm_prefix(question), aprefix + _rm_prefix(answer)])
|
||||
|
||||
|
||||
def _guess_delimiter(lines: list[str]) -> str:
|
||||
comma = 0
|
||||
tab = 0
|
||||
for line in lines:
|
||||
if len(line.split(",")) == 2:
|
||||
comma += 1
|
||||
if len(line.split("\t")) == 2:
|
||||
tab += 1
|
||||
return "\t" if tab >= comma else ","
|
||||
|
||||
|
||||
def _extract_pairs_with_delimiter(lines: list[str], delimiter: str) -> list[tuple[str, str]]:
|
||||
pairs: list[tuple[str, str]] = []
|
||||
question = ""
|
||||
answer = ""
|
||||
|
||||
for line in lines:
|
||||
arr = line.split(delimiter)
|
||||
if len(arr) != 2:
|
||||
if question:
|
||||
answer += "\n" + line
|
||||
continue
|
||||
|
||||
if question and answer:
|
||||
pairs.append((question, answer))
|
||||
question, answer = arr
|
||||
|
||||
if question:
|
||||
pairs.append((question, answer))
|
||||
|
||||
return [(q.strip(), a.strip()) for q, a in pairs if q.strip()]
|
||||
|
||||
|
||||
def _extract_pairs_from_csv(lines: list[str], delimiter: str) -> list[tuple[str, str]]:
|
||||
pairs: list[tuple[str, str]] = []
|
||||
question = ""
|
||||
answer = ""
|
||||
|
||||
reader = csv.reader(lines, delimiter=delimiter)
|
||||
for row, raw_line in zip(reader, lines, strict=False):
|
||||
if len(row) != 2:
|
||||
if question:
|
||||
answer += "\n" + raw_line
|
||||
continue
|
||||
|
||||
if question and answer:
|
||||
pairs.append((question, answer))
|
||||
question, answer = row
|
||||
|
||||
if question:
|
||||
pairs.append((question, answer))
|
||||
|
||||
return [(q.strip(), a.strip()) for q, a in pairs if q.strip()]
|
||||
|
||||
|
||||
def _parse_markdown_table_row(line: str) -> list[str] | None:
|
||||
if "|" not in line:
|
||||
return None
|
||||
|
||||
text = line.strip()
|
||||
if not text:
|
||||
return None
|
||||
|
||||
if text.startswith("|"):
|
||||
text = text[1:]
|
||||
if text.endswith("|"):
|
||||
text = text[:-1]
|
||||
|
||||
cells = [cell.strip() for cell in text.split("|")]
|
||||
if not cells:
|
||||
return None
|
||||
|
||||
if all(re.fullmatch(r":?-{3,}:?", c.replace(" ", "")) for c in cells if c):
|
||||
return None
|
||||
|
||||
return cells
|
||||
|
||||
|
||||
def _extract_pairs_from_markdown_tables(markdown_content: str) -> list[tuple[str, str]]:
|
||||
pairs: list[tuple[str, str]] = []
|
||||
|
||||
for line in (markdown_content or "").splitlines():
|
||||
cells = _parse_markdown_table_row(line)
|
||||
if not cells or len(cells) < 2:
|
||||
continue
|
||||
|
||||
question = cells[0]
|
||||
answer = cells[1]
|
||||
if question and answer:
|
||||
pairs.append((question, answer))
|
||||
|
||||
return pairs
|
||||
|
||||
|
||||
def _md_question_level(line: str) -> tuple[int, str]:
|
||||
match = re.match(r"^#*", line)
|
||||
if not match:
|
||||
return 0, line
|
||||
return len(match.group(0)), line.lstrip("#").lstrip()
|
||||
|
||||
|
||||
def _extract_pairs_from_markdown_headings(markdown_content: str) -> list[tuple[str, str]]:
|
||||
lines = (markdown_content or "").splitlines()
|
||||
if not lines:
|
||||
return []
|
||||
|
||||
pairs: list[tuple[str, str]] = []
|
||||
last_answer = ""
|
||||
question_stack: list[str] = []
|
||||
level_stack: list[int] = []
|
||||
code_block = False
|
||||
|
||||
for line in lines:
|
||||
if line.strip().startswith("```"):
|
||||
code_block = not code_block
|
||||
|
||||
question_level = 0
|
||||
question = ""
|
||||
if not code_block:
|
||||
question_level, question = _md_question_level(line)
|
||||
|
||||
if not question_level or question_level > 6:
|
||||
last_answer = f"{last_answer}\n{line}"
|
||||
continue
|
||||
|
||||
if last_answer.strip():
|
||||
sum_question = "\n".join(question_stack)
|
||||
if sum_question:
|
||||
pairs.append((sum_question, last_answer.strip()))
|
||||
last_answer = ""
|
||||
|
||||
while question_stack and question_level <= level_stack[-1]:
|
||||
question_stack.pop()
|
||||
level_stack.pop()
|
||||
|
||||
question_stack.append(question)
|
||||
level_stack.append(question_level)
|
||||
|
||||
if last_answer.strip():
|
||||
sum_question = "\n".join(question_stack)
|
||||
if sum_question:
|
||||
pairs.append((sum_question, last_answer.strip()))
|
||||
|
||||
return pairs
|
||||
|
||||
|
||||
def _extract_pairs_by_prefix(lines: list[str]) -> list[tuple[str, str]]:
|
||||
pairs: list[tuple[str, str]] = []
|
||||
question = ""
|
||||
answer_lines: list[str] = []
|
||||
|
||||
for line in lines:
|
||||
if re.match(r"^(Q|Question|问|问题)\s*[::]", line, flags=re.IGNORECASE):
|
||||
if question:
|
||||
pairs.append((question, "\n".join(answer_lines)))
|
||||
question = re.sub(r"^(Q|Question|问|问题)\s*[::]", "", line, flags=re.IGNORECASE).strip()
|
||||
answer_lines = []
|
||||
continue
|
||||
|
||||
if re.match(r"^(A|Answer|答|回答)\s*[::]", line, flags=re.IGNORECASE):
|
||||
answer_lines.append(re.sub(r"^(A|Answer|答|回答)\s*[::]", "", line, flags=re.IGNORECASE).strip())
|
||||
continue
|
||||
|
||||
if question:
|
||||
answer_lines.append(line)
|
||||
|
||||
if question:
|
||||
pairs.append((question, "\n".join(answer_lines)))
|
||||
|
||||
return [(q.strip(), a.strip()) for q, a in pairs if q.strip() and a.strip()]
|
||||
|
||||
|
||||
def _dedupe_pairs(pairs: list[tuple[str, str]]) -> list[tuple[str, str]]:
|
||||
res: list[tuple[str, str]] = []
|
||||
seen: set[tuple[str, str]] = set()
|
||||
|
||||
for question, answer in pairs:
|
||||
q = question.strip()
|
||||
a = answer.strip()
|
||||
if not q or not a:
|
||||
continue
|
||||
key = (q, a)
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
res.append((q, a))
|
||||
|
||||
return res
|
||||
|
||||
|
||||
def chunk_markdown(filename: str, markdown_content: str, parser_config: dict[str, Any] | None = None) -> list[str]:
|
||||
parser_config = parser_config or {}
|
||||
eng = str(parser_config.get("language", "Chinese")).lower() == "english"
|
||||
|
||||
suffix = ""
|
||||
if filename and "." in filename:
|
||||
suffix = "." + filename.lower().split(".")[-1]
|
||||
|
||||
lines = [line for line in (markdown_content or "").splitlines() if line.strip()]
|
||||
pairs: list[tuple[str, str]] = []
|
||||
|
||||
if suffix in {".xlsx", ".xls"}:
|
||||
pairs.extend(_extract_pairs_from_markdown_tables(markdown_content))
|
||||
if not pairs:
|
||||
delimiter = _guess_delimiter(lines)
|
||||
pairs.extend(_extract_pairs_with_delimiter(lines, delimiter))
|
||||
elif suffix == ".csv":
|
||||
pairs.extend(_extract_pairs_from_markdown_tables(markdown_content))
|
||||
delimiter = "\t" if any("\t" in line for line in lines) else ","
|
||||
pairs.extend(_extract_pairs_from_csv(lines, delimiter))
|
||||
elif suffix == ".txt":
|
||||
delimiter = _guess_delimiter(lines)
|
||||
pairs.extend(_extract_pairs_with_delimiter(lines, delimiter))
|
||||
elif suffix in {".md", ".markdown", ".mdx"}:
|
||||
pairs.extend(_extract_pairs_from_markdown_headings(markdown_content))
|
||||
pairs.extend(_extract_pairs_from_markdown_tables(markdown_content))
|
||||
elif suffix == ".docx":
|
||||
pairs.extend(_extract_pairs_from_markdown_headings(markdown_content))
|
||||
pairs.extend(_extract_pairs_from_markdown_tables(markdown_content))
|
||||
else:
|
||||
pairs.extend(_extract_pairs_from_markdown_headings(markdown_content))
|
||||
pairs.extend(_extract_pairs_from_markdown_tables(markdown_content))
|
||||
pairs.extend(_extract_pairs_by_prefix(lines))
|
||||
if not pairs:
|
||||
delimiter = _guess_delimiter(lines)
|
||||
pairs.extend(_extract_pairs_with_delimiter(lines, delimiter))
|
||||
|
||||
pairs = _dedupe_pairs(pairs)
|
||||
|
||||
if not pairs and lines:
|
||||
# 最后兜底:把内容按 2 行一组构成问答
|
||||
for i in range(0, len(lines), 2):
|
||||
q = lines[i]
|
||||
a = lines[i + 1] if i + 1 < len(lines) else ""
|
||||
if q.strip() and a.strip():
|
||||
pairs.append((q, a))
|
||||
|
||||
return [_to_qa_chunk(q, a, eng=eng) for q, a in pairs]
|
||||
235
src/knowledge/chunking/ragflow_like/presets.py
Normal file
235
src/knowledge/chunking/ragflow_like/presets.py
Normal file
@ -0,0 +1,235 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from copy import deepcopy
|
||||
from typing import Any
|
||||
|
||||
from src.utils import logger
|
||||
|
||||
CHUNK_PRESET_GENERAL = "general"
|
||||
CHUNK_PRESET_QA = "qa"
|
||||
CHUNK_PRESET_BOOK = "book"
|
||||
CHUNK_PRESET_LAWS = "laws"
|
||||
|
||||
CHUNK_PRESET_IDS = {
|
||||
CHUNK_PRESET_GENERAL,
|
||||
CHUNK_PRESET_QA,
|
||||
CHUNK_PRESET_BOOK,
|
||||
CHUNK_PRESET_LAWS,
|
||||
}
|
||||
|
||||
CHUNK_PRESET_DESCRIPTIONS: dict[str, str] = {
|
||||
CHUNK_PRESET_GENERAL: "通用分块:按分隔符和长度切分,适合大多数普通文档。",
|
||||
CHUNK_PRESET_QA: "问答分块:优先抽取问题-回答结构,适合 FAQ、题库、问答手册。",
|
||||
CHUNK_PRESET_BOOK: "书籍分块:强化章节标题识别并做层级合并,适合教材、手册、长章节文档。",
|
||||
CHUNK_PRESET_LAWS: "法规分块:按法条层级组织与合并,适合法律法规、制度规范类文本。",
|
||||
}
|
||||
|
||||
CHUNK_ENGINE_VERSION = "ragflow_like_v1"
|
||||
GENERAL_INTERNAL_PARSER_ID = "naive"
|
||||
|
||||
_BASE_DEFAULTS: dict[str, Any] = {
|
||||
"table_context_size": 0,
|
||||
"image_context_size": 0,
|
||||
}
|
||||
|
||||
_PRESET_DEFAULTS: dict[str, dict[str, Any] | None] = {
|
||||
CHUNK_PRESET_GENERAL: {
|
||||
"layout_recognize": "DeepDOC",
|
||||
"chunk_token_num": 512,
|
||||
"delimiter": "\n",
|
||||
"auto_keywords": 0,
|
||||
"auto_questions": 0,
|
||||
"html4excel": False,
|
||||
"topn_tags": 3,
|
||||
"raptor": {
|
||||
"use_raptor": True,
|
||||
"prompt": "Please summarize the following paragraphs. Be careful with the numbers, do not make things up. Paragraphs as following:\n {cluster_content}\nThe above is the content you need to summarize.",
|
||||
"max_token": 256,
|
||||
"threshold": 0.1,
|
||||
"max_cluster": 64,
|
||||
"random_seed": 0,
|
||||
},
|
||||
"graphrag": {
|
||||
"use_graphrag": True,
|
||||
"entity_types": ["organization", "person", "geo", "event", "category"],
|
||||
"method": "light",
|
||||
},
|
||||
},
|
||||
CHUNK_PRESET_QA: {"raptor": {"use_raptor": False}, "graphrag": {"use_graphrag": False}},
|
||||
CHUNK_PRESET_BOOK: {"raptor": {"use_raptor": False}, "graphrag": {"use_graphrag": False}},
|
||||
CHUNK_PRESET_LAWS: {"raptor": {"use_raptor": False}, "graphrag": {"use_graphrag": False}},
|
||||
}
|
||||
|
||||
|
||||
def deep_merge(base: dict[str, Any], override: dict[str, Any]) -> dict[str, Any]:
|
||||
result = deepcopy(base)
|
||||
for key, value in (override or {}).items():
|
||||
if isinstance(value, dict) and isinstance(result.get(key), dict):
|
||||
result[key] = deep_merge(result[key], value)
|
||||
else:
|
||||
result[key] = value
|
||||
return result
|
||||
|
||||
|
||||
def normalize_chunk_preset_id(value: str | None) -> str:
|
||||
if not value:
|
||||
return CHUNK_PRESET_GENERAL
|
||||
|
||||
normalized = str(value).strip().lower()
|
||||
if normalized == GENERAL_INTERNAL_PARSER_ID:
|
||||
return CHUNK_PRESET_GENERAL
|
||||
|
||||
if normalized in CHUNK_PRESET_IDS:
|
||||
return normalized
|
||||
|
||||
logger.warning(f"Unknown chunk preset id '{value}', fallback to general")
|
||||
return CHUNK_PRESET_GENERAL
|
||||
|
||||
|
||||
def map_to_internal_parser_id(preset_id: str) -> str:
|
||||
normalized = normalize_chunk_preset_id(preset_id)
|
||||
if normalized == CHUNK_PRESET_GENERAL:
|
||||
return GENERAL_INTERNAL_PARSER_ID
|
||||
return normalized
|
||||
|
||||
|
||||
def get_default_chunk_parser_config(preset_id: str) -> dict[str, Any]:
|
||||
normalized = normalize_chunk_preset_id(preset_id)
|
||||
default_config = deepcopy(_PRESET_DEFAULTS.get(normalized) or {})
|
||||
return deep_merge(_BASE_DEFAULTS, default_config)
|
||||
|
||||
|
||||
def _safe_int(value: Any) -> int | None:
|
||||
if value is None:
|
||||
return None
|
||||
try:
|
||||
return int(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _legacy_params_to_parser_config(params: dict[str, Any] | None) -> dict[str, Any]:
|
||||
if not isinstance(params, dict):
|
||||
return {}
|
||||
|
||||
parser_config: dict[str, Any] = {}
|
||||
|
||||
chunk_size = _safe_int(params.get("chunk_size"))
|
||||
chunk_overlap = _safe_int(params.get("chunk_overlap"))
|
||||
|
||||
if chunk_size and chunk_size > 0:
|
||||
parser_config["chunk_token_num"] = chunk_size
|
||||
if chunk_size and chunk_size > 0 and chunk_overlap is not None:
|
||||
overlap_percent = round(max(0, min(chunk_overlap, chunk_size - 1)) * 100 / chunk_size)
|
||||
parser_config["overlapped_percent"] = max(0, min(overlap_percent, 99))
|
||||
|
||||
if isinstance(params.get("qa_separator"), str) and params.get("qa_separator"):
|
||||
parser_config["delimiter"] = params["qa_separator"]
|
||||
|
||||
if isinstance(params.get("delimiter"), str) and params.get("delimiter"):
|
||||
parser_config["delimiter"] = params["delimiter"]
|
||||
|
||||
if "chunk_token_num" in params:
|
||||
normalized_chunk_token_num = _safe_int(params.get("chunk_token_num"))
|
||||
if normalized_chunk_token_num is not None:
|
||||
parser_config["chunk_token_num"] = normalized_chunk_token_num
|
||||
|
||||
if "overlapped_percent" in params:
|
||||
normalized_overlapped_percent = _safe_int(params.get("overlapped_percent"))
|
||||
if normalized_overlapped_percent is not None:
|
||||
parser_config["overlapped_percent"] = max(0, min(normalized_overlapped_percent, 99))
|
||||
|
||||
return parser_config
|
||||
|
||||
|
||||
def ensure_chunk_defaults_in_additional_params(additional_params: dict[str, Any] | None) -> dict[str, Any]:
|
||||
params = dict(additional_params or {})
|
||||
params["chunk_preset_id"] = normalize_chunk_preset_id(params.get("chunk_preset_id"))
|
||||
|
||||
if "chunk_parser_config" in params and not isinstance(params.get("chunk_parser_config"), dict):
|
||||
logger.warning("Invalid chunk_parser_config in additional_params, fallback to empty dict")
|
||||
params["chunk_parser_config"] = {}
|
||||
|
||||
return params
|
||||
|
||||
|
||||
def resolve_chunk_processing_params(
|
||||
kb_additional_params: dict[str, Any] | None,
|
||||
file_processing_params: dict[str, Any] | None,
|
||||
request_params: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
kb_additional = ensure_chunk_defaults_in_additional_params(kb_additional_params)
|
||||
file_params = dict(file_processing_params or {})
|
||||
request = dict(request_params or {})
|
||||
|
||||
preset_id = normalize_chunk_preset_id(
|
||||
request.get("chunk_preset_id")
|
||||
or file_params.get("chunk_preset_id")
|
||||
or kb_additional.get("chunk_preset_id")
|
||||
)
|
||||
|
||||
parser_config = get_default_chunk_parser_config(preset_id)
|
||||
|
||||
kb_parser_config = kb_additional.get("chunk_parser_config")
|
||||
if isinstance(kb_parser_config, dict):
|
||||
parser_config = deep_merge(parser_config, kb_parser_config)
|
||||
|
||||
file_parser_config = file_params.get("chunk_parser_config")
|
||||
if isinstance(file_parser_config, dict):
|
||||
parser_config = deep_merge(parser_config, file_parser_config)
|
||||
|
||||
req_parser_config = request.get("chunk_parser_config")
|
||||
if isinstance(req_parser_config, dict):
|
||||
parser_config = deep_merge(parser_config, req_parser_config)
|
||||
|
||||
merged_legacy = {}
|
||||
merged_legacy.update(file_params)
|
||||
merged_legacy.update(request)
|
||||
parser_config = deep_merge(parser_config, _legacy_params_to_parser_config(merged_legacy))
|
||||
|
||||
# Build processing params snapshot (keep existing + request overrides for non-chunk fields)
|
||||
snapshot: dict[str, Any] = {}
|
||||
snapshot.update(file_params)
|
||||
snapshot.update(request)
|
||||
snapshot["chunk_preset_id"] = preset_id
|
||||
snapshot["chunk_parser_config"] = parser_config
|
||||
snapshot["chunk_engine_version"] = CHUNK_ENGINE_VERSION
|
||||
|
||||
# Keep backward-compatible fields for current UI
|
||||
if "chunk_size" not in snapshot and isinstance(parser_config.get("chunk_token_num"), int):
|
||||
snapshot["chunk_size"] = parser_config["chunk_token_num"]
|
||||
|
||||
if "chunk_overlap" not in snapshot and isinstance(parser_config.get("overlapped_percent"), int):
|
||||
token_num = parser_config.get("chunk_token_num")
|
||||
if isinstance(token_num, int) and token_num > 0:
|
||||
snapshot["chunk_overlap"] = int(token_num * parser_config["overlapped_percent"] / 100)
|
||||
|
||||
if "qa_separator" not in snapshot and isinstance(parser_config.get("delimiter"), str):
|
||||
snapshot["qa_separator"] = parser_config["delimiter"]
|
||||
|
||||
return snapshot
|
||||
|
||||
|
||||
def get_chunk_preset_options() -> list[dict[str, str]]:
|
||||
return [
|
||||
{
|
||||
"value": CHUNK_PRESET_GENERAL,
|
||||
"label": "General",
|
||||
"description": CHUNK_PRESET_DESCRIPTIONS[CHUNK_PRESET_GENERAL],
|
||||
},
|
||||
{
|
||||
"value": CHUNK_PRESET_QA,
|
||||
"label": "QA",
|
||||
"description": CHUNK_PRESET_DESCRIPTIONS[CHUNK_PRESET_QA],
|
||||
},
|
||||
{
|
||||
"value": CHUNK_PRESET_BOOK,
|
||||
"label": "Book",
|
||||
"description": CHUNK_PRESET_DESCRIPTIONS[CHUNK_PRESET_BOOK],
|
||||
},
|
||||
{
|
||||
"value": CHUNK_PRESET_LAWS,
|
||||
"label": "Laws",
|
||||
"description": CHUNK_PRESET_DESCRIPTIONS[CHUNK_PRESET_LAWS],
|
||||
},
|
||||
]
|
||||
@ -11,6 +11,8 @@ from pymilvus import connections, utility
|
||||
|
||||
from src import config
|
||||
from src.knowledge.base import FileStatus, KnowledgeBase
|
||||
from src.knowledge.chunking.ragflow_like.dispatcher import chunk_markdown
|
||||
from src.knowledge.chunking.ragflow_like.presets import resolve_chunk_processing_params
|
||||
from src.knowledge.indexing import process_file_to_markdown
|
||||
from src.knowledge.utils.kb_utils import get_embedding_config
|
||||
from src.utils import hashstr, logger
|
||||
@ -40,6 +42,18 @@ class LightRagKB(KnowledgeBase):
|
||||
"""知识库类型标识"""
|
||||
return "lightrag"
|
||||
|
||||
@staticmethod
|
||||
def _prepare_lightrag_insert_payload(chunks: list[dict]) -> tuple[str, str | None, bool]:
|
||||
if not chunks:
|
||||
return "", None, False
|
||||
|
||||
if len(chunks) == 1:
|
||||
return chunks[0]["content"], None, False
|
||||
|
||||
delimiter = "\n<|YUXI_CHUNK_DELIM|>\n"
|
||||
payload = delimiter.join(chunk["content"] for chunk in chunks if chunk.get("content"))
|
||||
return payload, delimiter, True
|
||||
|
||||
def delete_database(self, db_id: str) -> dict:
|
||||
"""删除数据库,同时清除Milvus和Neo4j中的数据"""
|
||||
# Drop Milvus collection
|
||||
@ -137,6 +151,21 @@ class LightRagKB(KnowledgeBase):
|
||||
await instance.initialize_storages()
|
||||
await initialize_pipeline_status()
|
||||
|
||||
@staticmethod
|
||||
async def _ensure_doc_processed(rag: LightRAG, file_id: str) -> None:
|
||||
"""确保 LightRAG 文档处理成功,否则抛出异常。"""
|
||||
status_doc = await rag.doc_status.get_by_id(file_id)
|
||||
if not status_doc:
|
||||
raise ValueError(f"LightRAG 文档状态缺失: {file_id}")
|
||||
|
||||
status = status_doc.get("status")
|
||||
status_value = status.value if hasattr(status, "value") else status
|
||||
if status_value not in {"processed", "preprocessed"}:
|
||||
error_msg = status_doc.get("error_msg") or "unknown error"
|
||||
raise ValueError(
|
||||
f"LightRAG 实体关系抽取失败: file_id={file_id}, status={status_value}, error={error_msg}"
|
||||
)
|
||||
|
||||
async def _get_lightrag_instance(self, db_id: str) -> LightRAG | None:
|
||||
"""获取或创建 LightRAG 实例"""
|
||||
if db_id in self.instances:
|
||||
@ -293,14 +322,36 @@ class LightRagKB(KnowledgeBase):
|
||||
# Read markdown
|
||||
markdown_content = await self._read_markdown_from_minio(file_meta["markdown_file"])
|
||||
file_path = file_meta.get("path")
|
||||
filename = file_meta.get("filename") or file_id
|
||||
processing_params = resolve_chunk_processing_params(
|
||||
kb_additional_params=self.databases_meta.get(db_id, {}).get("metadata"),
|
||||
file_processing_params=file_meta.get("processing_params"),
|
||||
)
|
||||
self.files_meta[file_id]["processing_params"] = processing_params
|
||||
await self._save_metadata()
|
||||
|
||||
chunks = chunk_markdown(markdown_content, file_id, filename, processing_params)
|
||||
chunk_input, split_by_character, split_by_character_only = self._prepare_lightrag_insert_payload(chunks)
|
||||
if not chunk_input:
|
||||
chunk_input = markdown_content
|
||||
|
||||
# Clean up existing chunks if any (for re-indexing)
|
||||
await self.delete_file_chunks_only(db_id, file_id)
|
||||
|
||||
# Insert
|
||||
await rag.ainsert(input=markdown_content, ids=file_id, file_paths=file_path)
|
||||
await rag.ainsert(
|
||||
input=chunk_input,
|
||||
ids=file_id,
|
||||
file_paths=file_path,
|
||||
split_by_character=split_by_character,
|
||||
split_by_character_only=split_by_character_only,
|
||||
)
|
||||
await self._ensure_doc_processed(rag, file_id)
|
||||
|
||||
logger.info(f"Indexed file {file_id} into LightRAG")
|
||||
logger.info(
|
||||
f"Indexed file {file_id} into LightRAG with {len(chunks)} chunks, "
|
||||
f"chunk_preset_id={processing_params.get('chunk_preset_id')}"
|
||||
)
|
||||
|
||||
# Update status
|
||||
self.files_meta[file_id]["status"] = FileStatus.INDEXED
|
||||
@ -358,7 +409,12 @@ class LightRagKB(KnowledgeBase):
|
||||
|
||||
try:
|
||||
# 更新状态为处理中
|
||||
self.files_meta[file_id]["processing_params"] = params.copy()
|
||||
resolved_params = resolve_chunk_processing_params(
|
||||
kb_additional_params=self.databases_meta.get(db_id, {}).get("metadata"),
|
||||
file_processing_params=self.files_meta[file_id].get("processing_params"),
|
||||
request_params=params,
|
||||
)
|
||||
self.files_meta[file_id]["processing_params"] = resolved_params
|
||||
self.files_meta[file_id]["status"] = "processing"
|
||||
await self._persist_file(file_id)
|
||||
|
||||
@ -368,12 +424,24 @@ class LightRagKB(KnowledgeBase):
|
||||
markdown_content = await process_file_to_markdown(file_path, params=params)
|
||||
markdown_content_lines = markdown_content[:100].replace("\n", " ")
|
||||
logger.info(f"Markdown content: {markdown_content_lines}...")
|
||||
filename = file_meta.get("filename") or file_id
|
||||
chunks = chunk_markdown(markdown_content, file_id, filename, resolved_params)
|
||||
chunk_input, split_by_character, split_by_character_only = self._prepare_lightrag_insert_payload(chunks)
|
||||
if not chunk_input:
|
||||
chunk_input = markdown_content
|
||||
|
||||
# 先删除现有的 LightRAG 数据(仅删除chunks,保留元数据)
|
||||
await self.delete_file_chunks_only(db_id, file_id)
|
||||
|
||||
# 使用 LightRAG 重新插入内容
|
||||
await rag.ainsert(input=markdown_content, ids=file_id, file_paths=file_path)
|
||||
await rag.ainsert(
|
||||
input=chunk_input,
|
||||
ids=file_id,
|
||||
file_paths=file_path,
|
||||
split_by_character=split_by_character,
|
||||
split_by_character_only=split_by_character_only,
|
||||
)
|
||||
await self._ensure_doc_processed(rag, file_id)
|
||||
|
||||
logger.info(f"Updated {content_type} {file_path} in LightRAG. Done.")
|
||||
|
||||
|
||||
@ -10,11 +10,10 @@ from pymilvus import Collection, CollectionSchema, DataType, FieldSchema, connec
|
||||
|
||||
from src import config
|
||||
from src.knowledge.base import FileStatus, KnowledgeBase
|
||||
from src.knowledge.chunking.ragflow_like.dispatcher import chunk_markdown
|
||||
from src.knowledge.chunking.ragflow_like.presets import resolve_chunk_processing_params
|
||||
from src.knowledge.indexing import process_file_to_markdown
|
||||
from src.knowledge.utils.kb_utils import (
|
||||
get_embedding_config,
|
||||
split_text_into_chunks,
|
||||
)
|
||||
from src.knowledge.utils.kb_utils import get_embedding_config
|
||||
from src.models.embed import OtherEmbedding
|
||||
from src.utils import hashstr, logger
|
||||
from src.utils.datetime_utils import utc_isoformat
|
||||
@ -222,7 +221,7 @@ class MilvusKB(KnowledgeBase):
|
||||
|
||||
def _split_text_into_chunks(self, text: str, file_id: str, filename: str, params: dict) -> list[dict]:
|
||||
"""将文本分割成块"""
|
||||
return split_text_into_chunks(text, file_id, filename, params)
|
||||
return chunk_markdown(text, file_id, filename, params)
|
||||
|
||||
async def index_file(self, db_id: str, file_id: str, operator_id: str | None = None) -> dict:
|
||||
"""
|
||||
@ -284,7 +283,12 @@ class MilvusKB(KnowledgeBase):
|
||||
await self._persist_file(file_id)
|
||||
|
||||
# Read processing params inside lock to ensure we get the latest values
|
||||
params = file_meta.get("processing_params", {}) or {}
|
||||
params = resolve_chunk_processing_params(
|
||||
kb_additional_params=self.databases_meta.get(db_id, {}).get("metadata"),
|
||||
file_processing_params=file_meta.get("processing_params"),
|
||||
)
|
||||
self.files_meta[file_id]["processing_params"] = params
|
||||
await self._save_metadata()
|
||||
logger.debug(f"[index_file] file_id={file_id}, processing_params={params}")
|
||||
|
||||
# Add to processing queue
|
||||
@ -299,6 +303,7 @@ class MilvusKB(KnowledgeBase):
|
||||
chunks = self._split_text_into_chunks(markdown_content, file_id, filename, params)
|
||||
logger.info(
|
||||
f"Split {filename} into {len(chunks)} chunks with params: "
|
||||
f"chunk_preset_id={params.get('chunk_preset_id')}, "
|
||||
f"chunk_size={params.get('chunk_size')}, "
|
||||
f"chunk_overlap={params.get('chunk_overlap')}, "
|
||||
f"qa_separator={params.get('qa_separator')}"
|
||||
@ -391,7 +396,12 @@ class MilvusKB(KnowledgeBase):
|
||||
try:
|
||||
# 更新状态为处理中
|
||||
async with self._metadata_lock:
|
||||
self.files_meta[file_id]["processing_params"] = params.copy()
|
||||
resolved_params = resolve_chunk_processing_params(
|
||||
kb_additional_params=self.databases_meta.get(db_id, {}).get("metadata"),
|
||||
file_processing_params=self.files_meta[file_id].get("processing_params"),
|
||||
request_params=params,
|
||||
)
|
||||
self.files_meta[file_id]["processing_params"] = resolved_params
|
||||
self.files_meta[file_id]["status"] = "processing"
|
||||
await self._persist_file(file_id)
|
||||
|
||||
@ -404,7 +414,7 @@ class MilvusKB(KnowledgeBase):
|
||||
await self.delete_file_chunks_only(db_id, file_id)
|
||||
|
||||
# 重新生成 chunks
|
||||
chunks = self._split_text_into_chunks(markdown_content, file_id, filename, params)
|
||||
chunks = self._split_text_into_chunks(markdown_content, file_id, filename, resolved_params)
|
||||
logger.info(f"Split {filename} into {len(chunks)} chunks")
|
||||
|
||||
if chunks:
|
||||
|
||||
@ -2,6 +2,10 @@ import asyncio
|
||||
import os
|
||||
|
||||
from src.knowledge.base import KBNotFoundError, KnowledgeBase
|
||||
from src.knowledge.chunking.ragflow_like.presets import (
|
||||
deep_merge,
|
||||
ensure_chunk_defaults_in_additional_params,
|
||||
)
|
||||
from src.knowledge.factory import KnowledgeBaseFactory
|
||||
from src.utils import logger
|
||||
from src.utils.datetime_utils import utc_isoformat
|
||||
@ -179,7 +183,7 @@ class KnowledgeBaseManager:
|
||||
if db_info:
|
||||
# 补充 share_config 和 additional_params
|
||||
db_info["share_config"] = row.share_config or {"is_shared": True, "accessible_departments": []}
|
||||
db_info["additional_params"] = row.additional_params or {}
|
||||
db_info["additional_params"] = ensure_chunk_defaults_in_additional_params(row.additional_params)
|
||||
all_databases.append(db_info)
|
||||
return {"databases": all_databases}
|
||||
|
||||
@ -310,6 +314,8 @@ class KnowledgeBaseManager:
|
||||
if share_config is None:
|
||||
share_config = {"is_shared": True, "accessible_departments": []}
|
||||
|
||||
kwargs = ensure_chunk_defaults_in_additional_params(kwargs)
|
||||
|
||||
kb_instance = self._get_or_create_kb_instance(kb_type)
|
||||
db_info = await kb_instance.create_database(database_name, description, embed_info, **kwargs)
|
||||
db_id = db_info["db_id"]
|
||||
@ -414,7 +420,7 @@ class KnowledgeBaseManager:
|
||||
}
|
||||
|
||||
# 添加数据库中的附加字段
|
||||
db_info["additional_params"] = kb.additional_params or {}
|
||||
db_info["additional_params"] = ensure_chunk_defaults_in_additional_params(kb.additional_params)
|
||||
db_info["share_config"] = kb.share_config or {"is_shared": True, "accessible_departments": []}
|
||||
db_info["mindmap"] = kb.mindmap
|
||||
db_info["sample_questions"] = kb.sample_questions or []
|
||||
@ -566,6 +572,11 @@ class KnowledgeBaseManager:
|
||||
"""更新数据库"""
|
||||
from src.repositories.knowledge_base_repository import KnowledgeBaseRepository
|
||||
|
||||
kb_repo = KnowledgeBaseRepository()
|
||||
kb = await kb_repo.get_by_id(db_id)
|
||||
if kb is None:
|
||||
raise ValueError(f"数据库 {db_id} 不存在")
|
||||
|
||||
kb_instance = await self._get_kb_for_database(db_id)
|
||||
kb_instance.update_database(db_id, name, description, llm_info)
|
||||
|
||||
@ -576,13 +587,19 @@ class KnowledgeBaseManager:
|
||||
}
|
||||
if llm_info is not None:
|
||||
update_data["llm_info"] = llm_info
|
||||
|
||||
if additional_params is not None:
|
||||
update_data["additional_params"] = additional_params
|
||||
merged_additional_params = ensure_chunk_defaults_in_additional_params(
|
||||
deep_merge(kb.additional_params or {}, additional_params)
|
||||
)
|
||||
update_data["additional_params"] = merged_additional_params
|
||||
if db_id in kb_instance.databases_meta:
|
||||
kb_instance.databases_meta[db_id]["metadata"] = merged_additional_params
|
||||
|
||||
if share_config is not None:
|
||||
update_data["share_config"] = share_config
|
||||
|
||||
# 保存到数据库
|
||||
kb_repo = KnowledgeBaseRepository()
|
||||
await kb_repo.update(db_id, update_data)
|
||||
|
||||
return await self.get_database_info(db_id)
|
||||
|
||||
92
src/repositories/skill_repository.py
Normal file
92
src/repositories/skill_repository.py
Normal file
@ -0,0 +1,92 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.storage.postgres.models_business import Skill
|
||||
from src.utils.datetime_utils import utc_now_naive
|
||||
|
||||
|
||||
class SkillRepository:
|
||||
def __init__(self, db_session: AsyncSession):
|
||||
self.db = db_session
|
||||
|
||||
async def list_all(self) -> list[Skill]:
|
||||
result = await self.db.execute(select(Skill).order_by(Skill.updated_at.desc(), Skill.id.desc()))
|
||||
return list(result.scalars().all())
|
||||
|
||||
async def get_by_slug(self, slug: str) -> Skill | None:
|
||||
result = await self.db.execute(select(Skill).where(Skill.slug == slug))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def exists_slug(self, slug: str) -> bool:
|
||||
return (await self.get_by_slug(slug)) is not None
|
||||
|
||||
async def create(
|
||||
self,
|
||||
*,
|
||||
slug: str,
|
||||
name: str,
|
||||
description: str,
|
||||
tool_dependencies: list[str] | None,
|
||||
mcp_dependencies: list[str] | None,
|
||||
skill_dependencies: list[str] | None,
|
||||
dir_path: str,
|
||||
created_by: str | None,
|
||||
) -> Skill:
|
||||
now = utc_now_naive()
|
||||
item = Skill(
|
||||
slug=slug,
|
||||
name=name,
|
||||
description=description,
|
||||
tool_dependencies=tool_dependencies or [],
|
||||
mcp_dependencies=mcp_dependencies or [],
|
||||
skill_dependencies=skill_dependencies or [],
|
||||
dir_path=dir_path,
|
||||
created_by=created_by,
|
||||
updated_by=created_by,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
self.db.add(item)
|
||||
await self.db.commit()
|
||||
await self.db.refresh(item)
|
||||
return item
|
||||
|
||||
async def update_dependencies(
|
||||
self,
|
||||
item: Skill,
|
||||
*,
|
||||
tool_dependencies: list[str],
|
||||
mcp_dependencies: list[str],
|
||||
skill_dependencies: list[str],
|
||||
updated_by: str | None,
|
||||
) -> Skill:
|
||||
item.tool_dependencies = tool_dependencies
|
||||
item.mcp_dependencies = mcp_dependencies
|
||||
item.skill_dependencies = skill_dependencies
|
||||
item.updated_by = updated_by
|
||||
item.updated_at = utc_now_naive()
|
||||
await self.db.commit()
|
||||
await self.db.refresh(item)
|
||||
return item
|
||||
|
||||
async def update_metadata(
|
||||
self,
|
||||
item: Skill,
|
||||
*,
|
||||
name: str,
|
||||
description: str,
|
||||
updated_by: str | None,
|
||||
) -> Skill:
|
||||
item.name = name
|
||||
item.description = description
|
||||
item.updated_by = updated_by
|
||||
item.updated_at = utc_now_naive()
|
||||
await self.db.commit()
|
||||
await self.db.refresh(item)
|
||||
return item
|
||||
|
||||
async def delete(self, item: Skill) -> None:
|
||||
await self.db.delete(item)
|
||||
await self.db.commit()
|
||||
@ -231,6 +231,29 @@ class EvaluationService:
|
||||
logger.error(f"获取评估基准详情失败: {e}")
|
||||
raise
|
||||
|
||||
async def get_benchmark_download_info(self, benchmark_id: str) -> dict[str, str]:
|
||||
"""获取评估基准下载信息"""
|
||||
row = await self.eval_repo.get_benchmark(benchmark_id)
|
||||
if row is None:
|
||||
raise ValueError("Benchmark not found")
|
||||
|
||||
data_file_path = row.data_file_path or ""
|
||||
if not data_file_path or not os.path.exists(data_file_path):
|
||||
raise ValueError("Benchmark file not found")
|
||||
|
||||
filename_base = (row.name or "").strip()
|
||||
if not filename_base:
|
||||
filename_base = row.benchmark_id
|
||||
|
||||
filename_base = re.sub(r"[\\/:*?\"<>|]+", "_", filename_base).strip()
|
||||
if not filename_base or filename_base in {".", ".."}:
|
||||
filename_base = row.benchmark_id
|
||||
|
||||
if not filename_base.endswith(".jsonl"):
|
||||
filename_base = f"{filename_base}.jsonl"
|
||||
|
||||
return {"file_path": data_file_path, "filename": filename_base}
|
||||
|
||||
async def delete_benchmark(self, benchmark_id: str) -> None:
|
||||
"""删除评估基准"""
|
||||
try:
|
||||
|
||||
@ -35,6 +35,7 @@ _mcp_tools_stats: dict[str, dict[str, int]] = {}
|
||||
|
||||
# MCP Server configurations (Runtime cache, loaded from DB)
|
||||
MCP_SERVERS: dict[str, dict[str, Any]] = {}
|
||||
_UNSET = object()
|
||||
|
||||
# Default MCP Server configurations (Imported to DB on first run)
|
||||
_DEFAULT_MCP_SERVERS = {
|
||||
@ -130,6 +131,7 @@ async def init_mcp_servers() -> None:
|
||||
url=config.get("url"),
|
||||
command=config.get("command"),
|
||||
args=config.get("args"),
|
||||
env=config.get("env"),
|
||||
headers=config.get("headers"),
|
||||
timeout=config.get("timeout"),
|
||||
sse_read_timeout=config.get("sse_read_timeout"),
|
||||
@ -155,6 +157,7 @@ async def init_mcp_servers() -> None:
|
||||
url=config.get("url"),
|
||||
command=config.get("command"),
|
||||
args=config.get("args"),
|
||||
env=config.get("env"),
|
||||
headers=config.get("headers"),
|
||||
timeout=config.get("timeout"),
|
||||
sse_read_timeout=config.get("sse_read_timeout"),
|
||||
@ -368,6 +371,7 @@ async def create_mcp_server(
|
||||
url: str = None,
|
||||
command: str = None,
|
||||
args: list = None,
|
||||
env: dict = None,
|
||||
description: str = None,
|
||||
headers: dict = None,
|
||||
timeout: int = None,
|
||||
@ -389,6 +393,7 @@ async def create_mcp_server(
|
||||
url=url,
|
||||
command=command,
|
||||
args=args,
|
||||
env=env,
|
||||
headers=headers,
|
||||
timeout=timeout,
|
||||
sse_read_timeout=sse_read_timeout,
|
||||
@ -417,6 +422,7 @@ async def update_mcp_server(
|
||||
url: str = None,
|
||||
command: str = None,
|
||||
args: list = None,
|
||||
env: Any = _UNSET,
|
||||
headers: dict = None,
|
||||
timeout: int = None,
|
||||
sse_read_timeout: int = None,
|
||||
@ -439,6 +445,8 @@ async def update_mcp_server(
|
||||
server.command = command
|
||||
if args is not None:
|
||||
server.args = args
|
||||
if env is not _UNSET:
|
||||
server.env = env
|
||||
if headers is not None:
|
||||
server.headers = headers
|
||||
if timeout is not None:
|
||||
|
||||
226
src/services/skill_resolver.py
Normal file
226
src/services/skill_resolver.py
Normal file
@ -0,0 +1,226 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TypedDict
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.repositories.skill_repository import SkillRepository
|
||||
from src.storage.postgres.manager import pg_manager
|
||||
from src.storage.postgres.models_business import Skill
|
||||
from src.utils.logging_config import logger
|
||||
|
||||
|
||||
class SkillPromptMetadata(TypedDict):
|
||||
name: str
|
||||
description: str
|
||||
path: str
|
||||
|
||||
|
||||
class SkillDependencyNode(TypedDict):
|
||||
tools: list[str]
|
||||
mcps: list[str]
|
||||
skills: list[str]
|
||||
|
||||
|
||||
class SkillSessionSnapshot(TypedDict):
|
||||
selected_skills: list[str]
|
||||
visible_skills: list[str]
|
||||
prompt_metadata: dict[str, SkillPromptMetadata]
|
||||
dependency_map: dict[str, SkillDependencyNode]
|
||||
|
||||
|
||||
def normalize_selected_skills(selected_skills: list[str] | None) -> list[str]:
|
||||
return _normalize_string_list(selected_skills)
|
||||
|
||||
|
||||
def is_snapshot_match_selected_skills(
|
||||
snapshot: SkillSessionSnapshot | None,
|
||||
selected_skills: list[str] | None,
|
||||
) -> bool:
|
||||
if not snapshot:
|
||||
return False
|
||||
current = snapshot.get("selected_skills")
|
||||
if not isinstance(current, list):
|
||||
return False
|
||||
return current == normalize_selected_skills(selected_skills)
|
||||
|
||||
|
||||
async def resolve_session_snapshot(
|
||||
selected_skills: list[str] | None,
|
||||
*,
|
||||
db: AsyncSession | None = None,
|
||||
) -> SkillSessionSnapshot:
|
||||
normalized_selected = normalize_selected_skills(selected_skills)
|
||||
skills = await _list_skills_from_db(db)
|
||||
prompt_metadata, dependency_map = _build_maps(skills)
|
||||
visible_skills = expand_skill_closure(normalized_selected, dependency_map)
|
||||
return {
|
||||
"selected_skills": normalized_selected,
|
||||
"visible_skills": visible_skills,
|
||||
"prompt_metadata": prompt_metadata,
|
||||
"dependency_map": dependency_map,
|
||||
}
|
||||
|
||||
|
||||
def collect_prompt_metadata(
|
||||
snapshot: SkillSessionSnapshot | None,
|
||||
slugs: list[str] | None,
|
||||
) -> list[SkillPromptMetadata]:
|
||||
if not snapshot or not slugs:
|
||||
return []
|
||||
|
||||
prompt_metadata = snapshot.get("prompt_metadata") or {}
|
||||
result: list[SkillPromptMetadata] = []
|
||||
seen: set[str] = set()
|
||||
for slug in slugs:
|
||||
if not isinstance(slug, str):
|
||||
continue
|
||||
normalized = slug.strip()
|
||||
if not normalized or normalized in seen:
|
||||
continue
|
||||
seen.add(normalized)
|
||||
|
||||
item = prompt_metadata.get(normalized)
|
||||
if not item:
|
||||
logger.debug(f"Skill slug not found in session snapshot, skip prompt metadata: {normalized}")
|
||||
continue
|
||||
result.append(dict(item))
|
||||
return result
|
||||
|
||||
|
||||
def build_dependency_bundle(
|
||||
snapshot: SkillSessionSnapshot | None,
|
||||
activated_slugs: list[str] | None,
|
||||
) -> dict[str, list[str]]:
|
||||
if not snapshot:
|
||||
return {"tools": [], "mcps": [], "skills": []}
|
||||
|
||||
dependency_map = snapshot.get("dependency_map") or {}
|
||||
closure = expand_skill_closure(activated_slugs or [], dependency_map)
|
||||
tools: list[str] = []
|
||||
mcps: list[str] = []
|
||||
seen_tools: set[str] = set()
|
||||
seen_mcps: set[str] = set()
|
||||
|
||||
for slug in closure:
|
||||
dep = dependency_map.get(slug, {})
|
||||
for tool_name in dep.get("tools", []):
|
||||
if tool_name in seen_tools:
|
||||
continue
|
||||
seen_tools.add(tool_name)
|
||||
tools.append(tool_name)
|
||||
for mcp_name in dep.get("mcps", []):
|
||||
if mcp_name in seen_mcps:
|
||||
continue
|
||||
seen_mcps.add(mcp_name)
|
||||
mcps.append(mcp_name)
|
||||
|
||||
return {"tools": tools, "mcps": mcps, "skills": closure}
|
||||
|
||||
|
||||
def expand_skill_closure(
|
||||
slugs: list[str] | None,
|
||||
dependency_map: dict[str, SkillDependencyNode],
|
||||
) -> list[str]:
|
||||
ordered_roots = _normalize_string_list(slugs)
|
||||
if not ordered_roots:
|
||||
return []
|
||||
|
||||
result: list[str] = []
|
||||
seen: set[str] = set()
|
||||
|
||||
def dfs(slug: str, stack: set[str]) -> None:
|
||||
if slug in stack:
|
||||
logger.warning(f"Cycle detected in skill dependencies, skip: {' -> '.join([*stack, slug])}")
|
||||
return
|
||||
if slug in seen:
|
||||
return
|
||||
|
||||
node = dependency_map.get(slug)
|
||||
if not node:
|
||||
logger.warning(f"Skill dependency target not found in DB snapshot, skip: {slug}")
|
||||
return
|
||||
|
||||
seen.add(slug)
|
||||
result.append(slug)
|
||||
next_stack = set(stack)
|
||||
next_stack.add(slug)
|
||||
for dep in node.get("skills", []):
|
||||
dfs(dep, next_stack)
|
||||
|
||||
for root in ordered_roots:
|
||||
dfs(root, set())
|
||||
return result
|
||||
|
||||
|
||||
async def get_skill_options_from_db(
|
||||
*,
|
||||
db: AsyncSession | None = None,
|
||||
) -> list[dict[str, str]]:
|
||||
items = await _list_skills_from_db(db)
|
||||
return [
|
||||
{
|
||||
"id": item.slug,
|
||||
"name": item.name,
|
||||
"description": item.description,
|
||||
}
|
||||
for item in items
|
||||
]
|
||||
|
||||
|
||||
async def get_skill_slug_set_from_db(
|
||||
*,
|
||||
db: AsyncSession | None = None,
|
||||
) -> set[str]:
|
||||
items = await _list_skills_from_db(db)
|
||||
return {item.slug for item in items}
|
||||
|
||||
|
||||
def _normalize_string_list(values: list[str] | None) -> list[str]:
|
||||
if not values:
|
||||
return []
|
||||
normalized: list[str] = []
|
||||
seen: set[str] = set()
|
||||
for value in values:
|
||||
if not isinstance(value, str):
|
||||
continue
|
||||
item = value.strip()
|
||||
if not item or item in seen:
|
||||
continue
|
||||
seen.add(item)
|
||||
normalized.append(item)
|
||||
return normalized
|
||||
|
||||
|
||||
def _build_maps(skills: list[Skill]) -> tuple[dict[str, SkillPromptMetadata], dict[str, SkillDependencyNode]]:
|
||||
prompt_metadata: dict[str, SkillPromptMetadata] = {}
|
||||
dependency_map: dict[str, SkillDependencyNode] = {}
|
||||
for item in skills:
|
||||
prompt_metadata[item.slug] = {
|
||||
"name": item.name,
|
||||
"description": item.description,
|
||||
"path": f"/skills/{item.slug}/SKILL.md",
|
||||
}
|
||||
dependency_map[item.slug] = {
|
||||
"tools": _normalize_string_list(item.tool_dependencies or []),
|
||||
"mcps": _normalize_string_list(item.mcp_dependencies or []),
|
||||
"skills": _normalize_string_list(item.skill_dependencies or []),
|
||||
}
|
||||
return prompt_metadata, dependency_map
|
||||
|
||||
|
||||
async def _list_skills_from_db(db: AsyncSession | None) -> list[Skill]:
|
||||
if db is not None:
|
||||
repo = SkillRepository(db)
|
||||
return await repo.list_all()
|
||||
|
||||
try:
|
||||
async with pg_manager.get_async_session_context() as session:
|
||||
repo = SkillRepository(session)
|
||||
return await repo.list_all()
|
||||
except RuntimeError:
|
||||
# 在非 FastAPI 生命周期场景(如 worker/脚本)按需初始化
|
||||
pg_manager.initialize()
|
||||
async with pg_manager.get_async_session_context() as session:
|
||||
repo = SkillRepository(session)
|
||||
return await repo.list_all()
|
||||
527
src/services/skill_service.py
Normal file
527
src/services/skill_service.py
Normal file
@ -0,0 +1,527 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import shutil
|
||||
import tempfile
|
||||
import uuid
|
||||
import zipfile
|
||||
from pathlib import Path, PurePosixPath
|
||||
from typing import Any
|
||||
|
||||
import yaml
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src import config as sys_config
|
||||
from src.repositories.skill_repository import SkillRepository
|
||||
from src.services.mcp_service import get_mcp_server_names
|
||||
from src.storage.postgres.models_business import Skill
|
||||
|
||||
SKILL_SLUG_PATTERN = re.compile(r"^[a-z0-9]+(-[a-z0-9]+)*$")
|
||||
SKILL_NAME_PATTERN = SKILL_SLUG_PATTERN
|
||||
FRONTMATTER_PATTERN = re.compile(r"^---\s*\n(.*?)\n---\s*\n", re.DOTALL)
|
||||
|
||||
TEXT_FILE_EXTENSIONS = {
|
||||
".md",
|
||||
".txt",
|
||||
".py",
|
||||
".js",
|
||||
".ts",
|
||||
".json",
|
||||
".yaml",
|
||||
".yml",
|
||||
".toml",
|
||||
".ini",
|
||||
".cfg",
|
||||
".conf",
|
||||
".xml",
|
||||
".html",
|
||||
".css",
|
||||
".sql",
|
||||
".sh",
|
||||
".bat",
|
||||
".ps1",
|
||||
".env",
|
||||
".csv",
|
||||
".tsv",
|
||||
".rst",
|
||||
".ipynb",
|
||||
".vue",
|
||||
".jsx",
|
||||
".tsx",
|
||||
}
|
||||
|
||||
def _normalize_string_list(values: list[str] | None) -> list[str]:
|
||||
if not values:
|
||||
return []
|
||||
normalized: list[str] = []
|
||||
seen: set[str] = set()
|
||||
for value in values:
|
||||
if not isinstance(value, str):
|
||||
continue
|
||||
item = value.strip()
|
||||
if not item or item in seen:
|
||||
continue
|
||||
seen.add(item)
|
||||
normalized.append(item)
|
||||
return normalized
|
||||
|
||||
|
||||
def is_valid_skill_slug(slug: str) -> bool:
|
||||
if not isinstance(slug, str):
|
||||
return False
|
||||
return bool(SKILL_SLUG_PATTERN.match(slug.strip()))
|
||||
|
||||
|
||||
def validate_skill_slug(slug: str) -> str:
|
||||
normalized = slug.strip() if isinstance(slug, str) else ""
|
||||
if not is_valid_skill_slug(normalized):
|
||||
raise ValueError("无效 skill slug")
|
||||
return normalized
|
||||
|
||||
|
||||
def _get_buildin_tool_names() -> list[str]:
|
||||
from src.agents.common.tools import get_buildin_tools
|
||||
|
||||
return [tool.name for tool in get_buildin_tools()]
|
||||
|
||||
|
||||
def get_skills_root_dir() -> Path:
|
||||
root = Path(sys_config.save_dir) / "skills"
|
||||
root.mkdir(parents=True, exist_ok=True)
|
||||
return root
|
||||
|
||||
|
||||
async def get_skill_dependency_options(db: AsyncSession) -> dict[str, list[str]]:
|
||||
repo = SkillRepository(db)
|
||||
items = await repo.list_all()
|
||||
return {
|
||||
"tools": _get_buildin_tool_names(),
|
||||
"mcps": get_mcp_server_names(),
|
||||
"skills": [item.slug for item in items],
|
||||
}
|
||||
|
||||
|
||||
async def list_skills(db: AsyncSession) -> list[Skill]:
|
||||
repo = SkillRepository(db)
|
||||
return await repo.list_all()
|
||||
|
||||
|
||||
def _validate_dependencies(
|
||||
*,
|
||||
slug: str,
|
||||
tool_dependencies: list[str],
|
||||
mcp_dependencies: list[str],
|
||||
skill_dependencies: list[str],
|
||||
available_skill_slugs: set[str],
|
||||
) -> tuple[list[str], list[str], list[str]]:
|
||||
tools = _normalize_string_list(tool_dependencies)
|
||||
mcps = _normalize_string_list(mcp_dependencies)
|
||||
skills = _normalize_string_list(skill_dependencies)
|
||||
|
||||
available_tools = set(_get_buildin_tool_names())
|
||||
invalid_tools = [name for name in tools if name not in available_tools]
|
||||
if invalid_tools:
|
||||
raise ValueError(f"存在无效工具依赖: {', '.join(invalid_tools)}")
|
||||
|
||||
available_mcps = set(get_mcp_server_names())
|
||||
invalid_mcps = [name for name in mcps if name not in available_mcps]
|
||||
if invalid_mcps:
|
||||
raise ValueError(f"存在无效 MCP 依赖: {', '.join(invalid_mcps)}")
|
||||
|
||||
invalid_skills = [name for name in skills if name not in available_skill_slugs]
|
||||
if invalid_skills:
|
||||
raise ValueError(f"存在无效 skill 依赖: {', '.join(invalid_skills)}")
|
||||
|
||||
if slug in skills:
|
||||
raise ValueError("skill_dependencies 不允许包含自身")
|
||||
|
||||
return tools, mcps, skills
|
||||
|
||||
|
||||
async def update_skill_dependencies(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
slug: str,
|
||||
tool_dependencies: list[str],
|
||||
mcp_dependencies: list[str],
|
||||
skill_dependencies: list[str],
|
||||
updated_by: str | None,
|
||||
) -> Skill:
|
||||
item = await get_skill_or_raise(db, slug)
|
||||
repo = SkillRepository(db)
|
||||
skill_items = await repo.list_all()
|
||||
available_skill_slugs = {skill.slug for skill in skill_items}
|
||||
tools, mcps, skills = _validate_dependencies(
|
||||
slug=slug,
|
||||
tool_dependencies=tool_dependencies,
|
||||
mcp_dependencies=mcp_dependencies,
|
||||
skill_dependencies=skill_dependencies,
|
||||
available_skill_slugs=available_skill_slugs,
|
||||
)
|
||||
|
||||
return await repo.update_dependencies(
|
||||
item,
|
||||
tool_dependencies=tools,
|
||||
mcp_dependencies=mcps,
|
||||
skill_dependencies=skills,
|
||||
updated_by=updated_by,
|
||||
)
|
||||
|
||||
|
||||
def _validate_skill_name(name: str) -> str:
|
||||
name = name.strip()
|
||||
if not name:
|
||||
raise ValueError("SKILL.md frontmatter 缺少 name")
|
||||
if len(name) > 128:
|
||||
raise ValueError("skill name 长度不能超过 128")
|
||||
if not SKILL_NAME_PATTERN.match(name):
|
||||
raise ValueError("skill name 必须是小写字母/数字/短横线,且不能连续短横线")
|
||||
return name
|
||||
|
||||
|
||||
def _parse_skill_markdown(content: str) -> tuple[str, str, dict[str, Any]]:
|
||||
match = FRONTMATTER_PATTERN.match(content)
|
||||
if not match:
|
||||
raise ValueError("SKILL.md 缺少有效 frontmatter(--- ... ---)")
|
||||
|
||||
frontmatter_raw = match.group(1)
|
||||
try:
|
||||
data = yaml.safe_load(frontmatter_raw)
|
||||
except yaml.YAMLError as e:
|
||||
raise ValueError(f"SKILL.md frontmatter YAML 解析失败: {e}") from e
|
||||
|
||||
if not isinstance(data, dict):
|
||||
raise ValueError("SKILL.md frontmatter 必须是对象")
|
||||
|
||||
name = _validate_skill_name(str(data.get("name", "")))
|
||||
description = str(data.get("description", "")).strip()
|
||||
if not description:
|
||||
raise ValueError("SKILL.md frontmatter 缺少 description")
|
||||
|
||||
return name, description, data
|
||||
|
||||
|
||||
def _rewrite_frontmatter_name(content: str, new_name: str) -> str:
|
||||
match = FRONTMATTER_PATTERN.match(content)
|
||||
if not match:
|
||||
raise ValueError("SKILL.md 缺少有效 frontmatter(--- ... ---)")
|
||||
|
||||
frontmatter_raw = match.group(1)
|
||||
body = content[match.end() :]
|
||||
data = yaml.safe_load(frontmatter_raw)
|
||||
if not isinstance(data, dict):
|
||||
raise ValueError("SKILL.md frontmatter 必须是对象")
|
||||
data["name"] = new_name
|
||||
dumped = yaml.safe_dump(data, sort_keys=False, allow_unicode=True).strip()
|
||||
return f"---\n{dumped}\n---\n{body}"
|
||||
|
||||
|
||||
def _validate_zip_paths(zip_file: zipfile.ZipFile) -> None:
|
||||
for name in zip_file.namelist():
|
||||
pure = PurePosixPath(name)
|
||||
if pure.is_absolute():
|
||||
raise ValueError(f"ZIP 包含不安全绝对路径: {name}")
|
||||
if ".." in pure.parts:
|
||||
raise ValueError(f"ZIP 包含路径穿越片段: {name}")
|
||||
|
||||
|
||||
async def _generate_available_slug(repo: SkillRepository, base_slug: str) -> str:
|
||||
root = get_skills_root_dir()
|
||||
if not await repo.exists_slug(base_slug) and not (root / base_slug).exists():
|
||||
return base_slug
|
||||
|
||||
idx = 2
|
||||
while True:
|
||||
candidate = f"{base_slug}-v{idx}"
|
||||
if not await repo.exists_slug(candidate) and not (root / candidate).exists():
|
||||
return candidate
|
||||
idx += 1
|
||||
|
||||
|
||||
def _resolve_skill_dir(item: Skill) -> Path:
|
||||
dir_path = Path(item.dir_path)
|
||||
if dir_path.is_absolute():
|
||||
return dir_path
|
||||
return (Path(sys_config.save_dir) / dir_path).resolve()
|
||||
|
||||
|
||||
def _resolve_relative_path(skill_dir: Path, relative_path: str, *, allow_root: bool = False) -> tuple[Path, str]:
|
||||
rel = (relative_path or "").strip().replace("\\", "/")
|
||||
rel = rel.lstrip("/")
|
||||
if not rel and not allow_root:
|
||||
raise ValueError("path 不能为空")
|
||||
pure = PurePosixPath(rel) if rel else PurePosixPath(".")
|
||||
if ".." in pure.parts:
|
||||
raise ValueError("非法路径:不允许上级路径引用")
|
||||
|
||||
target = (skill_dir / pure).resolve()
|
||||
try:
|
||||
target.relative_to(skill_dir)
|
||||
except ValueError:
|
||||
raise ValueError("非法路径:越界访问被拒绝") from None
|
||||
|
||||
return target, rel
|
||||
|
||||
|
||||
def _is_text_path(path: Path) -> bool:
|
||||
if path.name == "SKILL.md":
|
||||
return True
|
||||
suffix = path.suffix.lower()
|
||||
return suffix in TEXT_FILE_EXTENSIONS
|
||||
|
||||
|
||||
def _build_tree(path: Path, base_dir: Path) -> list[dict[str, Any]]:
|
||||
children: list[dict[str, Any]] = []
|
||||
for child in sorted(path.iterdir(), key=lambda p: (not p.is_dir(), p.name.lower())):
|
||||
rel = child.relative_to(base_dir).as_posix()
|
||||
if child.is_dir():
|
||||
children.append(
|
||||
{
|
||||
"name": child.name,
|
||||
"path": rel,
|
||||
"is_dir": True,
|
||||
"children": _build_tree(child, base_dir),
|
||||
}
|
||||
)
|
||||
else:
|
||||
children.append(
|
||||
{
|
||||
"name": child.name,
|
||||
"path": rel,
|
||||
"is_dir": False,
|
||||
}
|
||||
)
|
||||
return children
|
||||
|
||||
|
||||
async def import_skill_zip(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
filename: str,
|
||||
file_bytes: bytes,
|
||||
created_by: str | None,
|
||||
) -> Skill:
|
||||
if not filename.lower().endswith(".zip"):
|
||||
raise ValueError("仅支持上传 .zip 文件")
|
||||
|
||||
repo = SkillRepository(db)
|
||||
skills_root = get_skills_root_dir()
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix=".skill-import-", dir=str(skills_root.parent)) as temp_root:
|
||||
temp_root_path = Path(temp_root)
|
||||
zip_path = temp_root_path / "upload.zip"
|
||||
extract_dir = temp_root_path / "extract"
|
||||
stage_dir = temp_root_path / "stage"
|
||||
extract_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
zip_path.write_bytes(file_bytes)
|
||||
|
||||
with zipfile.ZipFile(zip_path, "r") as zf:
|
||||
_validate_zip_paths(zf)
|
||||
zf.extractall(extract_dir)
|
||||
|
||||
skill_md_files = list(extract_dir.rglob("SKILL.md"))
|
||||
if len(skill_md_files) != 1:
|
||||
raise ValueError("ZIP 必须且只能包含一个技能(检测到一个 SKILL.md)")
|
||||
|
||||
skill_md_path = skill_md_files[0]
|
||||
source_skill_dir = skill_md_path.parent
|
||||
content = skill_md_path.read_text(encoding="utf-8")
|
||||
parsed_name, parsed_desc, _ = _parse_skill_markdown(content)
|
||||
|
||||
final_slug = await _generate_available_slug(repo, parsed_name)
|
||||
final_name = parsed_name
|
||||
if final_slug != parsed_name:
|
||||
final_name = final_slug
|
||||
content = _rewrite_frontmatter_name(content, final_name)
|
||||
skill_md_path.write_text(content, encoding="utf-8")
|
||||
|
||||
shutil.copytree(source_skill_dir, stage_dir)
|
||||
|
||||
temp_target = skills_root / f".{final_slug}.tmp-{uuid.uuid4().hex[:8]}"
|
||||
if temp_target.exists():
|
||||
shutil.rmtree(temp_target)
|
||||
shutil.move(str(stage_dir), str(temp_target))
|
||||
|
||||
final_dir = skills_root / final_slug
|
||||
if final_dir.exists():
|
||||
shutil.rmtree(temp_target, ignore_errors=True)
|
||||
raise ValueError(f"技能目录冲突,请重试: {final_slug}")
|
||||
temp_target.rename(final_dir)
|
||||
|
||||
try:
|
||||
item = await repo.create(
|
||||
slug=final_slug,
|
||||
name=final_name,
|
||||
description=parsed_desc,
|
||||
tool_dependencies=[],
|
||||
mcp_dependencies=[],
|
||||
skill_dependencies=[],
|
||||
dir_path=(Path("skills") / final_slug).as_posix(),
|
||||
created_by=created_by,
|
||||
)
|
||||
except Exception:
|
||||
shutil.rmtree(final_dir, ignore_errors=True)
|
||||
raise
|
||||
|
||||
return item
|
||||
|
||||
|
||||
async def get_skill_or_raise(db: AsyncSession, slug: str) -> Skill:
|
||||
slug = validate_skill_slug(slug)
|
||||
repo = SkillRepository(db)
|
||||
item = await repo.get_by_slug(slug)
|
||||
if not item:
|
||||
raise ValueError(f"技能 '{slug}' 不存在")
|
||||
return item
|
||||
|
||||
|
||||
async def get_skill_tree(db: AsyncSession, slug: str) -> list[dict[str, Any]]:
|
||||
item = await get_skill_or_raise(db, slug)
|
||||
skill_dir = _resolve_skill_dir(item)
|
||||
if not skill_dir.exists() or not skill_dir.is_dir():
|
||||
raise ValueError(f"技能目录不存在: {item.dir_path}")
|
||||
return _build_tree(skill_dir, skill_dir)
|
||||
|
||||
|
||||
async def read_skill_file(db: AsyncSession, slug: str, relative_path: str) -> dict[str, Any]:
|
||||
item = await get_skill_or_raise(db, slug)
|
||||
skill_dir = _resolve_skill_dir(item)
|
||||
target, rel = _resolve_relative_path(skill_dir, relative_path)
|
||||
if not target.exists() or not target.is_file():
|
||||
raise ValueError(f"文件不存在: {relative_path}")
|
||||
if not _is_text_path(target):
|
||||
raise ValueError("仅支持读取文本文件")
|
||||
try:
|
||||
content = target.read_text(encoding="utf-8")
|
||||
except UnicodeDecodeError as e:
|
||||
raise ValueError(f"文件编码不支持(仅支持 UTF-8): {e}") from e
|
||||
|
||||
return {"path": rel, "content": content}
|
||||
|
||||
|
||||
async def create_skill_node(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
slug: str,
|
||||
relative_path: str,
|
||||
is_dir: bool,
|
||||
content: str | None,
|
||||
updated_by: str | None,
|
||||
) -> None:
|
||||
item = await get_skill_or_raise(db, slug)
|
||||
skill_dir = _resolve_skill_dir(item)
|
||||
target, _ = _resolve_relative_path(skill_dir, relative_path)
|
||||
if target.exists():
|
||||
raise ValueError("目标已存在")
|
||||
|
||||
if is_dir:
|
||||
target.mkdir(parents=True, exist_ok=False)
|
||||
return
|
||||
|
||||
if not _is_text_path(target):
|
||||
raise ValueError("仅支持创建文本文件")
|
||||
|
||||
parsed_name: str | None = None
|
||||
parsed_desc: str | None = None
|
||||
if target.name == "SKILL.md" and target.parent == skill_dir:
|
||||
parsed_name, parsed_desc, _ = _parse_skill_markdown(content or "")
|
||||
if parsed_name != item.slug:
|
||||
raise ValueError("SKILL.md frontmatter.name 必须与 skill slug 一致")
|
||||
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
target.write_text(content or "", encoding="utf-8")
|
||||
|
||||
if parsed_name is not None and parsed_desc is not None:
|
||||
repo = SkillRepository(db)
|
||||
await repo.update_metadata(item, name=parsed_name, description=parsed_desc, updated_by=updated_by)
|
||||
|
||||
|
||||
async def update_skill_file(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
slug: str,
|
||||
relative_path: str,
|
||||
content: str,
|
||||
updated_by: str | None,
|
||||
) -> None:
|
||||
item = await get_skill_or_raise(db, slug)
|
||||
skill_dir = _resolve_skill_dir(item)
|
||||
target, _ = _resolve_relative_path(skill_dir, relative_path)
|
||||
if not target.exists() or not target.is_file():
|
||||
raise ValueError("文件不存在")
|
||||
if not _is_text_path(target):
|
||||
raise ValueError("仅支持编辑文本文件")
|
||||
|
||||
parsed_name = None
|
||||
parsed_desc = None
|
||||
if target.name == "SKILL.md" and target.parent == skill_dir:
|
||||
parsed_name, parsed_desc, _ = _parse_skill_markdown(content)
|
||||
if parsed_name != item.slug:
|
||||
raise ValueError("SKILL.md frontmatter.name 必须与 skill slug 一致")
|
||||
|
||||
target.write_text(content, encoding="utf-8")
|
||||
|
||||
if parsed_name is not None and parsed_desc is not None:
|
||||
repo = SkillRepository(db)
|
||||
await repo.update_metadata(item, name=parsed_name, description=parsed_desc, updated_by=updated_by)
|
||||
|
||||
|
||||
async def delete_skill_node(db: AsyncSession, *, slug: str, relative_path: str) -> None:
|
||||
item = await get_skill_or_raise(db, slug)
|
||||
skill_dir = _resolve_skill_dir(item)
|
||||
target, rel = _resolve_relative_path(skill_dir, relative_path, allow_root=False)
|
||||
if not target.exists():
|
||||
raise ValueError("目标不存在")
|
||||
|
||||
if rel == "SKILL.md":
|
||||
raise ValueError("不允许删除根目录 SKILL.md")
|
||||
|
||||
if target.is_dir():
|
||||
shutil.rmtree(target)
|
||||
else:
|
||||
target.unlink()
|
||||
|
||||
|
||||
async def export_skill_zip(db: AsyncSession, slug: str) -> tuple[str, str]:
|
||||
item = await get_skill_or_raise(db, slug)
|
||||
skill_dir = _resolve_skill_dir(item)
|
||||
if not skill_dir.exists() or not skill_dir.is_dir():
|
||||
raise ValueError("技能目录不存在")
|
||||
|
||||
fd, export_path = tempfile.mkstemp(prefix=f"skill-{slug}-", suffix=".zip")
|
||||
Path(export_path).unlink(missing_ok=True)
|
||||
export_file = Path(export_path)
|
||||
try:
|
||||
with zipfile.ZipFile(export_file, "w", compression=zipfile.ZIP_DEFLATED) as zf:
|
||||
for p in skill_dir.rglob("*"):
|
||||
arcname = Path(slug) / p.relative_to(skill_dir)
|
||||
zf.write(p, arcname.as_posix())
|
||||
except Exception:
|
||||
export_file.unlink(missing_ok=True)
|
||||
raise
|
||||
return export_path, f"{slug}.zip"
|
||||
|
||||
|
||||
async def delete_skill(db: AsyncSession, *, slug: str) -> None:
|
||||
repo = SkillRepository(db)
|
||||
item = await repo.get_by_slug(slug)
|
||||
if not item:
|
||||
raise ValueError(f"技能 '{slug}' 不存在")
|
||||
|
||||
skill_dir = _resolve_skill_dir(item)
|
||||
trash_dir: Path | None = None
|
||||
|
||||
if skill_dir.exists():
|
||||
trash_dir = skill_dir.with_name(f".deleted-{slug}-{uuid.uuid4().hex[:8]}")
|
||||
skill_dir.rename(trash_dir)
|
||||
|
||||
try:
|
||||
await repo.delete(item)
|
||||
except Exception:
|
||||
if trash_dir and trash_dir.exists():
|
||||
trash_dir.rename(skill_dir)
|
||||
raise
|
||||
|
||||
if trash_dir and trash_dir.exists():
|
||||
shutil.rmtree(trash_dir, ignore_errors=True)
|
||||
@ -341,6 +341,7 @@ class MCPServer(Base):
|
||||
url = Column(String(500), nullable=True, comment="服务器 URL(sse/streamable_http)")
|
||||
command = Column(String(500), nullable=True, comment="命令(stdio)")
|
||||
args = Column(JSON, nullable=True, comment="命令参数数组(stdio)")
|
||||
env = Column(JSON, nullable=True, comment="环境变量(stdio)")
|
||||
headers = Column(JSON, nullable=True, comment="HTTP 请求头")
|
||||
timeout = Column(Integer, nullable=True, comment="HTTP 超时时间(秒)")
|
||||
sse_read_timeout = Column(Integer, nullable=True, comment="SSE 读取超时(秒)")
|
||||
@ -369,6 +370,7 @@ class MCPServer(Base):
|
||||
"url": self.url,
|
||||
"command": self.command,
|
||||
"args": self.args or [],
|
||||
"env": self.env or {},
|
||||
"headers": self.headers or {},
|
||||
"timeout": self.timeout,
|
||||
"sse_read_timeout": self.sse_read_timeout,
|
||||
@ -402,6 +404,14 @@ class MCPServer(Base):
|
||||
config["args"] = json.loads(self.args)
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
if self.transport == "stdio" and self.env:
|
||||
if isinstance(self.env, dict):
|
||||
config["env"] = self.env
|
||||
elif isinstance(self.env, str):
|
||||
try:
|
||||
config["env"] = json.loads(self.env)
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
# headers 只用于 sse/streamable_http 传输类型
|
||||
if self.transport in ("sse", "streamable_http") and self.headers:
|
||||
if isinstance(self.headers, dict):
|
||||
|
||||
@ -164,6 +164,19 @@ class PostgresManager(metaclass=SingletonMeta):
|
||||
for stmt in stmts:
|
||||
await conn.execute(text(stmt))
|
||||
|
||||
async def ensure_business_schema(self):
|
||||
"""确保业务 schema 包含后续新增字段(兼容已存在表)。"""
|
||||
self._check_initialized()
|
||||
stmts = [
|
||||
"ALTER TABLE IF EXISTS skills ADD COLUMN IF NOT EXISTS tool_dependencies JSONB DEFAULT '[]'::jsonb",
|
||||
"ALTER TABLE IF EXISTS skills ADD COLUMN IF NOT EXISTS mcp_dependencies JSONB DEFAULT '[]'::jsonb",
|
||||
"ALTER TABLE IF EXISTS skills ADD COLUMN IF NOT EXISTS skill_dependencies JSONB DEFAULT '[]'::jsonb",
|
||||
"ALTER TABLE IF EXISTS mcp_servers ADD COLUMN IF NOT EXISTS env JSONB",
|
||||
]
|
||||
async with self.async_engine.begin() as conn:
|
||||
for stmt in stmts:
|
||||
await conn.execute(text(stmt))
|
||||
|
||||
@property
|
||||
def is_postgresql(self) -> bool:
|
||||
"""检查是否是 PostgreSQL 数据库"""
|
||||
|
||||
@ -171,6 +171,41 @@ class AgentConfig(Base):
|
||||
}
|
||||
|
||||
|
||||
class Skill(Base):
|
||||
"""Skill 元数据模型(内容存文件系统,索引存数据库)"""
|
||||
|
||||
__tablename__ = "skills"
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
slug = Column(String(128), nullable=False, unique=True, index=True, comment="技能唯一标识(目录名)")
|
||||
name = Column(String(128), nullable=False, comment="技能名称(来自 SKILL.md frontmatter.name)")
|
||||
description = Column(Text, nullable=False, comment="技能描述(来自 SKILL.md frontmatter.description)")
|
||||
tool_dependencies = Column(JSON, nullable=False, default=list, comment="依赖的内置工具名列表")
|
||||
mcp_dependencies = Column(JSON, nullable=False, default=list, comment="依赖的 MCP 服务名列表")
|
||||
skill_dependencies = Column(JSON, nullable=False, default=list, comment="依赖的其他 skill slug 列表")
|
||||
dir_path = Column(String(512), nullable=False, comment="技能目录路径(相对 save_dir)")
|
||||
created_by = Column(String(64), nullable=True)
|
||||
updated_by = Column(String(64), nullable=True)
|
||||
created_at = Column(DateTime, default=utc_now_naive)
|
||||
updated_at = Column(DateTime, default=utc_now_naive, onupdate=utc_now_naive)
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"id": self.id,
|
||||
"slug": self.slug,
|
||||
"name": self.name,
|
||||
"description": self.description,
|
||||
"tool_dependencies": self.tool_dependencies or [],
|
||||
"mcp_dependencies": self.mcp_dependencies or [],
|
||||
"skill_dependencies": self.skill_dependencies or [],
|
||||
"dir_path": self.dir_path,
|
||||
"created_by": self.created_by,
|
||||
"updated_by": self.updated_by,
|
||||
"created_at": format_utc_datetime(self.created_at),
|
||||
"updated_at": format_utc_datetime(self.updated_at),
|
||||
}
|
||||
|
||||
|
||||
class Conversation(Base):
|
||||
"""Conversation table - 对话表"""
|
||||
|
||||
@ -381,6 +416,7 @@ class MCPServer(Base):
|
||||
url = Column(String(500), nullable=True, comment="服务器 URL(sse/streamable_http)")
|
||||
command = Column(String(500), nullable=True, comment="命令(stdio)")
|
||||
args = Column(JSON, nullable=True, comment="命令参数数组(stdio)")
|
||||
env = Column(JSON, nullable=True, comment="环境变量(stdio)")
|
||||
headers = Column(JSON, nullable=True, comment="HTTP 请求头")
|
||||
timeout = Column(Integer, nullable=True, comment="HTTP 超时时间(秒)")
|
||||
sse_read_timeout = Column(Integer, nullable=True, comment="SSE 读取超时(秒)")
|
||||
@ -409,6 +445,7 @@ class MCPServer(Base):
|
||||
"url": self.url,
|
||||
"command": self.command,
|
||||
"args": self.args or [],
|
||||
"env": self.env or {},
|
||||
"headers": self.headers or {},
|
||||
"timeout": self.timeout,
|
||||
"sse_read_timeout": self.sse_read_timeout,
|
||||
@ -440,6 +477,14 @@ class MCPServer(Base):
|
||||
config["args"] = json.loads(self.args)
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
if self.transport == "stdio" and self.env:
|
||||
if isinstance(self.env, dict):
|
||||
config["env"] = self.env
|
||||
elif isinstance(self.env, str):
|
||||
try:
|
||||
config["env"] = json.loads(self.env)
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
# headers 只用于 sse/streamable_http 传输类型
|
||||
if self.transport in ("sse", "streamable_http") and self.headers:
|
||||
if isinstance(self.headers, dict):
|
||||
|
||||
62
test/api/test_evaluation_router.py
Normal file
62
test/api/test_evaluation_router.py
Normal file
@ -0,0 +1,62 @@
|
||||
"""
|
||||
Integration tests for evaluation router endpoints.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
|
||||
pytestmark = [pytest.mark.asyncio, pytest.mark.integration]
|
||||
|
||||
|
||||
async def _upload_test_benchmark(test_client, admin_headers: dict[str, str], db_id: str) -> tuple[str, str]:
|
||||
benchmark_name = f"pytest_benchmark_{uuid.uuid4().hex[:8]}"
|
||||
line = '{"query":"什么是单元测试?","gold_answer":"用于验证代码行为的自动化测试"}\n'
|
||||
|
||||
response = await test_client.post(
|
||||
f"/api/evaluation/databases/{db_id}/benchmarks/upload",
|
||||
data={"name": benchmark_name, "description": "pytest benchmark for download"},
|
||||
files={"file": ("pytest_benchmark.jsonl", line.encode("utf-8"), "application/x-ndjson")},
|
||||
headers=admin_headers,
|
||||
)
|
||||
assert response.status_code == 200, response.text
|
||||
|
||||
payload = response.json()
|
||||
assert payload.get("message") == "success"
|
||||
benchmark_id = payload.get("data", {}).get("benchmark_id")
|
||||
assert benchmark_id
|
||||
return benchmark_id, line
|
||||
|
||||
|
||||
async def test_download_benchmark_requires_admin(test_client, standard_user):
|
||||
response = await test_client.get(
|
||||
"/api/evaluation/benchmarks/benchmark_fake/download",
|
||||
headers=standard_user["headers"],
|
||||
)
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
async def test_admin_can_download_benchmark(test_client, admin_headers, knowledge_database):
|
||||
benchmark_id, expected_line = await _upload_test_benchmark(test_client, admin_headers, knowledge_database["db_id"])
|
||||
|
||||
response = await test_client.get(
|
||||
f"/api/evaluation/benchmarks/{benchmark_id}/download",
|
||||
headers=admin_headers,
|
||||
)
|
||||
assert response.status_code == 200, response.text
|
||||
assert "application/x-ndjson" in response.headers.get("content-type", "")
|
||||
assert "attachment" in response.headers.get("content-disposition", "").lower()
|
||||
|
||||
content = response.content.decode("utf-8")
|
||||
assert expected_line.strip() in content
|
||||
|
||||
|
||||
async def test_download_benchmark_not_found(test_client, admin_headers):
|
||||
response = await test_client.get(
|
||||
f"/api/evaluation/benchmarks/benchmark_not_found_{uuid.uuid4().hex[:8]}/download",
|
||||
headers=admin_headers,
|
||||
)
|
||||
assert response.status_code == 404, response.text
|
||||
|
||||
@ -40,6 +40,59 @@ async def test_admin_can_manage_knowledge_databases(test_client, admin_headers,
|
||||
assert update_response.json()["database"]["description"] == "Updated by pytest"
|
||||
|
||||
|
||||
async def test_create_database_with_chunk_preset(test_client, admin_headers):
|
||||
db_name = f"pytest_chunk_preset_{uuid.uuid4().hex[:6]}"
|
||||
payload = {
|
||||
"database_name": db_name,
|
||||
"description": "Chunk preset create test",
|
||||
"embed_model_name": "siliconflow/BAAI/bge-m3",
|
||||
"kb_type": "milvus",
|
||||
"additional_params": {"chunk_preset_id": "book"},
|
||||
}
|
||||
|
||||
create_response = await test_client.post("/api/knowledge/databases", json=payload, headers=admin_headers)
|
||||
assert create_response.status_code == 200, create_response.text
|
||||
db_id = create_response.json()["db_id"]
|
||||
|
||||
info_response = await test_client.get(f"/api/knowledge/databases/{db_id}", headers=admin_headers)
|
||||
assert info_response.status_code == 200, info_response.text
|
||||
assert info_response.json()["additional_params"]["chunk_preset_id"] == "book"
|
||||
|
||||
delete_response = await test_client.delete(f"/api/knowledge/databases/{db_id}", headers=admin_headers)
|
||||
assert delete_response.status_code == 200, delete_response.text
|
||||
|
||||
|
||||
async def test_update_database_additional_params_merge_keeps_chunk_preset(
|
||||
test_client, admin_headers, knowledge_database
|
||||
):
|
||||
db_id = knowledge_database["db_id"]
|
||||
|
||||
first_update = await test_client.put(
|
||||
f"/api/knowledge/databases/{db_id}",
|
||||
json={
|
||||
"name": knowledge_database["name"],
|
||||
"description": "update with chunk preset",
|
||||
"additional_params": {"chunk_preset_id": "qa"},
|
||||
},
|
||||
headers=admin_headers,
|
||||
)
|
||||
assert first_update.status_code == 200, first_update.text
|
||||
|
||||
second_update = await test_client.put(
|
||||
f"/api/knowledge/databases/{db_id}",
|
||||
json={
|
||||
"name": knowledge_database["name"],
|
||||
"description": "update without additional params",
|
||||
},
|
||||
headers=admin_headers,
|
||||
)
|
||||
assert second_update.status_code == 200, second_update.text
|
||||
|
||||
info_response = await test_client.get(f"/api/knowledge/databases/{db_id}", headers=admin_headers)
|
||||
assert info_response.status_code == 200, info_response.text
|
||||
assert info_response.json()["additional_params"]["chunk_preset_id"] == "qa"
|
||||
|
||||
|
||||
async def test_knowledge_routes_enforce_permissions(test_client, standard_user, knowledge_database):
|
||||
db_id = knowledge_database["db_id"]
|
||||
|
||||
|
||||
@ -80,8 +80,8 @@ async def test_sync_thread_attachment_state_updates_graph(monkeypatch: pytest.Mo
|
||||
assert captured["write_config"] == {"configurable": {"thread_id": "thread-1", "user_id": "u1"}}
|
||||
assert captured["write_values"]["attachments"] == attachments
|
||||
assert "/attachments/resume.md" in captured["write_values"]["files"]
|
||||
assert "/attachments/old.md" not in captured["write_values"]["files"]
|
||||
assert "/work/result.md" in captured["write_values"]["files"]
|
||||
assert captured["write_values"]["files"]["/attachments/old.md"] is None
|
||||
assert "/work/result.md" not in captured["write_values"]["files"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
111
test/test_ragflow_like_chunking.py
Normal file
111
test/test_ragflow_like_chunking.py
Normal file
@ -0,0 +1,111 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.append(os.getcwd())
|
||||
|
||||
from src.knowledge.chunking.ragflow_like.dispatcher import chunk_markdown
|
||||
from src.knowledge.chunking.ragflow_like.nlp import bullets_category
|
||||
from src.knowledge.chunking.ragflow_like.presets import (
|
||||
CHUNK_ENGINE_VERSION,
|
||||
get_chunk_preset_options,
|
||||
map_to_internal_parser_id,
|
||||
resolve_chunk_processing_params,
|
||||
)
|
||||
|
||||
|
||||
def test_general_maps_to_naive() -> None:
|
||||
assert map_to_internal_parser_id("general") == "naive"
|
||||
|
||||
|
||||
def test_resolve_chunk_processing_params_priority() -> None:
|
||||
resolved = resolve_chunk_processing_params(
|
||||
kb_additional_params={
|
||||
"chunk_preset_id": "book",
|
||||
"chunk_parser_config": {"chunk_token_num": 300, "delimiter": "\\n"},
|
||||
},
|
||||
file_processing_params={
|
||||
"chunk_preset_id": "qa",
|
||||
"chunk_parser_config": {"delimiter": "###"},
|
||||
},
|
||||
request_params={
|
||||
"chunk_preset_id": "laws",
|
||||
"chunk_parser_config": {"chunk_token_num": 666},
|
||||
"chunk_size": 777,
|
||||
},
|
||||
)
|
||||
|
||||
assert resolved["chunk_preset_id"] == "laws"
|
||||
assert resolved["chunk_engine_version"] == CHUNK_ENGINE_VERSION
|
||||
# legacy chunk_size 在当前实现里会映射为 chunk_token_num
|
||||
assert resolved["chunk_parser_config"]["chunk_token_num"] == 777
|
||||
assert resolved["chunk_parser_config"]["delimiter"] == "###"
|
||||
|
||||
|
||||
def test_qa_chunking_from_markdown_headings() -> None:
|
||||
content = """
|
||||
# 问题一
|
||||
这是答案一。
|
||||
|
||||
## 子问题
|
||||
这是答案二。
|
||||
""".strip()
|
||||
|
||||
chunks = chunk_markdown(
|
||||
markdown_content=content,
|
||||
file_id="file_1",
|
||||
filename="faq.md",
|
||||
processing_params={"chunk_preset_id": "qa", "chunk_parser_config": {}},
|
||||
)
|
||||
|
||||
assert len(chunks) >= 1
|
||||
assert "问题:" in chunks[0]["content"]
|
||||
assert "回答:" in chunks[0]["content"]
|
||||
|
||||
|
||||
def test_book_chunking_hierarchical_merge() -> None:
|
||||
content = """
|
||||
第一章 总则
|
||||
第一节 适用范围
|
||||
本规范适用于测试场景。
|
||||
第二节 基本原则
|
||||
应当遵循最小改动原则。
|
||||
""".strip()
|
||||
|
||||
chunks = chunk_markdown(
|
||||
markdown_content=content,
|
||||
file_id="file_2",
|
||||
filename="book.txt",
|
||||
processing_params={"chunk_preset_id": "book", "chunk_parser_config": {"chunk_token_num": 256}},
|
||||
)
|
||||
|
||||
assert len(chunks) >= 1
|
||||
assert any("第一章" in ck["content"] for ck in chunks)
|
||||
|
||||
|
||||
def test_markdown_heading_has_higher_weight_in_bullet_category() -> None:
|
||||
sections = [
|
||||
"# 3.2 个人所得项目及计税、申报方式概括",
|
||||
"一、关于季节工、临时工等费用税前扣除问题,以下规定继续执行。",
|
||||
"二、根据现行规定,补贴收入应并入工资薪金所得。",
|
||||
"(一)从超出国家规定比例支付的补贴,不属于免税福利费。",
|
||||
]
|
||||
|
||||
# 命中 markdown 标题模式(BULLET_PATTERN 下标 4)时,应该优先选中该组。
|
||||
assert bullets_category(sections) == 4
|
||||
|
||||
|
||||
def test_mid_sentence_bullet_marker_should_not_be_treated_as_heading() -> None:
|
||||
sections = [
|
||||
"根据前述规则:一、这里是句中枚举,不是章节标题,不能被当成层级。",
|
||||
"延续上文:(二)这里同样是正文中的枚举表达,不是独立标题。",
|
||||
"## 3.4 交通补贴的个税处理",
|
||||
]
|
||||
assert bullets_category(sections) == 4
|
||||
|
||||
|
||||
def test_chunk_preset_options_include_description() -> None:
|
||||
options = get_chunk_preset_options()
|
||||
assert len(options) == 4
|
||||
assert all(isinstance(option.get("description"), str) and option["description"] for option in options)
|
||||
352
test/test_runtime_config_middleware_skills.py
Normal file
352
test/test_runtime_config_middleware_skills.py
Normal file
@ -0,0 +1,352 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from langchain_core.messages import SystemMessage, ToolMessage
|
||||
from langgraph.types import Command
|
||||
|
||||
import src.agents.common.middlewares.runtime_config_middleware as runtime_middleware
|
||||
from src.agents.common.middlewares.runtime_config_middleware import RuntimeConfigMiddleware
|
||||
|
||||
|
||||
@dataclass
|
||||
class _FakeTool:
|
||||
name: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class _FakeRequest:
|
||||
runtime: Any
|
||||
tools: list[Any]
|
||||
system_message: SystemMessage
|
||||
state: dict[str, Any]
|
||||
|
||||
def override(self, **kwargs):
|
||||
return _FakeRequest(
|
||||
runtime=kwargs.get("runtime", self.runtime),
|
||||
tools=kwargs.get("tools", self.tools),
|
||||
system_message=kwargs.get("system_message", self.system_message),
|
||||
state=kwargs.get("state", self.state),
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class _FakeToolCallRequest:
|
||||
tool_call: dict[str, Any]
|
||||
runtime: Any
|
||||
state: dict[str, Any]
|
||||
|
||||
|
||||
async def _echo_handler(request):
|
||||
return request
|
||||
|
||||
|
||||
def _build_request(*, skills: list[str], tools: list[str], system_prompt: str = "你是助手", state=None) -> _FakeRequest:
|
||||
context = SimpleNamespace(system_prompt=system_prompt, skills=skills, tools=[], knowledges=[], mcps=[])
|
||||
runtime = SimpleNamespace(context=context)
|
||||
return _FakeRequest(
|
||||
runtime=runtime,
|
||||
tools=[_FakeTool(name=name) for name in tools],
|
||||
system_message=SystemMessage(content=[{"type": "text", "text": "base"}]),
|
||||
state=state or {},
|
||||
)
|
||||
|
||||
|
||||
def _build_tool_request(*, skills: list[str], visible_skills: list[str], file_path: str) -> _FakeToolCallRequest:
|
||||
return _FakeToolCallRequest(
|
||||
tool_call={"name": "read_file", "args": {"file_path": file_path}},
|
||||
runtime=SimpleNamespace(context=SimpleNamespace(skills=skills)),
|
||||
state={
|
||||
"skill_session_snapshot": {
|
||||
"selected_skills": skills,
|
||||
"visible_skills": visible_skills,
|
||||
"prompt_metadata": {},
|
||||
"dependency_map": {},
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _extract_appended_prompt(request: _FakeRequest) -> str:
|
||||
return request.system_message.content_blocks[-1]["text"]
|
||||
|
||||
|
||||
def _build_middleware() -> RuntimeConfigMiddleware:
|
||||
return RuntimeConfigMiddleware(
|
||||
enable_model_override=False,
|
||||
enable_tools_override=False,
|
||||
enable_system_prompt_override=True,
|
||||
enable_skills_prompt_override=True,
|
||||
)
|
||||
|
||||
|
||||
def _build_snapshot(selected: list[str], metadata: dict[str, dict[str, str]] | None = None) -> dict[str, Any]:
|
||||
return {
|
||||
"selected_skills": selected,
|
||||
"visible_skills": selected,
|
||||
"prompt_metadata": metadata or {},
|
||||
"dependency_map": {},
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_injects_skills_section_when_skills_configured_and_read_file_available(monkeypatch: pytest.MonkeyPatch):
|
||||
async def fake_resolve(selected):
|
||||
assert selected == ["research-report"]
|
||||
return _build_snapshot(
|
||||
["research-report"],
|
||||
{
|
||||
"research-report": {
|
||||
"name": "research-report",
|
||||
"description": "Write structured research reports",
|
||||
"path": "/skills/research-report/SKILL.md",
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
monkeypatch.setattr(runtime_middleware, "resolve_session_snapshot", fake_resolve)
|
||||
middleware = _build_middleware()
|
||||
request = _build_request(skills=["research-report"], tools=["read_file"])
|
||||
|
||||
result = await middleware.awrap_model_call(request, _echo_handler)
|
||||
prompt = _extract_appended_prompt(result)
|
||||
|
||||
assert "## Skills System" in prompt
|
||||
assert "**Skills Skills**: `/skills/` (higher priority)" in prompt
|
||||
assert "- **research-report**: Write structured research reports" in prompt
|
||||
assert "Read `/skills/research-report/SKILL.md` for full instructions" in prompt
|
||||
assert "Recognize when a skill applies" in prompt
|
||||
assert "当前时间:" in prompt
|
||||
assert "skill_session_snapshot" in result.state
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_skips_skills_section_when_context_skills_empty():
|
||||
middleware = _build_middleware()
|
||||
request = _build_request(skills=[], tools=["read_file"])
|
||||
|
||||
result = await middleware.awrap_model_call(request, _echo_handler)
|
||||
prompt = _extract_appended_prompt(result)
|
||||
|
||||
assert "## Skills System" not in prompt
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_skips_skills_section_without_read_file_and_logs_warning(monkeypatch: pytest.MonkeyPatch):
|
||||
warnings: list[str] = []
|
||||
fake_logger = SimpleNamespace(
|
||||
debug=lambda *_args, **_kwargs: None,
|
||||
warning=lambda message: warnings.append(message),
|
||||
)
|
||||
monkeypatch.setattr(runtime_middleware, "logger", fake_logger)
|
||||
|
||||
async def fake_resolve(_selected):
|
||||
return _build_snapshot(
|
||||
["research-report"],
|
||||
{
|
||||
"research-report": {
|
||||
"name": "research-report",
|
||||
"description": "Write structured research reports",
|
||||
"path": "/skills/research-report/SKILL.md",
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
monkeypatch.setattr(runtime_middleware, "resolve_session_snapshot", fake_resolve)
|
||||
middleware = _build_middleware()
|
||||
request = _build_request(skills=["research-report"], tools=["write_file"])
|
||||
|
||||
result = await middleware.awrap_model_call(request, _echo_handler)
|
||||
prompt = _extract_appended_prompt(result)
|
||||
|
||||
assert "## Skills System" not in prompt
|
||||
assert any("read_file unavailable" in msg for msg in warnings)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_injects_skills_in_input_order_with_dedup_and_invalid_slug_skipped(monkeypatch: pytest.MonkeyPatch):
|
||||
async def fake_resolve(_selected):
|
||||
return _build_snapshot(
|
||||
["beta", "missing", "alpha", "beta"],
|
||||
{
|
||||
"beta": {"name": "beta", "description": "beta skill", "path": "/skills/beta/SKILL.md"},
|
||||
"alpha": {"name": "alpha", "description": "alpha skill", "path": "/skills/alpha/SKILL.md"},
|
||||
},
|
||||
)
|
||||
|
||||
monkeypatch.setattr(runtime_middleware, "resolve_session_snapshot", fake_resolve)
|
||||
middleware = _build_middleware()
|
||||
request = _build_request(skills=["beta", "missing", "alpha", "beta"], tools=["read_file"])
|
||||
|
||||
result = await middleware.awrap_model_call(request, _echo_handler)
|
||||
prompt = _extract_appended_prompt(result)
|
||||
|
||||
beta_line = "- **beta**: beta skill"
|
||||
alpha_line = "- **alpha**: alpha skill"
|
||||
assert beta_line in prompt
|
||||
assert alpha_line in prompt
|
||||
assert prompt.find(beta_line) < prompt.find(alpha_line)
|
||||
assert prompt.count(beta_line) == 1
|
||||
assert "missing" not in prompt
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_awrap_tool_call_activates_skill_when_read_skill_md():
|
||||
middleware = _build_middleware()
|
||||
request = _build_tool_request(
|
||||
skills=["research-report"],
|
||||
visible_skills=["research-report"],
|
||||
file_path="/skills/research-report/SKILL.md",
|
||||
)
|
||||
|
||||
async def _handler(_request):
|
||||
return ToolMessage(content="ok", tool_call_id="tc-1")
|
||||
|
||||
result = await middleware.awrap_tool_call(request, _handler)
|
||||
assert isinstance(result, Command)
|
||||
assert result.update["activated_skills"] == ["research-report"]
|
||||
assert len(result.update["messages"]) == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_awrap_tool_call_skips_invalid_skill_slug_path():
|
||||
middleware = _build_middleware()
|
||||
request = _build_tool_request(
|
||||
skills=["research-report"],
|
||||
visible_skills=["research-report"],
|
||||
file_path="/skills/../SKILL.md",
|
||||
)
|
||||
|
||||
async def _handler(_request):
|
||||
return ToolMessage(content="ok", tool_call_id="tc-1")
|
||||
|
||||
result = await middleware.awrap_tool_call(request, _handler)
|
||||
assert isinstance(result, ToolMessage)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_awrap_tool_call_merges_with_existing_command_update():
|
||||
middleware = _build_middleware()
|
||||
request = _build_tool_request(
|
||||
skills=["research-report"],
|
||||
visible_skills=["research-report"],
|
||||
file_path="/skills/research-report/SKILL.md",
|
||||
)
|
||||
|
||||
async def _handler(_request):
|
||||
return Command(update={"messages": [ToolMessage(content="ok", tool_call_id="tc-1")], "activated_skills": ["a"]})
|
||||
|
||||
result = await middleware.awrap_tool_call(request, _handler)
|
||||
assert isinstance(result, Command)
|
||||
assert result.update["activated_skills"] == ["a", "research-report"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_awrap_tool_call_denies_invisible_skill():
|
||||
middleware = _build_middleware()
|
||||
request = _build_tool_request(
|
||||
skills=["research-report"],
|
||||
visible_skills=["alpha"],
|
||||
file_path="/skills/research-report/SKILL.md",
|
||||
)
|
||||
|
||||
async def _handler(_request):
|
||||
return ToolMessage(content="ok", tool_call_id="tc-1")
|
||||
|
||||
result = await middleware.awrap_tool_call(request, _handler)
|
||||
assert isinstance(result, ToolMessage)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_model_call_injects_dependency_tools_and_mcps_after_activation(monkeypatch: pytest.MonkeyPatch):
|
||||
monkeypatch.setattr(
|
||||
runtime_middleware,
|
||||
"get_buildin_tools",
|
||||
lambda: [_FakeTool(name="calculator"), _FakeTool(name="dep-tool")],
|
||||
)
|
||||
monkeypatch.setattr(runtime_middleware, "get_kb_based_tools", lambda db_names=None: [])
|
||||
monkeypatch.setattr(
|
||||
runtime_middleware,
|
||||
"build_dependency_bundle",
|
||||
lambda _snapshot, activated: {"tools": ["dep-tool"], "mcps": ["mcp-a"], "skills": activated},
|
||||
)
|
||||
|
||||
async def fake_get_enabled_mcp_tools(server_name: str):
|
||||
if server_name == "mcp-a":
|
||||
return [_FakeTool(name="mcp_tool")]
|
||||
return []
|
||||
|
||||
monkeypatch.setattr(runtime_middleware, "get_enabled_mcp_tools", fake_get_enabled_mcp_tools)
|
||||
|
||||
middleware = RuntimeConfigMiddleware(
|
||||
extra_tools=[_FakeTool(name="mcp_tool")],
|
||||
enable_model_override=False,
|
||||
enable_tools_override=True,
|
||||
enable_system_prompt_override=False,
|
||||
enable_skills_prompt_override=False,
|
||||
)
|
||||
|
||||
request = _build_request(
|
||||
skills=[],
|
||||
tools=["calculator", "dep-tool", "mcp_tool", "read_file"],
|
||||
state={"activated_skills": ["alpha"], "skill_session_snapshot": _build_snapshot([])},
|
||||
)
|
||||
|
||||
result = await middleware.awrap_model_call(request, _echo_handler)
|
||||
tool_names = [t.name for t in result.tools]
|
||||
assert "dep-tool" in tool_names
|
||||
assert "mcp_tool" in tool_names
|
||||
assert "calculator" not in tool_names
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_model_call_reuses_snapshot_until_skills_changed(monkeypatch: pytest.MonkeyPatch):
|
||||
called = {"count": 0}
|
||||
|
||||
async def fake_resolve(selected):
|
||||
called["count"] += 1
|
||||
return _build_snapshot(selected)
|
||||
|
||||
monkeypatch.setattr(runtime_middleware, "resolve_session_snapshot", fake_resolve)
|
||||
|
||||
middleware = _build_middleware()
|
||||
req1 = _build_request(skills=["alpha"], tools=["read_file"], state={})
|
||||
res1 = await middleware.awrap_model_call(req1, _echo_handler)
|
||||
assert called["count"] == 1
|
||||
|
||||
req2 = _build_request(skills=["alpha"], tools=["read_file"], state=res1.state)
|
||||
await middleware.awrap_model_call(req2, _echo_handler)
|
||||
assert called["count"] == 1
|
||||
|
||||
req3 = _build_request(skills=["beta"], tools=["read_file"], state=res1.state)
|
||||
await middleware.awrap_model_call(req3, _echo_handler)
|
||||
assert called["count"] == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_injects_dependency_skills_into_prompt(monkeypatch: pytest.MonkeyPatch):
|
||||
async def fake_resolve(_selected):
|
||||
return {
|
||||
"selected_skills": ["alpha"],
|
||||
"visible_skills": ["alpha", "beta"],
|
||||
"prompt_metadata": {
|
||||
"alpha": {"name": "alpha", "description": "alpha desc", "path": "/skills/alpha/SKILL.md"},
|
||||
"beta": {"name": "beta", "description": "beta desc", "path": "/skills/beta/SKILL.md"},
|
||||
},
|
||||
"dependency_map": {
|
||||
"alpha": {"tools": [], "mcps": [], "skills": ["beta"]},
|
||||
"beta": {"tools": [], "mcps": [], "skills": []},
|
||||
},
|
||||
}
|
||||
|
||||
monkeypatch.setattr(runtime_middleware, "resolve_session_snapshot", fake_resolve)
|
||||
middleware = _build_middleware()
|
||||
request = _build_request(skills=["alpha"], tools=["read_file"])
|
||||
|
||||
result = await middleware.awrap_model_call(request, _echo_handler)
|
||||
prompt = _extract_appended_prompt(result)
|
||||
assert "- **alpha**: alpha desc" in prompt
|
||||
assert "- **beta**: beta desc" in prompt
|
||||
85
test/test_skill_resolver.py
Normal file
85
test/test_skill_resolver.py
Normal file
@ -0,0 +1,85 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from src.services import skill_resolver as resolver
|
||||
from src.storage.postgres.models_business import Skill
|
||||
|
||||
|
||||
def test_expand_skill_closure_and_dependency_bundle():
|
||||
dependency_map = {
|
||||
"alpha": {"tools": ["t1"], "mcps": ["m1"], "skills": ["beta"]},
|
||||
"beta": {"tools": ["t2"], "mcps": ["m2"], "skills": ["gamma"]},
|
||||
"gamma": {"tools": ["t3"], "mcps": [], "skills": []},
|
||||
}
|
||||
snapshot: resolver.SkillSessionSnapshot = {
|
||||
"selected_skills": ["alpha"],
|
||||
"visible_skills": ["alpha", "beta", "gamma"],
|
||||
"prompt_metadata": {},
|
||||
"dependency_map": dependency_map,
|
||||
}
|
||||
|
||||
closure = resolver.expand_skill_closure(["alpha"], dependency_map)
|
||||
assert closure == ["alpha", "beta", "gamma"]
|
||||
|
||||
bundle = resolver.build_dependency_bundle(snapshot, ["alpha"])
|
||||
assert bundle["skills"] == ["alpha", "beta", "gamma"]
|
||||
assert bundle["tools"] == ["t1", "t2", "t3"]
|
||||
assert bundle["mcps"] == ["m1", "m2"]
|
||||
|
||||
|
||||
def test_expand_skill_closure_cycle():
|
||||
dependency_map = {
|
||||
"alpha": {"tools": [], "mcps": [], "skills": ["beta"]},
|
||||
"beta": {"tools": [], "mcps": [], "skills": ["alpha"]},
|
||||
}
|
||||
assert resolver.expand_skill_closure(["alpha"], dependency_map) == ["alpha", "beta"]
|
||||
|
||||
|
||||
def test_collect_prompt_metadata_order_and_dedup():
|
||||
snapshot: resolver.SkillSessionSnapshot = {
|
||||
"selected_skills": ["beta", "alpha"],
|
||||
"visible_skills": ["beta", "alpha"],
|
||||
"prompt_metadata": {
|
||||
"beta": {"name": "beta", "description": "beta skill", "path": "/skills/beta/SKILL.md"},
|
||||
"alpha": {"name": "alpha", "description": "alpha skill", "path": "/skills/alpha/SKILL.md"},
|
||||
},
|
||||
"dependency_map": {},
|
||||
}
|
||||
result = resolver.collect_prompt_metadata(snapshot, ["beta", "missing", "alpha", "beta"])
|
||||
assert [item["name"] for item in result] == ["beta", "alpha"]
|
||||
assert [item["path"] for item in result] == ["/skills/beta/SKILL.md", "/skills/alpha/SKILL.md"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_session_snapshot_and_selected_change(monkeypatch: pytest.MonkeyPatch):
|
||||
async def fake_list_skills(_db=None):
|
||||
return [
|
||||
Skill(
|
||||
slug="alpha",
|
||||
name="alpha",
|
||||
description="a",
|
||||
tool_dependencies=[],
|
||||
mcp_dependencies=[],
|
||||
skill_dependencies=["beta"],
|
||||
dir_path="skills/alpha",
|
||||
),
|
||||
Skill(
|
||||
slug="beta",
|
||||
name="beta",
|
||||
description="b",
|
||||
tool_dependencies=[],
|
||||
mcp_dependencies=[],
|
||||
skill_dependencies=[],
|
||||
dir_path="skills/beta",
|
||||
),
|
||||
]
|
||||
|
||||
monkeypatch.setattr(resolver, "_list_skills_from_db", fake_list_skills)
|
||||
|
||||
snapshot = await resolver.resolve_session_snapshot([" alpha ", "alpha"])
|
||||
assert snapshot["selected_skills"] == ["alpha"]
|
||||
assert snapshot["visible_skills"] == ["alpha", "beta"]
|
||||
|
||||
assert resolver.is_snapshot_match_selected_skills(snapshot, ["alpha"]) is True
|
||||
assert resolver.is_snapshot_match_selected_skills(snapshot, ["beta"]) is False
|
||||
165
test/test_skill_router.py
Normal file
165
test/test_skill_router.py
Normal file
@ -0,0 +1,165 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from server.routers.skill_router import skills
|
||||
from server.utils.auth_middleware import get_admin_user, get_db, get_superadmin_user
|
||||
from src.storage.postgres.models_business import Skill, User
|
||||
|
||||
|
||||
def _build_app(*, allow_superadmin: bool) -> FastAPI:
|
||||
app = FastAPI()
|
||||
app.include_router(skills, prefix="/api")
|
||||
|
||||
async def fake_db():
|
||||
return None
|
||||
|
||||
async def fake_admin_user():
|
||||
return User(
|
||||
username="admin",
|
||||
user_id="admin",
|
||||
password_hash="x",
|
||||
role="admin",
|
||||
)
|
||||
|
||||
async def fake_superadmin_user():
|
||||
if not allow_superadmin:
|
||||
raise HTTPException(status_code=403, detail="需要超级管理员权限")
|
||||
return User(
|
||||
username="root",
|
||||
user_id="root",
|
||||
password_hash="x",
|
||||
role="superadmin",
|
||||
)
|
||||
|
||||
app.dependency_overrides[get_db] = fake_db
|
||||
app.dependency_overrides[get_admin_user] = fake_admin_user
|
||||
app.dependency_overrides[get_superadmin_user] = fake_superadmin_user
|
||||
return app
|
||||
|
||||
|
||||
def test_list_skills_route_returns_data(monkeypatch):
|
||||
async def fake_list_skills(_db):
|
||||
return [
|
||||
Skill(
|
||||
slug="demo",
|
||||
name="demo",
|
||||
description="demo skill",
|
||||
dir_path="skills/demo",
|
||||
)
|
||||
]
|
||||
|
||||
monkeypatch.setattr("server.routers.skill_router.list_skills", fake_list_skills)
|
||||
|
||||
app = _build_app(allow_superadmin=True)
|
||||
client = TestClient(app)
|
||||
resp = client.get("/api/system/skills")
|
||||
assert resp.status_code == 200, resp.text
|
||||
payload = resp.json()
|
||||
assert payload["success"] is True
|
||||
assert payload["data"][0]["slug"] == "demo"
|
||||
|
||||
|
||||
def test_import_skill_requires_superadmin():
|
||||
app = _build_app(allow_superadmin=False)
|
||||
client = TestClient(app)
|
||||
|
||||
resp = client.post(
|
||||
"/api/system/skills/import",
|
||||
files={"file": ("demo.zip", b"not zip", "application/zip")},
|
||||
)
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
def test_update_skill_file_passes_operator(monkeypatch):
|
||||
captured: dict[str, str] = {}
|
||||
|
||||
async def fake_update_skill_file(_db, *, slug, relative_path, content, updated_by):
|
||||
captured["slug"] = slug
|
||||
captured["relative_path"] = relative_path
|
||||
captured["content"] = content
|
||||
captured["updated_by"] = updated_by
|
||||
|
||||
monkeypatch.setattr("server.routers.skill_router.update_skill_file", fake_update_skill_file)
|
||||
|
||||
app = _build_app(allow_superadmin=True)
|
||||
client = TestClient(app)
|
||||
|
||||
resp = client.put(
|
||||
"/api/system/skills/demo/file",
|
||||
json={
|
||||
"path": "SKILL.md",
|
||||
"content": "---\nname: demo\ndescription: demo\n---\n# Demo\n",
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert captured["slug"] == "demo"
|
||||
assert captured["relative_path"] == "SKILL.md"
|
||||
assert captured["updated_by"] == "root"
|
||||
|
||||
|
||||
def test_dependency_options_route(monkeypatch):
|
||||
async def fake_get_skill_dependency_options(_db):
|
||||
return {
|
||||
"tools": ["calculator"],
|
||||
"mcps": ["mcp-a"],
|
||||
"skills": ["demo"],
|
||||
}
|
||||
|
||||
monkeypatch.setattr("server.routers.skill_router.get_skill_dependency_options", fake_get_skill_dependency_options)
|
||||
|
||||
app = _build_app(allow_superadmin=True)
|
||||
client = TestClient(app)
|
||||
resp = client.get("/api/system/skills/dependency-options")
|
||||
assert resp.status_code == 200, resp.text
|
||||
payload = resp.json()
|
||||
assert payload["success"] is True
|
||||
assert payload["data"]["tools"] == ["calculator"]
|
||||
|
||||
|
||||
def test_update_skill_dependencies_route(monkeypatch):
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
async def fake_update_skill_dependencies(
|
||||
_db,
|
||||
*,
|
||||
slug,
|
||||
tool_dependencies,
|
||||
mcp_dependencies,
|
||||
skill_dependencies,
|
||||
updated_by,
|
||||
):
|
||||
captured["slug"] = slug
|
||||
captured["tool_dependencies"] = tool_dependencies
|
||||
captured["mcp_dependencies"] = mcp_dependencies
|
||||
captured["skill_dependencies"] = skill_dependencies
|
||||
captured["updated_by"] = updated_by
|
||||
return Skill(
|
||||
slug=slug,
|
||||
name=slug,
|
||||
description="demo",
|
||||
dir_path=f"skills/{slug}",
|
||||
tool_dependencies=tool_dependencies,
|
||||
mcp_dependencies=mcp_dependencies,
|
||||
skill_dependencies=skill_dependencies,
|
||||
)
|
||||
|
||||
monkeypatch.setattr("server.routers.skill_router.update_skill_dependencies", fake_update_skill_dependencies)
|
||||
|
||||
app = _build_app(allow_superadmin=True)
|
||||
client = TestClient(app)
|
||||
resp = client.put(
|
||||
"/api/system/skills/demo/dependencies",
|
||||
json={
|
||||
"tool_dependencies": ["calculator"],
|
||||
"mcp_dependencies": ["mcp-a"],
|
||||
"skill_dependencies": ["other-skill"],
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert captured["slug"] == "demo"
|
||||
assert captured["tool_dependencies"] == ["calculator"]
|
||||
assert captured["mcp_dependencies"] == ["mcp-a"]
|
||||
assert captured["skill_dependencies"] == ["other-skill"]
|
||||
assert captured["updated_by"] == "root"
|
||||
283
test/test_skill_service.py
Normal file
283
test/test_skill_service.py
Normal file
@ -0,0 +1,283 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from src.services import skill_service as svc
|
||||
from src.storage.postgres.models_business import Skill
|
||||
|
||||
|
||||
def _build_zip(files: dict[str, str]) -> bytes:
|
||||
buf = io.BytesIO()
|
||||
with zipfile.ZipFile(buf, "w", compression=zipfile.ZIP_DEFLATED) as zf:
|
||||
for path, content in files.items():
|
||||
zf.writestr(path, content)
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def test_parse_skill_markdown_ok():
|
||||
content = (
|
||||
"---\n"
|
||||
"name: demo-skill\n"
|
||||
"description: demo description\n"
|
||||
"---\n"
|
||||
"# Demo\n"
|
||||
)
|
||||
name, desc, meta = svc._parse_skill_markdown(content)
|
||||
assert name == "demo-skill"
|
||||
assert desc == "demo description"
|
||||
assert meta["name"] == "demo-skill"
|
||||
|
||||
|
||||
def test_parse_skill_markdown_requires_frontmatter():
|
||||
with pytest.raises(ValueError, match="frontmatter"):
|
||||
svc._parse_skill_markdown("# missing")
|
||||
|
||||
|
||||
def test_validate_skill_slug():
|
||||
assert svc.validate_skill_slug("demo-skill") == "demo-skill"
|
||||
with pytest.raises(ValueError, match="无效 skill slug"):
|
||||
svc.validate_skill_slug("../bad")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_skill_dependency_options(monkeypatch: pytest.MonkeyPatch):
|
||||
monkeypatch.setattr(svc, "_get_buildin_tool_names", lambda: ["calculator", "search"])
|
||||
monkeypatch.setattr(svc, "get_mcp_server_names", lambda: ["mcp-a", "mcp-b"])
|
||||
|
||||
class FakeRepo:
|
||||
def __init__(self, _db):
|
||||
pass
|
||||
|
||||
async def list_all(self):
|
||||
return [
|
||||
Skill(slug="alpha", name="alpha", description="a", dir_path="skills/alpha"),
|
||||
Skill(slug="beta", name="beta", description="b", dir_path="skills/beta"),
|
||||
]
|
||||
|
||||
monkeypatch.setattr(svc, "SkillRepository", FakeRepo)
|
||||
|
||||
result = await svc.get_skill_dependency_options(None)
|
||||
assert result["tools"] == ["calculator", "search"]
|
||||
assert result["mcps"] == ["mcp-a", "mcp-b"]
|
||||
assert result["skills"] == ["alpha", "beta"]
|
||||
|
||||
|
||||
def test_resolve_relative_path_blocks_traversal(tmp_path: Path):
|
||||
skill_dir = tmp_path / "skill"
|
||||
skill_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
with pytest.raises(ValueError, match="上级路径"):
|
||||
svc._resolve_relative_path(skill_dir, "../outside.txt")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_import_skill_zip_conflict_rewrite_name(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
|
||||
monkeypatch.setattr(svc.sys_config, "save_dir", str(tmp_path))
|
||||
|
||||
class FakeRepo:
|
||||
existing_slugs = {"demo"}
|
||||
created_item: Skill | None = None
|
||||
|
||||
def __init__(self, _db):
|
||||
pass
|
||||
|
||||
async def exists_slug(self, slug: str) -> bool:
|
||||
return slug in self.__class__.existing_slugs
|
||||
|
||||
async def create(
|
||||
self,
|
||||
*,
|
||||
slug: str,
|
||||
name: str,
|
||||
description: str,
|
||||
tool_dependencies: list[str] | None,
|
||||
mcp_dependencies: list[str] | None,
|
||||
skill_dependencies: list[str] | None,
|
||||
dir_path: str,
|
||||
created_by: str | None,
|
||||
) -> Skill:
|
||||
item = Skill(
|
||||
slug=slug,
|
||||
name=name,
|
||||
description=description,
|
||||
tool_dependencies=tool_dependencies or [],
|
||||
mcp_dependencies=mcp_dependencies or [],
|
||||
skill_dependencies=skill_dependencies or [],
|
||||
dir_path=dir_path,
|
||||
created_by=created_by,
|
||||
updated_by=created_by,
|
||||
)
|
||||
self.__class__.existing_slugs.add(slug)
|
||||
self.__class__.created_item = item
|
||||
return item
|
||||
|
||||
monkeypatch.setattr(svc, "SkillRepository", FakeRepo)
|
||||
|
||||
zip_bytes = _build_zip(
|
||||
{
|
||||
"demo/SKILL.md": (
|
||||
"---\n"
|
||||
"name: demo\n"
|
||||
"description: this is demo\n"
|
||||
"---\n"
|
||||
"# Demo\n"
|
||||
),
|
||||
"demo/prompts/system.md": "You are demo skill",
|
||||
}
|
||||
)
|
||||
|
||||
item = await svc.import_skill_zip(
|
||||
None,
|
||||
filename="demo.zip",
|
||||
file_bytes=zip_bytes,
|
||||
created_by="root",
|
||||
)
|
||||
|
||||
assert item.slug == "demo-v2"
|
||||
assert item.name == "demo-v2"
|
||||
skill_md = (tmp_path / "skills" / "demo-v2" / "SKILL.md").read_text(encoding="utf-8")
|
||||
assert "name: demo-v2" in skill_md
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_skill_md_syncs_metadata(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
|
||||
monkeypatch.setattr(svc.sys_config, "save_dir", str(tmp_path))
|
||||
skill_dir = tmp_path / "skills" / "demo"
|
||||
skill_dir.mkdir(parents=True, exist_ok=True)
|
||||
(skill_dir / "SKILL.md").write_text(
|
||||
"---\nname: demo\ndescription: old\n---\n# old\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
item = Skill(
|
||||
slug="demo",
|
||||
name="demo",
|
||||
description="old",
|
||||
dir_path="skills/demo",
|
||||
created_by="root",
|
||||
updated_by="root",
|
||||
)
|
||||
|
||||
async def fake_get_skill_or_raise(_db, _slug: str):
|
||||
return item
|
||||
|
||||
updates: dict[str, str | None] = {}
|
||||
|
||||
class FakeRepo:
|
||||
def __init__(self, _db):
|
||||
pass
|
||||
|
||||
async def update_metadata(
|
||||
self,
|
||||
_item: Skill,
|
||||
*,
|
||||
name: str,
|
||||
description: str,
|
||||
updated_by: str | None,
|
||||
) -> Skill:
|
||||
updates["name"] = name
|
||||
updates["description"] = description
|
||||
updates["updated_by"] = updated_by
|
||||
return item
|
||||
|
||||
monkeypatch.setattr(svc, "get_skill_or_raise", fake_get_skill_or_raise)
|
||||
monkeypatch.setattr(svc, "SkillRepository", FakeRepo)
|
||||
|
||||
new_content = (
|
||||
"---\n"
|
||||
"name: demo\n"
|
||||
"description: updated desc\n"
|
||||
"---\n"
|
||||
"# updated\n"
|
||||
)
|
||||
await svc.update_skill_file(
|
||||
None,
|
||||
slug="demo",
|
||||
relative_path="SKILL.md",
|
||||
content=new_content,
|
||||
updated_by="admin",
|
||||
)
|
||||
|
||||
assert updates["name"] == "demo"
|
||||
assert updates["description"] == "updated desc"
|
||||
assert updates["updated_by"] == "admin"
|
||||
saved_content = (skill_dir / "SKILL.md").read_text(encoding="utf-8")
|
||||
assert "description: updated desc" in saved_content
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_skill_dependencies(monkeypatch: pytest.MonkeyPatch):
|
||||
item = Skill(
|
||||
slug="alpha",
|
||||
name="alpha",
|
||||
description="alpha",
|
||||
dir_path="skills/alpha",
|
||||
tool_dependencies=[],
|
||||
mcp_dependencies=[],
|
||||
skill_dependencies=[],
|
||||
)
|
||||
monkeypatch.setattr(svc, "_get_buildin_tool_names", lambda: ["calculator"])
|
||||
monkeypatch.setattr(svc, "get_mcp_server_names", lambda: ["mcp-a"])
|
||||
|
||||
async def fake_get_skill_or_raise(_db, slug: str):
|
||||
assert slug == "alpha"
|
||||
return item
|
||||
|
||||
captured: dict[str, list[str] | str | None] = {}
|
||||
|
||||
class FakeRepo:
|
||||
def __init__(self, _db):
|
||||
pass
|
||||
|
||||
async def list_all(self):
|
||||
return [
|
||||
item,
|
||||
Skill(
|
||||
slug="beta",
|
||||
name="beta",
|
||||
description="beta",
|
||||
dir_path="skills/beta",
|
||||
tool_dependencies=[],
|
||||
mcp_dependencies=[],
|
||||
skill_dependencies=[],
|
||||
),
|
||||
]
|
||||
|
||||
async def update_dependencies(
|
||||
self,
|
||||
_item: Skill,
|
||||
*,
|
||||
tool_dependencies: list[str],
|
||||
mcp_dependencies: list[str],
|
||||
skill_dependencies: list[str],
|
||||
updated_by: str | None,
|
||||
):
|
||||
captured["tool_dependencies"] = tool_dependencies
|
||||
captured["mcp_dependencies"] = mcp_dependencies
|
||||
captured["skill_dependencies"] = skill_dependencies
|
||||
captured["updated_by"] = updated_by
|
||||
_item.tool_dependencies = tool_dependencies
|
||||
_item.mcp_dependencies = mcp_dependencies
|
||||
_item.skill_dependencies = skill_dependencies
|
||||
return _item
|
||||
|
||||
monkeypatch.setattr(svc, "get_skill_or_raise", fake_get_skill_or_raise)
|
||||
monkeypatch.setattr(svc, "SkillRepository", FakeRepo)
|
||||
|
||||
updated = await svc.update_skill_dependencies(
|
||||
None,
|
||||
slug="alpha",
|
||||
tool_dependencies=["calculator", "calculator"],
|
||||
mcp_dependencies=["mcp-a", "mcp-a"],
|
||||
skill_dependencies=["beta", "beta"],
|
||||
updated_by="root",
|
||||
)
|
||||
assert captured["tool_dependencies"] == ["calculator"]
|
||||
assert captured["mcp_dependencies"] == ["mcp-a"]
|
||||
assert captured["skill_dependencies"] == ["beta"]
|
||||
assert captured["updated_by"] == "root"
|
||||
assert updated.skill_dependencies == ["beta"]
|
||||
89
test/test_skills_backend.py
Normal file
89
test/test_skills_backend.py
Normal file
@ -0,0 +1,89 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
from src.agents.common.backends import skills_backend
|
||||
|
||||
|
||||
def _prepare_skills_dir(root: Path) -> None:
|
||||
(root / "alpha").mkdir(parents=True, exist_ok=True)
|
||||
(root / "alpha" / "SKILL.md").write_text(
|
||||
"---\nname: alpha\ndescription: alpha\n---\n# alpha\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
(root / "beta").mkdir(parents=True, exist_ok=True)
|
||||
(root / "beta" / "SKILL.md").write_text(
|
||||
"---\nname: beta\ndescription: beta\n---\n# beta\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
def test_selected_skills_backend_readonly_and_visible_only_selected(tmp_path, monkeypatch):
|
||||
_prepare_skills_dir(tmp_path)
|
||||
monkeypatch.setattr(skills_backend, "get_skills_root_dir", lambda: tmp_path)
|
||||
|
||||
backend = skills_backend.SelectedSkillsReadonlyBackend(selected_slugs=["alpha"])
|
||||
|
||||
root_entries = backend.ls_info("/")
|
||||
paths = sorted(entry.get("path") for entry in root_entries)
|
||||
assert paths == ["/alpha/"]
|
||||
|
||||
ok_read = backend.read("/alpha/SKILL.md")
|
||||
assert "alpha" in ok_read
|
||||
|
||||
denied_read = backend.read("/beta/SKILL.md")
|
||||
assert "Access denied" in denied_read
|
||||
|
||||
write_result = backend.write("/alpha/new.md", "x")
|
||||
assert write_result.error and "read-only" in write_result.error
|
||||
|
||||
edit_result = backend.edit("/alpha/SKILL.md", "alpha", "changed")
|
||||
assert edit_result.error and "read-only" in edit_result.error
|
||||
|
||||
upload_result = backend.upload_files([("/alpha/a.txt", b"a")])
|
||||
assert len(upload_result) == 1
|
||||
assert upload_result[0].error == "permission_denied"
|
||||
|
||||
|
||||
def test_composite_backend_mounts_skills_under_prefix(tmp_path, monkeypatch):
|
||||
_prepare_skills_dir(tmp_path)
|
||||
monkeypatch.setattr(skills_backend, "get_skills_root_dir", lambda: tmp_path)
|
||||
|
||||
runtime = SimpleNamespace(
|
||||
context=SimpleNamespace(skills=["alpha"]),
|
||||
state={
|
||||
"skill_session_snapshot": {
|
||||
"selected_skills": ["alpha"],
|
||||
"visible_skills": ["alpha", "beta"],
|
||||
"prompt_metadata": {},
|
||||
"dependency_map": {},
|
||||
}
|
||||
},
|
||||
)
|
||||
composite = skills_backend.create_agent_composite_backend(runtime)
|
||||
|
||||
root = composite.ls_info("/")
|
||||
all_paths = [entry.get("path") for entry in root]
|
||||
assert "/skills/" in all_paths
|
||||
|
||||
skills_root = composite.ls_info("/skills/")
|
||||
skill_paths = sorted(entry.get("path") for entry in skills_root)
|
||||
assert skill_paths == ["/skills/alpha/", "/skills/beta/"]
|
||||
|
||||
denied = composite.write("/skills/alpha/new.md", "x")
|
||||
assert denied.error and "read-only" in denied.error
|
||||
|
||||
|
||||
def test_composite_backend_fallbacks_to_context_skills_when_snapshot_missing(tmp_path, monkeypatch):
|
||||
_prepare_skills_dir(tmp_path)
|
||||
monkeypatch.setattr(skills_backend, "get_skills_root_dir", lambda: tmp_path)
|
||||
|
||||
runtime = SimpleNamespace(
|
||||
context=SimpleNamespace(skills=["alpha"]),
|
||||
state={},
|
||||
)
|
||||
composite = skills_backend.create_agent_composite_backend(runtime)
|
||||
skills_root = composite.ls_info("/skills/")
|
||||
skill_paths = sorted(entry.get("path") for entry in skills_root)
|
||||
assert skill_paths == ["/skills/alpha/"]
|
||||
@ -12,6 +12,7 @@ export * from './tasker' // 任务管理API
|
||||
export * from './mindmap_api' // 思维导图API
|
||||
export * from './department_api' // 部门管理API
|
||||
export * from './mcp_api' // MCP API
|
||||
export * from './skill_api' // Skills API
|
||||
|
||||
// 导出基础工具函数
|
||||
export {
|
||||
|
||||
@ -453,6 +453,15 @@ export const evaluationApi = {
|
||||
return apiAdminDelete(`/api/evaluation/benchmarks/${benchmarkId}`)
|
||||
},
|
||||
|
||||
/**
|
||||
* 下载评估基准
|
||||
* @param {string} benchmarkId - 基准ID
|
||||
* @returns {Promise} - Response对象
|
||||
*/
|
||||
downloadBenchmark: async (benchmarkId) => {
|
||||
return apiAdminGet(`/api/evaluation/benchmarks/${benchmarkId}/download`, {}, 'blob')
|
||||
},
|
||||
|
||||
/**
|
||||
* 自动生成评估基准
|
||||
* @param {string} dbId - 知识库ID
|
||||
|
||||
71
web/src/apis/skill_api.js
Normal file
71
web/src/apis/skill_api.js
Normal file
@ -0,0 +1,71 @@
|
||||
import {
|
||||
apiAdminGet,
|
||||
apiSuperAdminDelete,
|
||||
apiSuperAdminGet,
|
||||
apiSuperAdminPost,
|
||||
apiSuperAdminPut
|
||||
} from './base'
|
||||
|
||||
const BASE_URL = '/api/system/skills'
|
||||
|
||||
export const listSkills = async () => {
|
||||
return apiAdminGet(BASE_URL)
|
||||
}
|
||||
|
||||
export const importSkillZip = async (file) => {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
return apiSuperAdminPost(`${BASE_URL}/import`, formData)
|
||||
}
|
||||
|
||||
export const getSkillDependencyOptions = async () => {
|
||||
return apiSuperAdminGet(`${BASE_URL}/dependency-options`)
|
||||
}
|
||||
|
||||
export const getSkillTree = async (slug) => {
|
||||
return apiSuperAdminGet(`${BASE_URL}/${encodeURIComponent(slug)}/tree`)
|
||||
}
|
||||
|
||||
export const getSkillFile = async (slug, path) => {
|
||||
return apiSuperAdminGet(`${BASE_URL}/${encodeURIComponent(slug)}/file?path=${encodeURIComponent(path)}`)
|
||||
}
|
||||
|
||||
export const createSkillFile = async (slug, payload) => {
|
||||
return apiSuperAdminPost(`${BASE_URL}/${encodeURIComponent(slug)}/file`, payload)
|
||||
}
|
||||
|
||||
export const updateSkillFile = async (slug, payload) => {
|
||||
return apiSuperAdminPut(`${BASE_URL}/${encodeURIComponent(slug)}/file`, payload)
|
||||
}
|
||||
|
||||
export const updateSkillDependencies = async (slug, payload) => {
|
||||
return apiSuperAdminPut(`${BASE_URL}/${encodeURIComponent(slug)}/dependencies`, payload)
|
||||
}
|
||||
|
||||
export const deleteSkillFile = async (slug, path) => {
|
||||
return apiSuperAdminDelete(`${BASE_URL}/${encodeURIComponent(slug)}/file?path=${encodeURIComponent(path)}`)
|
||||
}
|
||||
|
||||
export const exportSkill = async (slug) => {
|
||||
return apiSuperAdminGet(`${BASE_URL}/${encodeURIComponent(slug)}/export`, {}, 'blob')
|
||||
}
|
||||
|
||||
export const deleteSkill = async (slug) => {
|
||||
return apiSuperAdminDelete(`${BASE_URL}/${encodeURIComponent(slug)}`)
|
||||
}
|
||||
|
||||
export const skillApi = {
|
||||
listSkills,
|
||||
importSkillZip,
|
||||
getSkillDependencyOptions,
|
||||
getSkillTree,
|
||||
getSkillFile,
|
||||
createSkillFile,
|
||||
updateSkillFile,
|
||||
updateSkillDependencies,
|
||||
deleteSkillFile,
|
||||
exportSkill,
|
||||
deleteSkill
|
||||
}
|
||||
|
||||
export default skillApi
|
||||
@ -372,6 +372,7 @@ import ModelSelectorComponent from '@/components/ModelSelectorComponent.vue'
|
||||
import { useAgentStore } from '@/stores/agent'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import { useDatabaseStore } from '@/stores/database'
|
||||
import { skillApi } from '@/apis/skill_api'
|
||||
import { storeToRefs } from 'pinia'
|
||||
|
||||
// Props
|
||||
@ -396,9 +397,17 @@ const databaseStore = useDatabaseStore()
|
||||
|
||||
watch(
|
||||
() => props.isOpen,
|
||||
(val) => {
|
||||
async (val) => {
|
||||
if (val) {
|
||||
databaseStore.loadDatabases().catch(() => {})
|
||||
loadLiveSkillOptions().catch(() => {})
|
||||
if (selectedAgentId.value) {
|
||||
try {
|
||||
await agentStore.fetchAgentDetail(selectedAgentId.value, true)
|
||||
} catch (error) {
|
||||
console.error('刷新智能体配置项失败:', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
@ -423,6 +432,7 @@ const tempSelectedValues = ref([])
|
||||
const selectionSearchText = ref('')
|
||||
const systemPromptEditMode = ref(false)
|
||||
const activeTab = ref('basic')
|
||||
const liveSkillOptions = ref([])
|
||||
|
||||
const isEmptyConfig = computed(() => {
|
||||
return !selectedAgentId.value || Object.keys(configurableItems.value).length === 0
|
||||
@ -443,7 +453,8 @@ const hasOtherConfigs = computed(() => {
|
||||
const isTools =
|
||||
value.template_metadata?.kind === 'mcps' ||
|
||||
value.template_metadata?.kind === 'knowledges' ||
|
||||
value.template_metadata?.kind === 'tools'
|
||||
value.template_metadata?.kind === 'tools' ||
|
||||
value.template_metadata?.kind === 'skills'
|
||||
|
||||
return !isBasic && !isTools
|
||||
})
|
||||
@ -462,6 +473,24 @@ const segmentedOptions = computed(() => {
|
||||
return options
|
||||
})
|
||||
|
||||
const loadLiveSkillOptions = async () => {
|
||||
if (!userStore.isAdmin) {
|
||||
liveSkillOptions.value = []
|
||||
return
|
||||
}
|
||||
try {
|
||||
const result = await skillApi.listSkills()
|
||||
const rows = result?.data || []
|
||||
liveSkillOptions.value = rows.map((item) => ({
|
||||
id: item.slug,
|
||||
name: item.slug,
|
||||
description: item.description || ''
|
||||
}))
|
||||
} catch (error) {
|
||||
console.warn('加载实时 Skills 列表失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 通用选项获取与处理
|
||||
const getConfigOptions = (value) => {
|
||||
if (value?.template_metadata?.kind === 'tools') {
|
||||
@ -470,25 +499,28 @@ const getConfigOptions = (value) => {
|
||||
if (value?.template_metadata?.kind === 'knowledges') {
|
||||
return databaseStore.databases || []
|
||||
}
|
||||
if (value?.template_metadata?.kind === 'skills') {
|
||||
return liveSkillOptions.value.length > 0 ? liveSkillOptions.value : value?.options || []
|
||||
}
|
||||
return value?.options || []
|
||||
}
|
||||
|
||||
const isListConfig = (key, value) => {
|
||||
const isTools = value?.template_metadata?.kind === 'tools'
|
||||
const isList = value?.type === 'list'
|
||||
return isTools || isList
|
||||
return isTools || isList || key === 'skills'
|
||||
}
|
||||
|
||||
const getOptionValue = (option) => {
|
||||
if (typeof option === 'object' && option !== null) {
|
||||
return option.id || option.value || option.name
|
||||
return option.id || option.value || option.name || option.db_id || option.slug
|
||||
}
|
||||
return option
|
||||
}
|
||||
|
||||
const getOptionLabel = (option) => {
|
||||
if (typeof option === 'object' && option !== null) {
|
||||
return option.name || option.label || option.id
|
||||
return option.name || option.label || option.id || option.db_id || option.slug
|
||||
}
|
||||
return option
|
||||
}
|
||||
@ -523,7 +555,9 @@ const shouldShowConfig = (key, value) => {
|
||||
const isTools =
|
||||
value.template_metadata?.kind === 'mcps' ||
|
||||
value.template_metadata?.kind === 'knowledges' ||
|
||||
value.template_metadata?.kind === 'tools'
|
||||
value.template_metadata?.kind === 'tools' ||
|
||||
value.template_metadata?.kind === 'skills' ||
|
||||
key === 'skills'
|
||||
|
||||
if (activeTab.value === 'basic') {
|
||||
// 基础:System Prompt, LLM Model
|
||||
@ -626,6 +660,9 @@ const openSelectionModal = async (key) => {
|
||||
console.error('加载知识库列表失败:', error)
|
||||
}
|
||||
}
|
||||
if (configurableItems.value[key]?.template_metadata?.kind === 'skills') {
|
||||
await loadLiveSkillOptions()
|
||||
}
|
||||
const currentValues = agentConfig.value[key] || []
|
||||
tempSelectedValues.value = [...currentValues]
|
||||
selectionModalOpen.value = true
|
||||
@ -678,29 +715,14 @@ const validateAndFilterConfig = () => {
|
||||
const configItem = configItems[key]
|
||||
const currentValue = validatedConfig[key]
|
||||
|
||||
// 检查工具配置
|
||||
if (configItem.template_metadata?.kind === 'tools' && Array.isArray(currentValue)) {
|
||||
const availableToolIds = availableTools.value
|
||||
? Object.values(availableTools.value).map((tool) => tool.id)
|
||||
: []
|
||||
validatedConfig[key] = currentValue.filter((toolId) => availableToolIds.includes(toolId))
|
||||
if (Array.isArray(currentValue) && (configItem.template_metadata?.kind === 'tools' || configItem.type === 'list')) {
|
||||
const options = getConfigOptions(configItem)
|
||||
const validValues = new Set(options.map((opt) => String(getOptionValue(opt))))
|
||||
if (validValues.size === 0) return
|
||||
|
||||
validatedConfig[key] = currentValue.filter((value) => validValues.has(String(value)))
|
||||
if (validatedConfig[key].length !== currentValue.length) {
|
||||
console.warn(`工具配置 ${key} 中包含无效的工具ID,已自动过滤`)
|
||||
}
|
||||
}
|
||||
|
||||
// 检查多选配置项 (type === 'list' 且有 options)
|
||||
else if (
|
||||
configItem.type === 'list' &&
|
||||
configItem.options.length > 0 &&
|
||||
Array.isArray(currentValue)
|
||||
) {
|
||||
const validOptions = configItem.options
|
||||
validatedConfig[key] = currentValue.filter((value) => validOptions.includes(value))
|
||||
|
||||
if (validatedConfig[key].length !== currentValue.length) {
|
||||
console.warn(`配置项 ${key} 中包含无效的选项,已自动过滤`)
|
||||
console.warn(`配置项 ${key} 中包含无效选项,已自动过滤`)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@ -4,7 +4,27 @@
|
||||
<p>调整分块参数可以控制文本的切分方式,影响检索质量和文档加载效率。</p>
|
||||
</div>
|
||||
<a-form :model="tempChunkParams" name="chunkConfig" autocomplete="off" layout="vertical">
|
||||
<div class="chunk-row">
|
||||
<a-form-item v-if="showPreset" name="chunk_preset_id">
|
||||
<template #label>
|
||||
<span class="chunk-preset-label">
|
||||
分块策略
|
||||
<a-tooltip :title="presetDescription">
|
||||
<QuestionCircleOutlined class="chunk-preset-help-icon" />
|
||||
</a-tooltip>
|
||||
</span>
|
||||
</template>
|
||||
<a-select
|
||||
v-model:value="tempChunkParams.chunk_preset_id"
|
||||
:options="presetOptions"
|
||||
style="width: 100%"
|
||||
/>
|
||||
<p class="param-description">
|
||||
预设策略对齐 RAGFlow:General、QA、Book、Laws。
|
||||
<span v-if="allowPresetFollowDefault">留空时沿用知识库默认策略。</span>
|
||||
</p>
|
||||
</a-form-item>
|
||||
|
||||
<div class="chunk-row" v-if="showChunkSizeOverlap">
|
||||
<a-form-item label="Chunk Size" name="chunk_size">
|
||||
<a-input-number
|
||||
v-model:value="tempChunkParams.chunk_size"
|
||||
@ -42,7 +62,15 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
import { computed } from 'vue'
|
||||
import { QuestionCircleOutlined } from '@ant-design/icons-vue'
|
||||
import {
|
||||
CHUNK_PRESET_OPTIONS,
|
||||
CHUNK_PRESET_LABEL_MAP,
|
||||
getChunkPresetDescription
|
||||
} from '@/utils/chunk_presets'
|
||||
|
||||
const props = defineProps({
|
||||
tempChunkParams: {
|
||||
type: Object,
|
||||
required: true
|
||||
@ -50,8 +78,45 @@ defineProps({
|
||||
showQaSplit: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
showChunkSizeOverlap: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
showPreset: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
allowPresetFollowDefault: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
databasePresetId: {
|
||||
type: String,
|
||||
default: 'general'
|
||||
}
|
||||
})
|
||||
|
||||
const presetOptions = computed(() => {
|
||||
const options = []
|
||||
const defaultPresetLabel = CHUNK_PRESET_LABEL_MAP[props.databasePresetId] || 'General'
|
||||
|
||||
if (props.allowPresetFollowDefault) {
|
||||
options.push({
|
||||
value: '',
|
||||
label: `沿用知识库默认(${defaultPresetLabel})`
|
||||
})
|
||||
}
|
||||
|
||||
options.push(...CHUNK_PRESET_OPTIONS.map(({ value, label }) => ({ value, label })))
|
||||
|
||||
return options
|
||||
})
|
||||
|
||||
const effectivePresetId = computed(
|
||||
() => props.tempChunkParams.chunk_preset_id || props.databasePresetId || 'general'
|
||||
)
|
||||
const presetDescription = computed(() => getChunkPresetDescription(effectivePresetId.value))
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@ -92,4 +157,16 @@ defineProps({
|
||||
margin: 4px 0 0 0;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.chunk-preset-label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.chunk-preset-help-icon {
|
||||
color: var(--gray-500);
|
||||
cursor: help;
|
||||
font-size: 14px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -48,6 +48,14 @@
|
||||
<a-button type="text" size="small" @click.stop="previewBenchmark(benchmark)">
|
||||
<EyeOutlined />
|
||||
</a-button>
|
||||
<a-button
|
||||
type="text"
|
||||
size="small"
|
||||
:loading="!!downloadingBenchmarkMap[benchmark.benchmark_id]"
|
||||
@click.stop="downloadBenchmark(benchmark)"
|
||||
>
|
||||
<DownloadOutlined />
|
||||
</a-button>
|
||||
<a-button type="text" size="small" danger @click.stop="deleteBenchmark(benchmark)">
|
||||
<DeleteOutlined />
|
||||
</a-button>
|
||||
@ -196,6 +204,7 @@ import {
|
||||
UploadOutlined,
|
||||
RobotOutlined,
|
||||
EyeOutlined,
|
||||
DownloadOutlined,
|
||||
DeleteOutlined,
|
||||
CheckCircleOutlined,
|
||||
CloseCircleOutlined,
|
||||
@ -225,6 +234,7 @@ const generateModalVisible = ref(false)
|
||||
const previewModalVisible = ref(false)
|
||||
const previewData = ref(null)
|
||||
const previewQuestions = ref([])
|
||||
const downloadingBenchmarkMap = reactive({})
|
||||
const previewPagination = ref({
|
||||
current: 1,
|
||||
pageSize: 10,
|
||||
@ -407,6 +417,60 @@ const previewBenchmark = async (benchmark) => {
|
||||
}
|
||||
}
|
||||
|
||||
const parseDownloadFilename = (contentDisposition) => {
|
||||
if (!contentDisposition) return ''
|
||||
|
||||
const utf8Match = contentDisposition.match(/filename\*=UTF-8''([^;]+)/i)
|
||||
if (utf8Match && utf8Match[1]) {
|
||||
try {
|
||||
return decodeURIComponent(utf8Match[1])
|
||||
} catch (error) {
|
||||
console.warn('解析 UTF-8 文件名失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const asciiMatch = contentDisposition.match(/filename=\"?([^\";]+)\"?/i)
|
||||
if (asciiMatch && asciiMatch[1]) {
|
||||
return asciiMatch[1]
|
||||
}
|
||||
|
||||
return ''
|
||||
}
|
||||
|
||||
// 下载基准
|
||||
const downloadBenchmark = async (benchmark) => {
|
||||
const benchmarkId = benchmark?.benchmark_id
|
||||
if (!benchmarkId) return
|
||||
if (downloadingBenchmarkMap[benchmarkId]) return
|
||||
|
||||
downloadingBenchmarkMap[benchmarkId] = true
|
||||
try {
|
||||
const response = await evaluationApi.downloadBenchmark(benchmarkId)
|
||||
const blob = await response.blob()
|
||||
const contentDisposition =
|
||||
response.headers.get('Content-Disposition') || response.headers.get('content-disposition')
|
||||
const headerFilename = parseDownloadFilename(contentDisposition)
|
||||
const fallbackFilename = `${benchmark.name || benchmarkId}.jsonl`
|
||||
const filename = headerFilename || fallbackFilename
|
||||
|
||||
const url = window.URL.createObjectURL(blob)
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = filename
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
window.URL.revokeObjectURL(url)
|
||||
|
||||
message.success('下载成功')
|
||||
} catch (error) {
|
||||
console.error('下载基准失败:', error)
|
||||
message.error(`下载失败: ${error.message || '未知错误'}`)
|
||||
} finally {
|
||||
delete downloadingBenchmarkMap[benchmarkId]
|
||||
}
|
||||
}
|
||||
|
||||
// 删除基准
|
||||
const deleteBenchmark = (benchmark) => {
|
||||
Modal.confirm({
|
||||
|
||||
@ -189,7 +189,14 @@
|
||||
<a-button key="submit" type="primary" @click="handleIndexConfigConfirm">确定</a-button>
|
||||
</template>
|
||||
<div class="index-params">
|
||||
<ChunkParamsConfig :temp-chunk-params="indexParams" :show-qa-split="true" />
|
||||
<ChunkParamsConfig
|
||||
:temp-chunk-params="indexParams"
|
||||
:show-qa-split="true"
|
||||
:show-chunk-size-overlap="!isLightRAG"
|
||||
:show-preset="true"
|
||||
:allow-preset-follow-default="true"
|
||||
:database-preset-id="store.database?.additional_params?.chunk_preset_id || 'general'"
|
||||
/>
|
||||
</div>
|
||||
</a-modal>
|
||||
|
||||
@ -704,8 +711,25 @@ const indexConfigModalTitle = ref('入库参数配置')
|
||||
const indexParams = ref({
|
||||
chunk_size: 1000,
|
||||
chunk_overlap: 200,
|
||||
qa_separator: ''
|
||||
qa_separator: '',
|
||||
chunk_preset_id: ''
|
||||
})
|
||||
const buildIndexParamsPayload = () => {
|
||||
const payload = {}
|
||||
if (indexParams.value.chunk_preset_id) {
|
||||
payload.chunk_preset_id = indexParams.value.chunk_preset_id
|
||||
}
|
||||
|
||||
if (isLightRAG.value) {
|
||||
payload.qa_separator = indexParams.value.qa_separator || ''
|
||||
return payload
|
||||
}
|
||||
|
||||
return {
|
||||
...indexParams.value,
|
||||
...payload
|
||||
}
|
||||
}
|
||||
const currentIndexFileIds = ref([])
|
||||
const isBatchIndexOperation = ref(false)
|
||||
|
||||
@ -1088,12 +1112,6 @@ const handleBatchIndex = async () => {
|
||||
return
|
||||
}
|
||||
|
||||
if (isLightRAG.value) {
|
||||
await store.indexFiles(validKeys)
|
||||
selectedRowKeys.value = []
|
||||
return
|
||||
}
|
||||
|
||||
currentIndexFileIds.value = [...validKeys]
|
||||
isBatchIndexOperation.value = true
|
||||
indexConfigModalTitle.value = '批量入库参数配置'
|
||||
@ -1172,11 +1190,6 @@ const handleParseFile = async (record) => {
|
||||
|
||||
const handleIndexFile = async (record) => {
|
||||
closePopover(record.file_id)
|
||||
if (isLightRAG.value) {
|
||||
await store.indexFiles([record.file_id])
|
||||
return
|
||||
}
|
||||
|
||||
// 打开参数配置弹窗
|
||||
currentIndexFileIds.value = [record.file_id]
|
||||
isBatchIndexOperation.value = false
|
||||
@ -1189,7 +1202,8 @@ const handleIndexFile = async (record) => {
|
||||
Object.assign(indexParams.value, {
|
||||
chunk_size: 1000,
|
||||
chunk_overlap: 200,
|
||||
qa_separator: ''
|
||||
qa_separator: '',
|
||||
chunk_preset_id: ''
|
||||
})
|
||||
}
|
||||
|
||||
@ -1214,7 +1228,7 @@ const handleReindexFile = async (record) => {
|
||||
const handleIndexConfigConfirm = async () => {
|
||||
try {
|
||||
// 调用 indexFiles 接口 (支持 params)
|
||||
const result = await store.indexFiles(currentIndexFileIds.value, indexParams.value)
|
||||
const result = await store.indexFiles(currentIndexFileIds.value, buildIndexParamsPayload())
|
||||
if (result) {
|
||||
currentIndexFileIds.value = []
|
||||
// 清空选择
|
||||
@ -1228,7 +1242,8 @@ const handleIndexConfigConfirm = async () => {
|
||||
Object.assign(indexParams.value, {
|
||||
chunk_size: 1000,
|
||||
chunk_overlap: 200,
|
||||
qa_separator: ''
|
||||
qa_separator: '',
|
||||
chunk_preset_id: ''
|
||||
})
|
||||
} else {
|
||||
// message.error(`入库失败: ${result.message}`); // store already shows message
|
||||
@ -1249,7 +1264,8 @@ const handleIndexConfigCancel = () => {
|
||||
Object.assign(indexParams.value, {
|
||||
chunk_size: 1000,
|
||||
chunk_overlap: 200,
|
||||
qa_separator: ''
|
||||
qa_separator: '',
|
||||
chunk_preset_id: ''
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@ -108,15 +108,17 @@
|
||||
<div class="col-item">
|
||||
<div class="setting-label">入库参数配置</div>
|
||||
<div class="setting-content">
|
||||
<template v-if="!isGraphBased">
|
||||
<ChunkParamsConfig :temp-chunk-params="indexParams" :show-qa-split="true" />
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="lightrag-tip">
|
||||
<Info :size="14" style="margin-right: 6px" />
|
||||
<span>LightRAG 将使用默认参数自动入库</span>
|
||||
</div>
|
||||
</template>
|
||||
<ChunkParamsConfig
|
||||
:temp-chunk-params="indexParams"
|
||||
:show-qa-split="true"
|
||||
:show-chunk-size-overlap="!isGraphBased"
|
||||
:show-preset="true"
|
||||
:allow-preset-follow-default="true"
|
||||
:database-preset-id="store.database?.additional_params?.chunk_preset_id || 'general'"
|
||||
/>
|
||||
<p v-if="isGraphBased" class="param-description">
|
||||
LightRAG 按分隔符预切分,超长片段仍会按 token 大小继续切分。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -564,9 +566,26 @@ const autoIndex = ref(false)
|
||||
const indexParams = ref({
|
||||
chunk_size: 1000,
|
||||
chunk_overlap: 200,
|
||||
qa_separator: ''
|
||||
qa_separator: '',
|
||||
chunk_preset_id: ''
|
||||
})
|
||||
|
||||
const buildAutoIndexParams = () => {
|
||||
const payload = {}
|
||||
if (indexParams.value.chunk_preset_id) {
|
||||
payload.chunk_preset_id = indexParams.value.chunk_preset_id
|
||||
}
|
||||
|
||||
if (isGraphBased.value) {
|
||||
payload.qa_separator = indexParams.value.qa_separator || ''
|
||||
return payload
|
||||
}
|
||||
return {
|
||||
...indexParams.value,
|
||||
...payload
|
||||
}
|
||||
}
|
||||
|
||||
// 计算属性:是否支持QA分割
|
||||
const isQaSplitSupported = computed(() => {
|
||||
const type = kbType.value?.toLowerCase()
|
||||
@ -990,7 +1009,7 @@ const chunkData = async () => {
|
||||
const params = { ...chunkParams.value }
|
||||
if (autoIndex.value) {
|
||||
params.auto_index = true
|
||||
Object.assign(params, indexParams.value)
|
||||
Object.assign(params, buildAutoIndexParams())
|
||||
}
|
||||
|
||||
// 构造 _preprocessed_map 和 items (minio urls)
|
||||
@ -1068,7 +1087,7 @@ const chunkData = async () => {
|
||||
const params = { ...chunkParams.value, content_hashes }
|
||||
if (autoIndex.value) {
|
||||
params.auto_index = true
|
||||
Object.assign(params, indexParams.value)
|
||||
Object.assign(params, buildAutoIndexParams())
|
||||
}
|
||||
|
||||
await store.addFiles({
|
||||
@ -1604,17 +1623,6 @@ const chunkData = async () => {
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.lightrag-tip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-top: 8px;
|
||||
padding: 8px 12px;
|
||||
background: var(--main-50);
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
color: var(--gray-600);
|
||||
}
|
||||
|
||||
.setting-label .ant-checkbox {
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
@ -40,6 +40,9 @@
|
||||
{{ getKbTypeLabel(database.kb_type || 'lightrag') }}
|
||||
</a-tag>
|
||||
<a-tag color="blue" size="small">{{ database.embed_info?.name || 'N/A' }}</a-tag>
|
||||
<a-tag color="cyan" size="small">{{
|
||||
chunkPresetLabelMap[database.additional_params?.chunk_preset_id || 'general'] || 'General'
|
||||
}}</a-tag>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -81,6 +84,18 @@
|
||||
>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item name="chunk_preset_id">
|
||||
<template #label>
|
||||
<span class="chunk-preset-label">
|
||||
分块策略
|
||||
<a-tooltip :title="editPresetDescription">
|
||||
<QuestionCircleOutlined class="chunk-preset-help-icon" />
|
||||
</a-tooltip>
|
||||
</span>
|
||||
</template>
|
||||
<a-select v-model:value="editForm.chunk_preset_id" :options="chunkPresetOptions" />
|
||||
</a-form-item>
|
||||
|
||||
<!-- 仅对 LightRAG 类型显示 LLM 配置 -->
|
||||
<a-form-item v-if="database.kb_type === 'lightrag'" label="语言模型 (LLM)" name="llm_info">
|
||||
<ModelSelectorComponent
|
||||
@ -122,8 +137,13 @@ import { useRouter } from 'vue-router'
|
||||
import { useDatabaseStore } from '@/stores/database'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import { getKbTypeLabel, getKbTypeColor } from '@/utils/kb_utils'
|
||||
import {
|
||||
CHUNK_PRESET_OPTIONS,
|
||||
CHUNK_PRESET_LABEL_MAP,
|
||||
getChunkPresetDescription
|
||||
} from '@/utils/chunk_presets'
|
||||
import { message } from 'ant-design-vue'
|
||||
import { LeftOutlined } from '@ant-design/icons-vue'
|
||||
import { LeftOutlined, QuestionCircleOutlined } from '@ant-design/icons-vue'
|
||||
import { Pencil, Trash2, Copy } from 'lucide-vue-next'
|
||||
import { departmentApi } from '@/apis/department_api'
|
||||
import ModelSelectorComponent from '@/components/ModelSelectorComponent.vue'
|
||||
@ -220,12 +240,17 @@ const editForm = reactive({
|
||||
name: '',
|
||||
description: '',
|
||||
auto_generate_questions: false,
|
||||
chunk_preset_id: 'general',
|
||||
llm_info: {
|
||||
provider: '',
|
||||
model_name: ''
|
||||
}
|
||||
})
|
||||
|
||||
const chunkPresetOptions = CHUNK_PRESET_OPTIONS.map(({ label, value }) => ({ label, value }))
|
||||
const chunkPresetLabelMap = CHUNK_PRESET_LABEL_MAP
|
||||
const editPresetDescription = computed(() => getChunkPresetDescription(editForm.chunk_preset_id))
|
||||
|
||||
const rules = {
|
||||
name: [{ required: true, message: '请输入知识库名称' }]
|
||||
}
|
||||
@ -238,6 +263,7 @@ const showEditModal = () => {
|
||||
editForm.description = database.value.description || ''
|
||||
editForm.auto_generate_questions =
|
||||
database.value.additional_params?.auto_generate_questions || false
|
||||
editForm.chunk_preset_id = database.value.additional_params?.chunk_preset_id || 'general'
|
||||
|
||||
// 如果是 LightRAG 类型,加载当前的 LLM 配置
|
||||
if (database.value.kb_type === 'lightrag') {
|
||||
@ -283,7 +309,8 @@ const handleEditSubmit = () => {
|
||||
name: editForm.name,
|
||||
description: editForm.description,
|
||||
additional_params: {
|
||||
auto_generate_questions: editForm.auto_generate_questions
|
||||
auto_generate_questions: editForm.auto_generate_questions,
|
||||
chunk_preset_id: editForm.chunk_preset_id || 'general'
|
||||
},
|
||||
share_config: {
|
||||
is_shared: finalIsShared,
|
||||
@ -428,4 +455,16 @@ const deleteDatabase = () => {
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.chunk-preset-label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.chunk-preset-help-icon {
|
||||
color: var(--gray-500);
|
||||
cursor: help;
|
||||
font-size: 14px;
|
||||
}
|
||||
</style>
|
||||
|
||||
145
web/src/components/McpEnvEditor.vue
Normal file
145
web/src/components/McpEnvEditor.vue
Normal file
@ -0,0 +1,145 @@
|
||||
<template>
|
||||
<div class="env-editor">
|
||||
<div v-for="(row, index) in rows" :key="index" class="env-row">
|
||||
<a-input v-model:value="row.key" placeholder="Key" class="env-key-input" />
|
||||
<a-input v-model:value="row.value" placeholder="Value" class="env-value-input" />
|
||||
<a-button
|
||||
size="small"
|
||||
type="text"
|
||||
danger
|
||||
@click="removeRow(index)"
|
||||
:disabled="rows.length === 1"
|
||||
>
|
||||
删除
|
||||
</a-button>
|
||||
</div>
|
||||
<a-button size="small" @click="addRow">
|
||||
<template #icon><PlusOutlined /></template>
|
||||
添加变量
|
||||
</a-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue'
|
||||
import { PlusOutlined } from '@ant-design/icons-vue'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: Object,
|
||||
default: null
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
|
||||
const rows = ref([{ key: '', value: '' }])
|
||||
const syncingFromObject = ref(false)
|
||||
|
||||
const objectToRows = (envObj) => {
|
||||
if (!envObj || typeof envObj !== 'object') {
|
||||
return [{ key: '', value: '' }]
|
||||
}
|
||||
const entries = Object.entries(envObj)
|
||||
if (entries.length === 0) {
|
||||
return [{ key: '', value: '' }]
|
||||
}
|
||||
return entries.map(([key, value]) => ({
|
||||
key,
|
||||
value: value == null ? '' : String(value)
|
||||
}))
|
||||
}
|
||||
|
||||
const normalizeEnvObject = (value) => {
|
||||
if (value == null) {
|
||||
return null
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
try {
|
||||
const parsed = JSON.parse(value)
|
||||
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
||||
return parsed
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
return null
|
||||
}
|
||||
if (typeof value === 'object' && !Array.isArray(value)) {
|
||||
return value
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const rowsToObject = (rowsValue) => {
|
||||
const entries = rowsValue
|
||||
.map((row) => ({
|
||||
key: row.key.trim(),
|
||||
value: row.value
|
||||
}))
|
||||
.filter((row) => row.key)
|
||||
if (entries.length === 0) {
|
||||
return null
|
||||
}
|
||||
return Object.fromEntries(entries.map((row) => [row.key, row.value]))
|
||||
}
|
||||
|
||||
const addRow = () => {
|
||||
rows.value.push({ key: '', value: '' })
|
||||
}
|
||||
|
||||
const removeRow = (index) => {
|
||||
if (rows.value.length === 1) {
|
||||
rows.value[0].key = ''
|
||||
rows.value[0].value = ''
|
||||
return
|
||||
}
|
||||
rows.value.splice(index, 1)
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(value) => {
|
||||
syncingFromObject.value = true
|
||||
const normalized = normalizeEnvObject(value)
|
||||
if (!normalized) {
|
||||
rows.value = [{ key: '', value: '' }]
|
||||
} else {
|
||||
rows.value = objectToRows(normalized)
|
||||
}
|
||||
syncingFromObject.value = false
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
watch(
|
||||
rows,
|
||||
(value) => {
|
||||
if (syncingFromObject.value) {
|
||||
return
|
||||
}
|
||||
const obj = rowsToObject(value)
|
||||
emit('update:modelValue', obj)
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.env-editor {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
|
||||
.env-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
|
||||
.env-key-input,
|
||||
.env-value-input {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@ -78,6 +78,10 @@
|
||||
</a-tag>
|
||||
</span>
|
||||
</div>
|
||||
<div class="info-item" v-if="server.env && Object.keys(server.env).length > 0">
|
||||
<label>环境变量</label>
|
||||
<pre class="headers-pre">{{ JSON.stringify(server.env, null, 2) }}</pre>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="info-item" v-if="server.description">
|
||||
|
||||
@ -214,7 +214,7 @@
|
||||
</template>
|
||||
|
||||
<!-- StdIO 类型 -->
|
||||
<template v-if="form.transport === 'stdio'">
|
||||
<template v-if="isStdioTransport">
|
||||
<a-form-item label="命令" required class="form-item">
|
||||
<a-input v-model:value="form.command" placeholder="例如:npx 或 /path/to/server" />
|
||||
</a-form-item>
|
||||
@ -227,6 +227,10 @@
|
||||
style="width: 100%"
|
||||
/>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="环境变量" class="form-item">
|
||||
<McpEnvEditor v-model="form.env" />
|
||||
</a-form-item>
|
||||
</template>
|
||||
|
||||
<a-form-item label="标签" class="form-item">
|
||||
@ -283,6 +287,7 @@ import {
|
||||
} from '@ant-design/icons-vue'
|
||||
import { mcpApi } from '@/apis/mcp_api'
|
||||
import McpServerDetailModal from './McpServerDetailModal.vue'
|
||||
import McpEnvEditor from './McpEnvEditor.vue'
|
||||
|
||||
// 状态
|
||||
const loading = ref(false)
|
||||
@ -304,6 +309,7 @@ const form = reactive({
|
||||
url: '',
|
||||
command: '',
|
||||
args: [],
|
||||
env: null,
|
||||
headersText: '',
|
||||
timeout: null,
|
||||
sse_read_timeout: null,
|
||||
@ -321,6 +327,10 @@ const httpCount = computed(
|
||||
)
|
||||
const sseCount = computed(() => servers.value.filter((s) => s.transport === 'sse').length)
|
||||
const stdioCount = computed(() => servers.value.filter((s) => s.transport === 'stdio').length)
|
||||
const envEditorKey = computed(() => `${form.name}-${form.transport}`)
|
||||
const isStdioTransport = computed(
|
||||
() => String(form.transport || '').trim().toLowerCase() === 'stdio'
|
||||
)
|
||||
|
||||
// 获取服务器列表
|
||||
const fetchServers = async () => {
|
||||
@ -352,6 +362,7 @@ const showAddModal = () => {
|
||||
url: '',
|
||||
command: '',
|
||||
args: [],
|
||||
env: null,
|
||||
headersText: '',
|
||||
timeout: null,
|
||||
sse_read_timeout: null,
|
||||
@ -362,8 +373,7 @@ const showAddModal = () => {
|
||||
formModalVisible.value = true
|
||||
}
|
||||
|
||||
// 显示编辑模态框
|
||||
const showEditModal = (server) => {
|
||||
const applyServerToForm = (server) => {
|
||||
editMode.value = true
|
||||
formMode.value = 'form'
|
||||
Object.assign(form, {
|
||||
@ -373,6 +383,7 @@ const showEditModal = (server) => {
|
||||
url: server.url || '',
|
||||
command: server.command || '',
|
||||
args: server.args || [],
|
||||
env: server.env || null,
|
||||
headersText: server.headers ? JSON.stringify(server.headers, null, 2) : '',
|
||||
timeout: server.timeout,
|
||||
sse_read_timeout: server.sse_read_timeout,
|
||||
@ -382,6 +393,20 @@ const showEditModal = (server) => {
|
||||
formModalVisible.value = true
|
||||
}
|
||||
|
||||
// 显示编辑模态框
|
||||
const showEditModal = async (server) => {
|
||||
try {
|
||||
const result = await mcpApi.getMcpServer(server.name)
|
||||
if (result.success && result.data) {
|
||||
applyServerToForm(result.data)
|
||||
return
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('获取服务器详情失败,回退使用列表数据:', err)
|
||||
}
|
||||
applyServerToForm(server)
|
||||
}
|
||||
|
||||
// 显示详情模态框
|
||||
const showDetailModal = (server) => {
|
||||
selectedServer.value = server
|
||||
@ -420,6 +445,7 @@ const handleFormSubmit = async () => {
|
||||
url: form.url || null,
|
||||
command: form.command || null,
|
||||
args: form.args.length > 0 ? form.args : null,
|
||||
env: form.env,
|
||||
headers,
|
||||
timeout: form.timeout || null,
|
||||
sse_read_timeout: form.sse_read_timeout || null,
|
||||
@ -577,6 +603,7 @@ const parseJsonToForm = () => {
|
||||
url: obj.url || '',
|
||||
command: obj.command || '',
|
||||
args: obj.args || [],
|
||||
env: obj.env || null,
|
||||
headersText: obj.headers ? JSON.stringify(obj.headers, null, 2) : '',
|
||||
timeout: obj.timeout || null,
|
||||
sse_read_timeout: obj.sse_read_timeout || null,
|
||||
|
||||
@ -58,6 +58,15 @@
|
||||
<ApiOutlined class="icon" />
|
||||
<span>MCP 管理</span>
|
||||
</div>
|
||||
<div
|
||||
class="sider-item"
|
||||
:class="{ activesec: activeTab === 'skills' }"
|
||||
@click="activeTab = 'skills'"
|
||||
v-if="userStore.isSuperAdmin"
|
||||
>
|
||||
<FolderOutlined class="icon" />
|
||||
<span>Skills 管理</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 顶部导航 (Mobile) -->
|
||||
@ -94,6 +103,14 @@
|
||||
>
|
||||
MCP 管理
|
||||
</div>
|
||||
<div
|
||||
class="nav-item"
|
||||
:class="{ active: activeTab === 'skills' }"
|
||||
@click="activeTab = 'skills'"
|
||||
v-if="userStore.isSuperAdmin"
|
||||
>
|
||||
Skills 管理
|
||||
</div>
|
||||
<div
|
||||
class="nav-item"
|
||||
:class="{ active: activeTab === 'department' }"
|
||||
@ -123,6 +140,10 @@
|
||||
<McpServersComponent />
|
||||
</div>
|
||||
|
||||
<div v-show="activeTab === 'skills'" v-if="userStore.isSuperAdmin">
|
||||
<SkillsManagerComponent />
|
||||
</div>
|
||||
|
||||
<div v-show="activeTab === 'department'" v-if="userStore.isSuperAdmin">
|
||||
<DepartmentManagementComponent />
|
||||
</div>
|
||||
@ -140,13 +161,15 @@ import {
|
||||
CodeOutlined,
|
||||
UserOutlined,
|
||||
ApiOutlined,
|
||||
TeamOutlined
|
||||
TeamOutlined,
|
||||
FolderOutlined
|
||||
} from '@ant-design/icons-vue'
|
||||
import BasicSettingsSection from '@/components/BasicSettingsSection.vue'
|
||||
import ModelProvidersComponent from '@/components/ModelProvidersComponent.vue'
|
||||
import UserManagementComponent from '@/components/UserManagementComponent.vue'
|
||||
import McpServersComponent from '@/components/McpServersComponent.vue'
|
||||
import DepartmentManagementComponent from '@/components/DepartmentManagementComponent.vue'
|
||||
import SkillsManagerComponent from '@/components/SkillsManagerComponent.vue'
|
||||
|
||||
const props = defineProps({
|
||||
visible: {
|
||||
|
||||
685
web/src/components/SkillsManagerComponent.vue
Normal file
685
web/src/components/SkillsManagerComponent.vue
Normal file
@ -0,0 +1,685 @@
|
||||
<template>
|
||||
<div class="skills-manager">
|
||||
<div class="header-section">
|
||||
<div class="header-content">
|
||||
<h3 class="title">Skills 管理</h3>
|
||||
<p class="description">导入、编辑、导出和删除技能包。技能内容保存在文件系统,元数据保存在数据库。</p>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<a-upload
|
||||
accept=".zip"
|
||||
:show-upload-list="false"
|
||||
:custom-request="handleImportUpload"
|
||||
:disabled="loading || importing"
|
||||
>
|
||||
<a-button type="primary" :loading="importing">导入 ZIP</a-button>
|
||||
</a-upload>
|
||||
<a-button @click="fetchSkills" :disabled="loading">刷新</a-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a-spin :spinning="loading">
|
||||
<div class="content-layout">
|
||||
<div class="skills-list-panel">
|
||||
<a-table
|
||||
size="small"
|
||||
:columns="columns"
|
||||
:data-source="skills"
|
||||
:pagination="false"
|
||||
row-key="slug"
|
||||
:custom-row="bindSkillRow"
|
||||
:row-class-name="rowClassName"
|
||||
:scroll="{ y: 360 }"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="skill-detail-panel">
|
||||
<a-empty v-if="!currentSkill" description="请选择一个 Skill" />
|
||||
|
||||
<template v-else>
|
||||
<div class="detail-header">
|
||||
<div class="detail-title">
|
||||
<strong>{{ currentSkill.name }}</strong>
|
||||
<span class="slug">({{ currentSkill.slug }})</span>
|
||||
<span class="dependency-summary">
|
||||
工具 {{ (currentSkill.tool_dependencies || []).length }} · MCP
|
||||
{{ (currentSkill.mcp_dependencies || []).length }} · Skills
|
||||
{{ (currentSkill.skill_dependencies || []).length }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="detail-actions">
|
||||
<a-button size="small" @click="reloadTree">刷新目录</a-button>
|
||||
<a-button size="small" @click="openCreateModal(false)">新建文件</a-button>
|
||||
<a-button size="small" @click="openCreateModal(true)">新建目录</a-button>
|
||||
<a-button size="small" @click="handleExport">导出 ZIP</a-button>
|
||||
<a-button danger size="small" @click="confirmDeleteSkill">删除 Skill</a-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="dependency-panel">
|
||||
<div class="dependency-header">
|
||||
<span class="dependency-title">依赖管理</span>
|
||||
<a-button
|
||||
type="primary"
|
||||
size="small"
|
||||
:loading="savingDependencies"
|
||||
:disabled="loading || savingDependencies"
|
||||
@click="saveDependencies"
|
||||
>
|
||||
保存依赖
|
||||
</a-button>
|
||||
</div>
|
||||
<a-form layout="vertical" class="dependency-form">
|
||||
<a-form-item label="工具依赖">
|
||||
<a-select
|
||||
v-model:value="dependencyForm.tool_dependencies"
|
||||
mode="multiple"
|
||||
:options="toolDependencyOptions"
|
||||
placeholder="选择工具依赖"
|
||||
:disabled="loading || savingDependencies"
|
||||
allow-clear
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item label="MCP 依赖">
|
||||
<a-select
|
||||
v-model:value="dependencyForm.mcp_dependencies"
|
||||
mode="multiple"
|
||||
:options="mcpDependencyOptions"
|
||||
placeholder="选择 MCP 服务依赖"
|
||||
:disabled="loading || savingDependencies"
|
||||
allow-clear
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item label="Skill 依赖">
|
||||
<a-select
|
||||
v-model:value="dependencyForm.skill_dependencies"
|
||||
mode="multiple"
|
||||
:options="skillDependencyOptions"
|
||||
placeholder="选择 Skill 依赖"
|
||||
:disabled="loading || savingDependencies"
|
||||
allow-clear
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</div>
|
||||
|
||||
<div class="detail-body">
|
||||
<div class="tree-panel">
|
||||
<div class="tree-toolbar">
|
||||
<span class="tree-title">目录树</span>
|
||||
<a-button
|
||||
size="small"
|
||||
danger
|
||||
@click="confirmDeleteNode"
|
||||
:disabled="!selectedPath || selectedPath === 'SKILL.md'"
|
||||
>
|
||||
删除节点
|
||||
</a-button>
|
||||
</div>
|
||||
<a-tree
|
||||
:tree-data="treeData"
|
||||
:selected-keys="selectedTreeKeys"
|
||||
:default-expand-all="true"
|
||||
@select="handleTreeSelect"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="editor-panel">
|
||||
<div class="editor-toolbar">
|
||||
<span class="editor-path">{{ selectedPath || '请选择文本文件' }}</span>
|
||||
<a-button
|
||||
type="primary"
|
||||
size="small"
|
||||
@click="saveCurrentFile"
|
||||
:disabled="!canSave"
|
||||
:loading="savingFile"
|
||||
>
|
||||
保存
|
||||
</a-button>
|
||||
</div>
|
||||
|
||||
<a-empty v-if="!selectedPath || selectedIsDir" description="请选择文本文件后编辑" />
|
||||
<a-textarea
|
||||
v-else
|
||||
v-model:value="fileContent"
|
||||
:rows="22"
|
||||
class="file-editor"
|
||||
placeholder="文件内容"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</a-spin>
|
||||
|
||||
<a-modal
|
||||
v-model:open="createModalVisible"
|
||||
:title="createForm.isDir ? '新建目录' : '新建文件'"
|
||||
@ok="handleCreateNode"
|
||||
:confirm-loading="creatingNode"
|
||||
:maskClosable="false"
|
||||
>
|
||||
<a-form layout="vertical">
|
||||
<a-form-item label="路径(相对 skill 根目录)" required>
|
||||
<a-input v-model:value="createForm.path" placeholder="例如 prompts/guide.md" />
|
||||
</a-form-item>
|
||||
<a-form-item v-if="!createForm.isDir" label="文件内容">
|
||||
<a-textarea v-model:value="createForm.content" :rows="8" />
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { message, Modal } from 'ant-design-vue'
|
||||
import { skillApi } from '@/apis/skill_api'
|
||||
|
||||
const loading = ref(false)
|
||||
const importing = ref(false)
|
||||
const savingFile = ref(false)
|
||||
const creatingNode = ref(false)
|
||||
const savingDependencies = ref(false)
|
||||
|
||||
const skills = ref([])
|
||||
const currentSkill = ref(null)
|
||||
const treeData = ref([])
|
||||
const selectedTreeKeys = ref([])
|
||||
const selectedPath = ref('')
|
||||
const selectedIsDir = ref(false)
|
||||
const fileContent = ref('')
|
||||
const originalFileContent = ref('')
|
||||
|
||||
const createModalVisible = ref(false)
|
||||
const createForm = reactive({
|
||||
path: '',
|
||||
isDir: false,
|
||||
content: ''
|
||||
})
|
||||
const dependencyOptions = reactive({
|
||||
tools: [],
|
||||
mcps: [],
|
||||
skills: []
|
||||
})
|
||||
const dependencyForm = reactive({
|
||||
tool_dependencies: [],
|
||||
mcp_dependencies: [],
|
||||
skill_dependencies: []
|
||||
})
|
||||
|
||||
const columns = [
|
||||
{ title: '名称', dataIndex: 'name', key: 'name', width: 180, ellipsis: true },
|
||||
{ title: 'Slug', dataIndex: 'slug', key: 'slug', width: 180, ellipsis: true },
|
||||
{
|
||||
title: '依赖',
|
||||
key: 'dependencies',
|
||||
width: 150,
|
||||
customRender: ({ record }) =>
|
||||
`T${(record.tool_dependencies || []).length} / M${(record.mcp_dependencies || []).length} / S${(record.skill_dependencies || []).length}`
|
||||
},
|
||||
{ title: '描述', dataIndex: 'description', key: 'description', ellipsis: true },
|
||||
{ title: '更新时间', dataIndex: 'updated_at', key: 'updated_at', width: 180, ellipsis: true }
|
||||
]
|
||||
|
||||
const canSave = computed(() => {
|
||||
if (!selectedPath.value || selectedIsDir.value) return false
|
||||
return fileContent.value !== originalFileContent.value
|
||||
})
|
||||
|
||||
const rowClassName = (record) => {
|
||||
return currentSkill.value?.slug === record.slug ? 'selected-row' : ''
|
||||
}
|
||||
|
||||
const toolDependencyOptions = computed(() =>
|
||||
(dependencyOptions.tools || []).map((item) => ({ label: item, value: item }))
|
||||
)
|
||||
|
||||
const mcpDependencyOptions = computed(() =>
|
||||
(dependencyOptions.mcps || []).map((item) => ({ label: item, value: item }))
|
||||
)
|
||||
|
||||
const skillDependencyOptions = computed(() =>
|
||||
(dependencyOptions.skills || [])
|
||||
.filter((slug) => slug !== currentSkill.value?.slug)
|
||||
.map((item) => ({ label: item, value: item }))
|
||||
)
|
||||
|
||||
const bindSkillRow = (record) => ({
|
||||
onClick: () => selectSkill(record)
|
||||
})
|
||||
|
||||
const normalizeTree = (nodes) => {
|
||||
return (nodes || []).map((node) => ({
|
||||
title: node.name,
|
||||
key: node.path,
|
||||
isLeaf: !node.is_dir,
|
||||
path: node.path,
|
||||
is_dir: node.is_dir,
|
||||
children: node.is_dir ? normalizeTree(node.children || []) : undefined
|
||||
}))
|
||||
}
|
||||
|
||||
const resetFileState = () => {
|
||||
selectedPath.value = ''
|
||||
selectedIsDir.value = false
|
||||
selectedTreeKeys.value = []
|
||||
fileContent.value = ''
|
||||
originalFileContent.value = ''
|
||||
}
|
||||
|
||||
const fetchSkills = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const result = await skillApi.listSkills()
|
||||
skills.value = result?.data || []
|
||||
|
||||
if (currentSkill.value) {
|
||||
const latest = skills.value.find((item) => item.slug === currentSkill.value.slug)
|
||||
if (!latest) {
|
||||
currentSkill.value = null
|
||||
treeData.value = []
|
||||
resetFileState()
|
||||
} else {
|
||||
currentSkill.value = latest
|
||||
syncDependencyFormFromSkill(latest)
|
||||
}
|
||||
}
|
||||
await fetchDependencyOptions()
|
||||
} catch (error) {
|
||||
message.error(error.message || '获取 Skills 列表失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const fetchDependencyOptions = async () => {
|
||||
try {
|
||||
const result = await skillApi.getSkillDependencyOptions()
|
||||
const data = result?.data || {}
|
||||
dependencyOptions.tools = data.tools || []
|
||||
dependencyOptions.mcps = data.mcps || []
|
||||
dependencyOptions.skills = data.skills || []
|
||||
} catch (error) {
|
||||
message.error(error.message || '获取依赖选项失败')
|
||||
}
|
||||
}
|
||||
|
||||
const syncDependencyFormFromSkill = (skillRecord) => {
|
||||
dependencyForm.tool_dependencies = [...(skillRecord?.tool_dependencies || [])]
|
||||
dependencyForm.mcp_dependencies = [...(skillRecord?.mcp_dependencies || [])]
|
||||
dependencyForm.skill_dependencies = [...(skillRecord?.skill_dependencies || [])]
|
||||
}
|
||||
|
||||
const reloadTree = async () => {
|
||||
if (!currentSkill.value) return
|
||||
loading.value = true
|
||||
try {
|
||||
const result = await skillApi.getSkillTree(currentSkill.value.slug)
|
||||
treeData.value = normalizeTree(result?.data || [])
|
||||
} catch (error) {
|
||||
message.error(error.message || '加载目录树失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const selectSkill = async (record) => {
|
||||
currentSkill.value = record
|
||||
syncDependencyFormFromSkill(record)
|
||||
resetFileState()
|
||||
await reloadTree()
|
||||
}
|
||||
|
||||
const handleTreeSelect = async (keys, info) => {
|
||||
if (!keys?.length) {
|
||||
resetFileState()
|
||||
return
|
||||
}
|
||||
|
||||
const node = info?.node || {}
|
||||
const path = node.path || node.key
|
||||
const isDir = !!node.is_dir
|
||||
selectedTreeKeys.value = [path]
|
||||
selectedPath.value = path
|
||||
selectedIsDir.value = isDir
|
||||
|
||||
if (isDir) {
|
||||
fileContent.value = ''
|
||||
originalFileContent.value = ''
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await skillApi.getSkillFile(currentSkill.value.slug, path)
|
||||
const content = result?.data?.content || ''
|
||||
fileContent.value = content
|
||||
originalFileContent.value = content
|
||||
} catch (error) {
|
||||
message.error(error.message || '读取文件失败')
|
||||
}
|
||||
}
|
||||
|
||||
const saveCurrentFile = async () => {
|
||||
if (!currentSkill.value || !selectedPath.value || selectedIsDir.value) return
|
||||
savingFile.value = true
|
||||
try {
|
||||
await skillApi.updateSkillFile(currentSkill.value.slug, {
|
||||
path: selectedPath.value,
|
||||
content: fileContent.value
|
||||
})
|
||||
originalFileContent.value = fileContent.value
|
||||
message.success('保存成功')
|
||||
if (selectedPath.value === 'SKILL.md') {
|
||||
await fetchSkills()
|
||||
}
|
||||
} catch (error) {
|
||||
message.error(error.message || '保存失败')
|
||||
} finally {
|
||||
savingFile.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const openCreateModal = (isDir) => {
|
||||
if (!currentSkill.value) return
|
||||
createForm.path = ''
|
||||
createForm.content = ''
|
||||
createForm.isDir = isDir
|
||||
createModalVisible.value = true
|
||||
}
|
||||
|
||||
const handleCreateNode = async () => {
|
||||
if (!currentSkill.value) return
|
||||
if (!createForm.path.trim()) {
|
||||
message.warning('请输入路径')
|
||||
return
|
||||
}
|
||||
creatingNode.value = true
|
||||
try {
|
||||
await skillApi.createSkillFile(currentSkill.value.slug, {
|
||||
path: createForm.path.trim(),
|
||||
is_dir: createForm.isDir,
|
||||
content: createForm.content
|
||||
})
|
||||
createModalVisible.value = false
|
||||
await reloadTree()
|
||||
message.success(createForm.isDir ? '目录创建成功' : '文件创建成功')
|
||||
} catch (error) {
|
||||
message.error(error.message || '创建失败')
|
||||
} finally {
|
||||
creatingNode.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const confirmDeleteNode = () => {
|
||||
if (!currentSkill.value || !selectedPath.value || selectedPath.value === 'SKILL.md') return
|
||||
Modal.confirm({
|
||||
title: '确认删除节点?',
|
||||
content: selectedPath.value,
|
||||
okText: '删除',
|
||||
okType: 'danger',
|
||||
cancelText: '取消',
|
||||
onOk: async () => {
|
||||
try {
|
||||
await skillApi.deleteSkillFile(currentSkill.value.slug, selectedPath.value)
|
||||
resetFileState()
|
||||
await reloadTree()
|
||||
message.success('删除成功')
|
||||
} catch (error) {
|
||||
message.error(error.message || '删除失败')
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const confirmDeleteSkill = () => {
|
||||
if (!currentSkill.value) return
|
||||
const slug = currentSkill.value.slug
|
||||
Modal.confirm({
|
||||
title: `确认删除 Skill「${slug}」?`,
|
||||
content: '将同时删除技能目录与数据库记录,该操作不可恢复。',
|
||||
okText: '删除',
|
||||
okType: 'danger',
|
||||
cancelText: '取消',
|
||||
onOk: async () => {
|
||||
try {
|
||||
await skillApi.deleteSkill(slug)
|
||||
message.success('Skill 删除成功')
|
||||
currentSkill.value = null
|
||||
treeData.value = []
|
||||
resetFileState()
|
||||
await fetchSkills()
|
||||
} catch (error) {
|
||||
message.error(error.message || '删除 Skill 失败')
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const getDownloadFilename = (response, fallback) => {
|
||||
const header = response.headers.get('content-disposition') || ''
|
||||
const match = header.match(/filename\*=UTF-8''([^;]+)|filename="?([^";]+)"?/i)
|
||||
if (!match) return fallback
|
||||
return decodeURIComponent(match[1] || match[2] || fallback)
|
||||
}
|
||||
|
||||
const handleExport = async () => {
|
||||
if (!currentSkill.value) return
|
||||
try {
|
||||
const response = await skillApi.exportSkill(currentSkill.value.slug)
|
||||
const blob = await response.blob()
|
||||
const filename = getDownloadFilename(response, `${currentSkill.value.slug}.zip`)
|
||||
const url = URL.createObjectURL(blob)
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = filename
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
link.remove()
|
||||
URL.revokeObjectURL(url)
|
||||
} catch (error) {
|
||||
message.error(error.message || '导出失败')
|
||||
}
|
||||
}
|
||||
|
||||
const handleImportUpload = async ({ file, onSuccess, onError }) => {
|
||||
importing.value = true
|
||||
try {
|
||||
const result = await skillApi.importSkillZip(file)
|
||||
message.success('导入成功')
|
||||
await fetchSkills()
|
||||
const imported = result?.data
|
||||
if (imported?.slug) {
|
||||
const record = skills.value.find((item) => item.slug === imported.slug)
|
||||
if (record) {
|
||||
await selectSkill(record)
|
||||
}
|
||||
}
|
||||
onSuccess?.(result)
|
||||
} catch (error) {
|
||||
message.error(error.message || '导入失败')
|
||||
onError?.(error)
|
||||
} finally {
|
||||
importing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const saveDependencies = async () => {
|
||||
if (!currentSkill.value) return
|
||||
savingDependencies.value = true
|
||||
try {
|
||||
const result = await skillApi.updateSkillDependencies(currentSkill.value.slug, {
|
||||
tool_dependencies: dependencyForm.tool_dependencies,
|
||||
mcp_dependencies: dependencyForm.mcp_dependencies,
|
||||
skill_dependencies: dependencyForm.skill_dependencies
|
||||
})
|
||||
const updated = result?.data || null
|
||||
if (updated) {
|
||||
currentSkill.value = updated
|
||||
syncDependencyFormFromSkill(updated)
|
||||
}
|
||||
await fetchSkills()
|
||||
message.success('依赖保存成功')
|
||||
} catch (error) {
|
||||
message.error(error.message || '依赖保存失败')
|
||||
} finally {
|
||||
savingDependencies.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchSkills()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.skills-manager {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.header-section {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.title {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.description {
|
||||
margin: 0;
|
||||
color: var(--gray-600);
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.content-layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(280px, 36%) 1fr;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.skills-list-panel {
|
||||
border: 1px solid var(--gray-150);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.skill-detail-panel {
|
||||
border: 1px solid var(--gray-150);
|
||||
border-radius: 8px;
|
||||
padding: 10px;
|
||||
min-height: 420px;
|
||||
}
|
||||
|
||||
.detail-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.detail-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.slug {
|
||||
color: var(--gray-600);
|
||||
}
|
||||
|
||||
.dependency-summary {
|
||||
color: var(--gray-600);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.detail-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.dependency-panel {
|
||||
border: 1px solid var(--gray-150);
|
||||
border-radius: 8px;
|
||||
padding: 8px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.dependency-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.dependency-title {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.dependency-form :deep(.ant-form-item) {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.detail-body {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(240px, 35%) 1fr;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.tree-panel,
|
||||
.editor-panel {
|
||||
border: 1px solid var(--gray-150);
|
||||
border-radius: 8px;
|
||||
padding: 8px;
|
||||
min-height: 360px;
|
||||
}
|
||||
|
||||
.tree-toolbar,
|
||||
.editor-toolbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.tree-title {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.editor-path {
|
||||
color: var(--gray-700);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.file-editor {
|
||||
font-family: Menlo, Monaco, Consolas, 'Courier New', monospace;
|
||||
}
|
||||
|
||||
:deep(.selected-row td) {
|
||||
background: var(--gray-100) !important;
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.content-layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.detail-body {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@ -2,12 +2,13 @@
|
||||
import { ref, reactive, onMounted, computed, provide } from 'vue'
|
||||
import { RouterLink, RouterView, useRoute } from 'vue-router'
|
||||
import { GithubOutlined } from '@ant-design/icons-vue'
|
||||
import { Bot, Waypoints, LibraryBig, BarChart3, CircleCheck } from 'lucide-vue-next'
|
||||
import { Bot, Waypoints, LibraryBig, BarChart3, CircleCheck, Folder } from 'lucide-vue-next'
|
||||
|
||||
import { useConfigStore } from '@/stores/config'
|
||||
import { useDatabaseStore } from '@/stores/database'
|
||||
import { useInfoStore } from '@/stores/info'
|
||||
import { useTaskerStore } from '@/stores/tasker'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import UserInfoComponent from '@/components/UserInfoComponent.vue'
|
||||
import DebugComponent from '@/components/DebugComponent.vue'
|
||||
@ -18,6 +19,7 @@ const configStore = useConfigStore()
|
||||
const databaseStore = useDatabaseStore()
|
||||
const infoStore = useInfoStore()
|
||||
const taskerStore = useTaskerStore()
|
||||
const userStore = useUserStore()
|
||||
const { activeCount: activeCountRef, isDrawerOpen } = storeToRefs(taskerStore)
|
||||
|
||||
const layoutSettings = reactive({
|
||||
@ -86,32 +88,45 @@ console.log(route)
|
||||
const activeTaskCount = computed(() => activeCountRef.value || 0)
|
||||
|
||||
// 下面是导航菜单部分,添加智能体项
|
||||
const mainList = [
|
||||
{
|
||||
name: '智能体',
|
||||
path: '/agent',
|
||||
icon: Bot,
|
||||
activeIcon: Bot
|
||||
},
|
||||
{
|
||||
name: '图谱',
|
||||
path: '/graph',
|
||||
icon: Waypoints,
|
||||
activeIcon: Waypoints
|
||||
},
|
||||
{
|
||||
name: '知识库',
|
||||
path: '/database',
|
||||
icon: LibraryBig,
|
||||
activeIcon: LibraryBig
|
||||
},
|
||||
{
|
||||
name: 'Dashboard',
|
||||
path: '/dashboard',
|
||||
icon: BarChart3,
|
||||
activeIcon: BarChart3
|
||||
const mainList = computed(() => {
|
||||
const items = [
|
||||
{
|
||||
name: '智能体',
|
||||
path: '/agent',
|
||||
icon: Bot,
|
||||
activeIcon: Bot
|
||||
},
|
||||
{
|
||||
name: '图谱',
|
||||
path: '/graph',
|
||||
icon: Waypoints,
|
||||
activeIcon: Waypoints
|
||||
},
|
||||
{
|
||||
name: '知识库',
|
||||
path: '/database',
|
||||
icon: LibraryBig,
|
||||
activeIcon: LibraryBig
|
||||
},
|
||||
{
|
||||
name: 'Dashboard',
|
||||
path: '/dashboard',
|
||||
icon: BarChart3,
|
||||
activeIcon: BarChart3
|
||||
}
|
||||
]
|
||||
|
||||
if (userStore.isSuperAdmin) {
|
||||
items.push({
|
||||
name: 'Skills 管理',
|
||||
path: '/skills',
|
||||
icon: Folder,
|
||||
activeIcon: Folder
|
||||
})
|
||||
}
|
||||
]
|
||||
|
||||
return items
|
||||
})
|
||||
|
||||
// Provide settings modal methods to child components
|
||||
provide('settingsModal', {
|
||||
|
||||
@ -90,6 +90,24 @@ const router = createRouter({
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
path: '/skills',
|
||||
name: 'skills',
|
||||
component: AppLayout,
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
name: 'SkillsComp',
|
||||
component: () => import('../views/SkillsView.vue'),
|
||||
meta: {
|
||||
keepAlive: false,
|
||||
requiresAuth: true,
|
||||
requiresAdmin: true,
|
||||
requiresSuperAdmin: true
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
path: '/:pathMatch(.*)*',
|
||||
name: 'NotFound',
|
||||
@ -104,6 +122,7 @@ router.beforeEach(async (to, from, next) => {
|
||||
// 检查路由是否需要认证
|
||||
const requiresAuth = to.matched.some((record) => record.meta.requiresAuth === true)
|
||||
const requiresAdmin = to.matched.some((record) => record.meta.requiresAdmin)
|
||||
const requiresSuperAdmin = to.matched.some((record) => record.meta.requiresSuperAdmin)
|
||||
|
||||
const userStore = useUserStore()
|
||||
|
||||
@ -120,6 +139,7 @@ router.beforeEach(async (to, from, next) => {
|
||||
|
||||
const isLoggedIn = userStore.isLoggedIn
|
||||
const isAdmin = userStore.isAdmin
|
||||
const isSuperAdmin = userStore.isSuperAdmin
|
||||
|
||||
// 如果路由需要认证但用户未登录
|
||||
if (requiresAuth && !isLoggedIn) {
|
||||
@ -159,6 +179,26 @@ router.beforeEach(async (to, from, next) => {
|
||||
return
|
||||
}
|
||||
|
||||
// 如果路由需要超级管理员权限但用户不是超级管理员
|
||||
if (requiresSuperAdmin && !isSuperAdmin) {
|
||||
try {
|
||||
const agentStore = useAgentStore()
|
||||
if (!agentStore.isInitialized) {
|
||||
await agentStore.initialize()
|
||||
}
|
||||
const defaultAgent = agentStore.defaultAgent
|
||||
if (defaultAgent && defaultAgent.id) {
|
||||
next(`/agent/${defaultAgent.id}`)
|
||||
} else {
|
||||
next('/')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取智能体信息失败:', error)
|
||||
next('/')
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// 如果用户已登录但访问登录页
|
||||
if (to.path === '/login' && isLoggedIn) {
|
||||
next('/')
|
||||
|
||||
33
web/src/utils/chunk_presets.js
Normal file
33
web/src/utils/chunk_presets.js
Normal file
@ -0,0 +1,33 @@
|
||||
export const CHUNK_PRESET_OPTIONS = [
|
||||
{
|
||||
value: 'general',
|
||||
label: 'General',
|
||||
description: '通用分块:按分隔符和长度切分,适合大多数普通文档。'
|
||||
},
|
||||
{
|
||||
value: 'qa',
|
||||
label: 'QA',
|
||||
description: '问答分块:优先抽取问题-回答结构,适合 FAQ、题库、问答手册。'
|
||||
},
|
||||
{
|
||||
value: 'book',
|
||||
label: 'Book',
|
||||
description: '书籍分块:强化章节标题识别并做层级合并,适合教材、手册、长章节文档。'
|
||||
},
|
||||
{
|
||||
value: 'laws',
|
||||
label: 'Laws',
|
||||
description: '法规分块:按法条层级组织与合并,适合法律法规、制度规范类文本。'
|
||||
}
|
||||
]
|
||||
|
||||
export const CHUNK_PRESET_LABEL_MAP = Object.fromEntries(
|
||||
CHUNK_PRESET_OPTIONS.map((item) => [item.value, item.label])
|
||||
)
|
||||
|
||||
export const CHUNK_PRESET_DESCRIPTION_MAP = Object.fromEntries(
|
||||
CHUNK_PRESET_OPTIONS.map((item) => [item.value, item.description])
|
||||
)
|
||||
|
||||
export const getChunkPresetDescription = (presetId) =>
|
||||
CHUNK_PRESET_DESCRIPTION_MAP[presetId] || CHUNK_PRESET_DESCRIPTION_MAP.general
|
||||
@ -56,6 +56,19 @@
|
||||
placeholder="请选择嵌入模型"
|
||||
/>
|
||||
|
||||
<div class="chunk-preset-title-row">
|
||||
<h3 style="margin: 0">分块策略</h3>
|
||||
<a-tooltip :title="selectedPresetDescription">
|
||||
<QuestionCircleOutlined class="chunk-preset-help-icon" />
|
||||
</a-tooltip>
|
||||
</div>
|
||||
<a-select
|
||||
v-model:value="newDatabase.chunk_preset_id"
|
||||
:options="chunkPresetOptions"
|
||||
style="width: 100%"
|
||||
size="large"
|
||||
/>
|
||||
|
||||
<!-- 仅对 LightRAG 提供语言选择和LLM选择 -->
|
||||
<div v-if="newDatabase.kb_type === 'lightrag'">
|
||||
<h3 style="margin-top: 20px">语言</h3>
|
||||
@ -197,7 +210,7 @@ import { useRouter, useRoute } from 'vue-router'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useConfigStore } from '@/stores/config'
|
||||
import { useDatabaseStore } from '@/stores/database'
|
||||
import { LockOutlined, InfoCircleOutlined, PlusOutlined } from '@ant-design/icons-vue'
|
||||
import { LockOutlined, PlusOutlined, QuestionCircleOutlined } from '@ant-design/icons-vue'
|
||||
import { typeApi } from '@/apis/knowledge_api'
|
||||
import HeaderComponent from '@/components/HeaderComponent.vue'
|
||||
import ModelSelectorComponent from '@/components/ModelSelectorComponent.vue'
|
||||
@ -206,6 +219,7 @@ import ShareConfigForm from '@/components/ShareConfigForm.vue'
|
||||
import dayjs, { parseToShanghai } from '@/utils/time'
|
||||
import AiTextarea from '@/components/AiTextarea.vue'
|
||||
import { getKbTypeLabel, getKbTypeIcon, getKbTypeColor } from '@/utils/kb_utils'
|
||||
import { CHUNK_PRESET_OPTIONS, getChunkPresetDescription } from '@/utils/chunk_presets'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
@ -240,6 +254,8 @@ const languageOptions = [
|
||||
{ label: '印地语 Hindi', value: 'Hindi' }
|
||||
]
|
||||
|
||||
const chunkPresetOptions = CHUNK_PRESET_OPTIONS.map(({ label, value }) => ({ label, value }))
|
||||
|
||||
const createEmptyDatabaseForm = () => ({
|
||||
name: '',
|
||||
description: '',
|
||||
@ -247,6 +263,7 @@ const createEmptyDatabaseForm = () => ({
|
||||
kb_type: 'milvus',
|
||||
is_private: false,
|
||||
storage: '',
|
||||
chunk_preset_id: 'general',
|
||||
language: 'Chinese',
|
||||
llm_info: {
|
||||
provider: '',
|
||||
@ -256,6 +273,10 @@ const createEmptyDatabaseForm = () => ({
|
||||
|
||||
const newDatabase = reactive(createEmptyDatabaseForm())
|
||||
|
||||
const selectedPresetDescription = computed(() =>
|
||||
getChunkPresetDescription(newDatabase.chunk_preset_id)
|
||||
)
|
||||
|
||||
const llmModelSpec = computed(() => {
|
||||
const provider = newDatabase.llm_info?.provider || ''
|
||||
const modelName = newDatabase.llm_info?.model_name || ''
|
||||
@ -364,7 +385,8 @@ const buildRequestData = () => {
|
||||
embed_model_name: newDatabase.embed_model_name || configStore.config.embed_model,
|
||||
kb_type: newDatabase.kb_type,
|
||||
additional_params: {
|
||||
is_private: newDatabase.is_private || false
|
||||
is_private: newDatabase.is_private || false,
|
||||
chunk_preset_id: newDatabase.chunk_preset_id || 'general'
|
||||
}
|
||||
}
|
||||
|
||||
@ -429,6 +451,20 @@ onMounted(() => {
|
||||
|
||||
<style lang="less" scoped>
|
||||
.new-database-modal {
|
||||
.chunk-preset-title-row {
|
||||
margin-top: 20px;
|
||||
margin-bottom: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.chunk-preset-help-icon {
|
||||
color: var(--gray-500);
|
||||
cursor: help;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.kb-type-guide {
|
||||
margin: 12px 0;
|
||||
}
|
||||
|
||||
15
web/src/views/SkillsView.vue
Normal file
15
web/src/views/SkillsView.vue
Normal file
@ -0,0 +1,15 @@
|
||||
<template>
|
||||
<div class="skills-view">
|
||||
<SkillsManagerComponent />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import SkillsManagerComponent from '@/components/SkillsManagerComponent.vue'
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.skills-view {
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
||||
Loading…
Reference in New Issue
Block a user