Merge remote-tracking branch 'refs/remotes/zhizhizhii/feature/sandbox' into feat/sandbox-provisioner-shared-storage
# Conflicts: # pyproject.toml # src/agents/common/base.py
This commit is contained in:
commit
9c1e5e2154
100
README.md
100
README.md
@ -1,10 +1,8 @@
|
||||
|
||||
<div align="center">
|
||||
<img width="140" height="140" alt="image" src="https://github.com/user-attachments/assets/299137b7-08d8-45b0-9feb-7b4ab35d7b48" />
|
||||
|
||||
<h1>语析 - 基于大模型的知识库与知识图谱智能体开发平台</h1>
|
||||
|
||||
[](https://github.com/xerrors/Yuxi-Know/tree/v0.4.4)
|
||||
[](https://github.com/xerrors/Yuxi-Know/tree/v0.5.1)
|
||||
[](https://github.com/xerrors/Yuxi-Know/blob/main/docker-compose.yml)
|
||||
[](https://github.com/xerrors/Yuxi-Know/issues)
|
||||
[](https://github.com/xerrors/Yuxi-Know/blob/main/LICENSE)
|
||||
@ -23,10 +21,13 @@
|
||||
|
||||
</div>
|
||||
|
||||
<img width="2752" height="1536" alt="wps_pic_0" src="https://github.com/user-attachments/assets/96742b56-eda7-4aae-a4df-6fe9d3c30fd1" />
|
||||
|
||||
**图由 Nano Banana 2 生成*
|
||||
|
||||
## 核心特性
|
||||
|
||||
- **智能体开发**:基于 LangGraph v1 的多智能体架构,支持子智能体、工具调用与中间件机制
|
||||
- **智能体开发**:基于 LangGraph,支持子智能体、Skills、MCPs、Tools 与中间件机制
|
||||
- **知识库(RAG)**:多格式文档上传,支持 Embedding / Rerank 配置及知识库评估
|
||||
- **知识图谱**:基于 LightRAG 的图谱构建与可视化,支持属性图谱并参与智能体推理
|
||||
- **平台与工程化**:Vue + FastAPI 架构,支持暗黑模式、Docker 与生产级部署
|
||||
@ -68,7 +69,6 @@
|
||||
- 修改方法备注信息 [#478](https://github.com/xerrors/Yuxi-Know/pull/478)
|
||||
- 修复多次 human-in-the-loop 的渲染解析问题 [#453](https://github.com/xerrors/Yuxi-Know/issues/453) [#475](https://github.com/xerrors/Yuxi-Know/pull/475)
|
||||
- 修复消息加载逻辑导致的前端消息渲染延迟问题
|
||||
-
|
||||
|
||||
</details>
|
||||
|
||||
@ -112,7 +112,7 @@
|
||||
- 更多智能体开发套件 中间件、子智能体,更简洁,更易上手。
|
||||
</details>
|
||||
|
||||
<img width="4224" height="1006" alt="image" src="https://github.com/user-attachments/assets/66a85b70-5a40-4c5e-aeaa-18b3c85aa76f" />
|
||||
<img width="1760" height="410" alt="image" src="https://github.com/user-attachments/assets/7f668fdc-9472-4153-8e76-7fe3e665d060" />
|
||||
|
||||
|
||||
## 快速开始
|
||||
@ -120,7 +120,7 @@
|
||||
克隆代码,并初始化
|
||||
|
||||
```
|
||||
git clone --branch v0.4.4 --depth 1 https://github.com/xerrors/Yuxi-Know.git
|
||||
git clone --branch v0.5.1 --depth 1 https://github.com/xerrors/Yuxi-Know.git
|
||||
cd Yuxi-Know
|
||||
|
||||
# Linux/macOS
|
||||
@ -140,18 +140,80 @@ docker compose up --build
|
||||
|
||||
## 示例与演示
|
||||
|
||||
|
||||
<img width="4420" height="2510" alt="image" src="https://github.com/user-attachments/assets/76d58c8f-e4ef-4373-8ab6-7c80da568910" />
|
||||
<br>
|
||||
<img width="10116" height="5751" alt="11111" src="https://github.com/user-attachments/assets/d3e4fe09-fa48-4686-93ea-2c50300ade21" />
|
||||
<br>
|
||||
<img width="10116" height="5751" alt="22222" src="https://github.com/user-attachments/assets/734a7cce-8b38-48ae-8e21-ca88996e5dde" />
|
||||
|
||||
<br>
|
||||
<img width="10116" height="5751" alt="1212" src="https://github.com/user-attachments/assets/06d56525-69bf-463a-8360-286b2cf8796f" />
|
||||
<br>
|
||||
<img width="10116" height="5751" alt="44444" src="https://github.com/user-attachments/assets/e390ec4b-8690-4aee-bbb2-3536f7f67dc9" />
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td align="center">
|
||||
<img src="https://github.com/user-attachments/assets/1f7af2b8-10f1-4bf3-aa75-bd975928fadc" width="100%" alt="首页"/>
|
||||
<br/>
|
||||
<strong>首页</strong>
|
||||
</td>
|
||||
<td align="center">
|
||||
<img src="https://github.com/user-attachments/assets/d3e4fe09-fa48-4686-93ea-2c50300ade21" width="100%" alt="Dashboard 统计"/>
|
||||
<br/>
|
||||
<strong>Dashboard 统计</strong>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center">
|
||||
<img src="https://github.com/user-attachments/assets/a2059193-bc25-492f-9260-105a1fa1d567" width="100%" alt="智能体配置"/>
|
||||
<br/>
|
||||
<strong>智能体配置</strong>
|
||||
</td>
|
||||
<td align="center">
|
||||
<img src="https://github.com/user-attachments/assets/06d56525-69bf-463a-8360-286b2cf8796f" width="100%" alt="知识库调用"/>
|
||||
<br/>
|
||||
<strong>知识库调用</strong>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center">
|
||||
<img src="https://github.com/user-attachments/assets/0548d89c-15a3-47cf-ba87-1b544f7dd749" width="100%" alt="新建知识库"/>
|
||||
<br/>
|
||||
<strong>新建知识库</strong>
|
||||
</td>
|
||||
<td align="center">
|
||||
<img src="https://github.com/user-attachments/assets/21396d04-376b-4e9a-8139-eec8c3cc915a" width="100%" alt="知识库管理"/>
|
||||
<br/>
|
||||
<strong>知识库管理</strong>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center">
|
||||
<img src="https://github.com/user-attachments/assets/fc46a14b-16fb-47ea-84a0-148a451f3012" width="100%" alt="知识图谱"/>
|
||||
<br/>
|
||||
<strong>知识图谱可视化</strong>
|
||||
</td>
|
||||
<td align="center">
|
||||
<img src="https://github.com/user-attachments/assets/d8b3de51-2854-455b-956f-2ae2d8d5f677" width="100%" alt="项目文档"/>
|
||||
<br/>
|
||||
<strong>项目使用文档</strong>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center">
|
||||
<img src="https://github.com/user-attachments/assets/b0d9dd2b-df3b-47b4-9899-3d8dd0928409" width="100%" alt="拓展管理(Skills)"/>
|
||||
<br/>
|
||||
<strong>拓展管理(Skills)</strong>
|
||||
</td>
|
||||
<td align="center">
|
||||
<img src="https://github.com/user-attachments/assets/9305d7a4-663b-4e5d-a252-211d6caa019b" width="100%" alt="拓展管理(MCPs)"/>
|
||||
<br/>
|
||||
<strong>拓展管理(MCPs)</strong>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center">
|
||||
<img src="https://github.com/user-attachments/assets/13bd22ea-ddde-4262-8c29-69fb948bce44" width="100%" alt="拓展管理(Skills)"/>
|
||||
<br/>
|
||||
<strong>用户/部门权限管理</strong>
|
||||
</td>
|
||||
<td align="center">
|
||||
<img src="https://github.com/user-attachments/assets/cc886b04-719e-4abd-807d-e9955080003d" width="100%" alt="拓展管理(MCPs)"/>
|
||||
<br/>
|
||||
<strong>模型供应商配置</strong>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
## 参与贡献
|
||||
|
||||
|
||||
@ -3,7 +3,7 @@ services:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: docker/api.Dockerfile
|
||||
image: yuxi-api:0.5.dev
|
||||
image: yuxi-api:0.5.2.dev
|
||||
container_name: api-dev
|
||||
working_dir: /app
|
||||
volumes:
|
||||
@ -73,7 +73,7 @@ services:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: docker/api.Dockerfile
|
||||
image: yuxi-api:0.5.dev
|
||||
image: yuxi-api:0.5.2.dev
|
||||
container_name: worker-dev
|
||||
working_dir: /app
|
||||
volumes:
|
||||
@ -135,6 +135,7 @@ services:
|
||||
build:
|
||||
context: ./docker/sandbox_provisioner
|
||||
dockerfile: Dockerfile
|
||||
image: yuxi-sandbox-provisioner:0.5.2.dev
|
||||
container_name: sandbox-provisioner
|
||||
volumes:
|
||||
- ./saves:/app/saves
|
||||
@ -181,7 +182,7 @@ services:
|
||||
context: .
|
||||
dockerfile: docker/web.Dockerfile
|
||||
target: development
|
||||
image: yuxi-web:0.5.dev
|
||||
image: yuxi-web:0.5.2.dev
|
||||
container_name: web-dev
|
||||
volumes:
|
||||
- ./web/src:/app/src
|
||||
@ -324,6 +325,8 @@ services:
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
container_name: redis
|
||||
ports:
|
||||
- "6379:6379"
|
||||
command: redis-server --appendonly yes
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "ping"]
|
||||
|
||||
@ -29,6 +29,7 @@ RUN set -ex \
|
||||
&& apt-get install -y --no-install-recommends --fix-missing \
|
||||
curl \
|
||||
ffmpeg \
|
||||
libpq5 \
|
||||
libsm6 \
|
||||
libxext6 \
|
||||
# (D) 清理垃圾,减小体积
|
||||
|
||||
@ -236,16 +236,31 @@ class LocalContainerProvisionerBackend:
|
||||
raise RuntimeError(f"sandbox {sandbox_id} is not ready at {record.sandbox_url}")
|
||||
return record
|
||||
|
||||
threads_root = Path(self._threads_host_path).resolve()
|
||||
thread_user_data = (threads_root / safe_thread_id / "user-data").resolve()
|
||||
try:
|
||||
thread_user_data.relative_to(threads_root)
|
||||
except ValueError as exc:
|
||||
raise ValueError("thread_id resolved outside threads host root") from exc
|
||||
thread_user_data.mkdir(parents=True, exist_ok=True)
|
||||
# 检测是否是 Windows 绝对路径 (如 D:/ 或 D:\)
|
||||
threads_root_str = self._threads_host_path
|
||||
is_windows_path = len(threads_root_str) >= 2 and threads_root_str[1] == ':'
|
||||
|
||||
skills_path = Path(self._skills_host_path)
|
||||
skills_path.mkdir(parents=True, exist_ok=True)
|
||||
if is_windows_path:
|
||||
# Windows 路径,直接使用,不调用 resolve()
|
||||
threads_root = Path(threads_root_str)
|
||||
thread_user_data = threads_root / safe_thread_id / "user-data"
|
||||
# Windows 路径下无法在 Linux 容器内创建目录,跳过 mkdir
|
||||
else:
|
||||
threads_root = Path(threads_root_str).resolve()
|
||||
thread_user_data = (threads_root / safe_thread_id / "user-data").resolve()
|
||||
try:
|
||||
thread_user_data.relative_to(threads_root)
|
||||
except ValueError as exc:
|
||||
raise ValueError("thread_id resolved outside threads host root") from exc
|
||||
thread_user_data.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
skills_path_str = self._skills_host_path
|
||||
is_skills_windows = len(skills_path_str) >= 2 and skills_path_str[1] == ':'
|
||||
if is_skills_windows:
|
||||
skills_path = Path(skills_path_str)
|
||||
else:
|
||||
skills_path = Path(skills_path_str)
|
||||
skills_path.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
container_name = self._container_name(sandbox_id)
|
||||
run_kwargs = {
|
||||
|
||||
@ -8,6 +8,10 @@ export default defineConfig({
|
||||
title: "Yuxi-Know",
|
||||
description: "语析",
|
||||
base: '/Yuxi-Know/',
|
||||
srcDir: './',
|
||||
ignoreDeadLinks: [
|
||||
/localhost/
|
||||
],
|
||||
markdown: {
|
||||
config: (md) => {
|
||||
md.use(markdownItTaskCheckbox)
|
||||
@ -38,13 +42,22 @@ export default defineConfig({
|
||||
{ text: '知识库评估', link: '/latest/intro/evaluation' }
|
||||
]
|
||||
},
|
||||
{
|
||||
text: '智能体开发',
|
||||
items: [
|
||||
{ text: '智能体配置', link: '/latest/agents/agents-config' },
|
||||
{ text: '上下文配置', link: '/latest/agents/context-config' },
|
||||
{ text: '工具系统', link: '/latest/agents/tools-system' },
|
||||
{ text: '中间件', link: '/latest/agents/middleware' },
|
||||
{ text: 'MCP 集成', link: '/latest/agents/mcp-integration' },
|
||||
{ text: 'Skills 管理', link: '/latest/agents/skills-management' }
|
||||
]
|
||||
},
|
||||
{
|
||||
text: '高级配置',
|
||||
items: [
|
||||
{ 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' }
|
||||
|
||||
@ -1,347 +0,0 @@
|
||||
# 智能体
|
||||
|
||||
## 智能体开发
|
||||
|
||||
系统基于 [LangGraph](https://github.com/langchain-ai/langgraph) 并通过统一的 `AgentManager` 管理所有智能体。`src/agents/__init__.py` 会在启动时遍历 `src/agents` 目录,对每个包含 `__init__.py` 的子包执行自动发现:所有继承 `BaseAgent` 的类都会被注册并立即初始化,因此只要代码落位正确,就不需要再手动登记或修改管理器。
|
||||
|
||||
仓库预置了若干可直接运行的智能体:`chatbot` 聚焦对话与动态工具调度,`reporter` 演示报告类链路,`deep_agent` 提供深度分析能力。这些目录展示了上下文类、Graph 构造方式、子智能体引用以及中间件组合的范例,新增功能时可以直接复用。
|
||||
|
||||
### 智能体元数据配置
|
||||
|
||||
每个智能体可以通过在智能体目录下创建 `metadata.toml` 文件来配置元数据信息。这个文件使用 TOML 格式,包含以下字段:
|
||||
|
||||
- `name`: 智能体显示名称
|
||||
- `description`: 智能体功能描述
|
||||
- `examples`: 示例问题列表(数组格式)
|
||||
|
||||
例如,`src/agents/chatbot/metadata.toml`:
|
||||
|
||||
<<< @/../src/agents/chatbot/metadata.toml
|
||||
|
||||
**注意**:`metadata.toml` 文件是可选的,如果没有提供,系统将使用智能体类的基本属性。
|
||||
|
||||
### 创建新的智能体
|
||||
|
||||
在 `src/agents` 下新建一个包,保持与现有目录一致的结构:放置 Graph 构造逻辑(通常命名为 `graph.py`),并在包内的 `__init__.py` 中暴露主类。
|
||||
|
||||
智能体类必须继承 `src.agents.common.BaseAgent`,同时实现异步的 `get_graph` 方法来返回编译后的 LangGraph 实例,并配置好 `checkpointer`,否则无法从历史对话中恢复。
|
||||
|
||||
需要额外上下文字段时,可继承 `BaseContext` 构建自己的配置表单,再把类绑定到 `context_schema`,平台会在 `saves/agents/<module>` 下生成默认配置。
|
||||
|
||||
案例1 基于MySQL工具,以及自定义 MCP Server 的数据库报表助手。
|
||||
|
||||
<<< @/../src/agents/reporter/graph.py
|
||||
|
||||
### 工具系统
|
||||
|
||||
系统提供统一的工具获取函数 `get_tools_from_context(context)`,自动从上下文配置中组装工具列表:
|
||||
|
||||
```python
|
||||
from src.agents.common.tools import get_tools_from_context
|
||||
|
||||
async def get_graph(self, **kwargs):
|
||||
context = self.get_context()
|
||||
tools = await get_tools_from_context(context)
|
||||
# tools 已包含:基础工具、知识库工具、MCP 工具
|
||||
```
|
||||
|
||||
该函数会自动处理三类工具的组装:
|
||||
1. **基础工具**: 从 `context.tools` 筛选的内置工具
|
||||
2. **知识库工具**: 根据 `context.knowledges` 自动生成检索工具
|
||||
3. **MCP 工具**: 根据 `context.mcps` 加载并过滤的 MCP 服务器工具
|
||||
|
||||
### BaseContext 配置字段
|
||||
|
||||
`BaseContext` 已内置以下常用配置字段,所有智能体可直接复用:
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `model` | str | 使用的 LLM 模型 |
|
||||
| `system_prompt` | str | 系统提示词 |
|
||||
| `tools` | list[str] | 启用的内置工具列表 |
|
||||
| `knowledges` | list[str] | 关联的知识库列表 |
|
||||
| `mcps` | list[str] | 启用的 MCP 服务器名称 |
|
||||
| `skills` | list[str] | 关联的 Skills(运行时只读挂载到 `/skills`) |
|
||||
|
||||
```python
|
||||
from src.agents.common import BaseContext
|
||||
|
||||
@dataclass(kw_only=True)
|
||||
class MyAgentContext(BaseContext):
|
||||
# 继承所有 BaseContext 字段
|
||||
# 可在此添加智能体特有的额外配置
|
||||
custom_field: str = "默认值"
|
||||
```
|
||||
|
||||
如需自定义工具选项(如 ReporterAgent 的 MySQL 工具),可覆盖 `tools` 字段的 `options` 元数据:
|
||||
|
||||
```python
|
||||
from src.agents.common import BaseContext, gen_tool_info
|
||||
from src.agents.common.toolkits.buildin import calculator, query_knowledge_graph
|
||||
from src.agents.common.toolkits.buildin.tools import _create_tavily_search
|
||||
from src.agents.common.toolkits.mysql import get_mysql_tools
|
||||
|
||||
@dataclass(kw_only=True)
|
||||
class ReporterContext(BaseContext):
|
||||
tools: Annotated[list[dict], {"__template_metadata__": {"kind": "tools"}}] = field(
|
||||
default_factory=lambda: [t.name for t in get_mysql_tools()],
|
||||
metadata={
|
||||
"name": "工具",
|
||||
"options": lambda: gen_tool_info(
|
||||
[calculator, query_knowledge_graph, _create_tavily_search()] + get_mysql_tools()
|
||||
),
|
||||
"description": "包含内置工具和 MySQL 工具包。",
|
||||
},
|
||||
)
|
||||
|
||||
def __post_init__(self):
|
||||
self.mcps = ["mcp-server-chart"] # 默认启用图表 MCP
|
||||
```
|
||||
|
||||
智能体实例的生命周期交给管理器处理,会在自动发现时完成初始化并缓存单例,以便快速响应请求。在容器内热重载时,只要保存文件即可触发重新导入;需要强制刷新可调用 `agent_manager.get_agent(<id>, reload=True)`。
|
||||
|
||||
更多动态工具选择与 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`。
|
||||
|
||||
对工具、模型或提示语的调整建议封装到中间件或独立函数里,既方便多智能体共用,又能保持 `BaseAgent` 的基础接口稳定。变更提交后无需手动刷新注册表,只要确保包结构未改变,智能体会在热重载中自动更新。
|
||||
|
||||
### 子智能体与中间件
|
||||
|
||||
子智能体集中放在 `src/agents/common/subagents` 目录,典型例子是 `calc_agent`,它通过 LangChain 的 `create_agent` 构建计算器能力并以工具暴露给主图。新增子智能体时沿用这一结构:在目录内编写封装函数与 `@tool` 装饰器,导出后即可被任意智能体调用。
|
||||
|
||||
中间件位于 `src/agents/common/middlewares`,包含上下文感知提示词、模型选择、动态工具加载以及附件注入等实现。如果需要编写新的中间件,请遵循 LangChain 官方文档中对 `AgentMiddleware`、`ModelRequest`、`ModelResponse` 等接口的定义,完成后在该目录的 `__init__.py` 暴露入口,主智能体即可在 `middleware` 列表中引用。
|
||||
|
||||
#### RuntimeConfigMiddleware
|
||||
|
||||
`RuntimeConfigMiddleware`([runtime_config_middleware.py](https://github.com/xerrors/Yuxi-Know/blob/main/src/agents/common/middlewares/runtime_config_middleware.py))是系统默认的核心中间件之一,负责在每次模型调用前自动注入运行时配置:
|
||||
|
||||
1. **自动注入当前时间**:在 system prompt 开头追加当前时间,格式为 `当前时间:YYYY-MM-DD HH:MM:SS`,确保 LLM 能获取准确的时间上下文。
|
||||
2. **动态加载工具**:根据 `context.tools`、`context.knowledges`、`context.mcps` 自动组装可用工具列表。
|
||||
3. **模型选择**:根据 `context.model` 加载对应模型配置。
|
||||
|
||||
如需自定义时间注入逻辑或禁用该行为,可继承该中间件并覆盖 `awrap_model_call` 方法。
|
||||
|
||||
#### 文件上传中间件
|
||||
|
||||
文件上传功能通过 `inject_attachment_context` 中间件实现(位于 `src/agents/common/middlewares/attachment_middleware.py`)。该中间件基于 LangChain 1.0 的 `AgentMiddleware` 标准实现,具有以下特点:
|
||||
|
||||
1. **状态扩展**:定义 `AttachmentState` 扩展 `AgentState`,添加可选的 `attachments` 字段
|
||||
2. **自动注入**:在模型调用前,从 `request.state` 中读取附件并转换为 `SystemMessage`
|
||||
3. **向后兼容**:不使用文件上传的智能体不受影响
|
||||
|
||||
##### 为智能体启用文件上传
|
||||
|
||||
只需两步:
|
||||
|
||||
**步骤 1:声明能力**(让前端显示上传按钮)
|
||||
|
||||
```python
|
||||
class MyAgent(BaseAgent):
|
||||
capabilities = ["file_upload"]
|
||||
```
|
||||
|
||||
**步骤 2:添加中间件**(让智能体能够处理附件内容)
|
||||
|
||||
```python
|
||||
from src.agents.common.middlewares import inject_attachment_context
|
||||
|
||||
async def get_graph(self):
|
||||
graph = create_agent(
|
||||
model=load_chat_model("..."),
|
||||
tools=tools,
|
||||
middleware=[
|
||||
inject_attachment_context, # 添加附件中间件
|
||||
context_aware_prompt, # 其他中间件...
|
||||
# ...
|
||||
],
|
||||
checkpointer=await self._get_checkpointer(),
|
||||
)
|
||||
return graph
|
||||
```
|
||||
|
||||
##### 工作流程
|
||||
|
||||
1. **前端上传**:用户在聊天界面上传文档(txt、md、docx、html)
|
||||
2. **API 解析**:后端将文档转换为 Markdown 格式并存储到数据库(超过 32k 会被截断)
|
||||
3. **自动加载**:API 层在调用 agent 前从数据库加载附件数据
|
||||
4. **中间件注入**:`inject_attachment_context` 自动将附件内容注入为系统消息
|
||||
5. **模型处理**:LLM 接收到附件内容和用户问题,进行综合回答
|
||||
|
||||
这种设计确保了附件功能的可选性和可扩展性,任何智能体都可以通过添加中间件快速启用文件上传能力。
|
||||
|
||||
## 内置工具与 MCP 集成
|
||||
|
||||
系统会根据配置自动组装工具集合,涵盖知识图谱查询、向量检索生成的动态工具、MySQL 只读查询能力、Tavily 搜索以及所有注册的 MCP 工具。
|
||||
|
||||
MCP (Model Context Protocol) 服务的配置现已全面支持通过系统管理界面或 API 进行动态管理,数据持久化存储在数据库中。`src/services/mcp_service.py` 仅作为核心逻辑层和默认配置的存放处,不再建议直接修改代码来添加服务器。
|
||||
|
||||
### MCP 服务器管理
|
||||
|
||||
系统提供了完善的 API (`/system/mcp-servers`) 和管理界面来执行 MCP 服务器的增删改查操作。
|
||||
|
||||
#### 支持的传输协议
|
||||
|
||||
系统支持三种 MCP 传输协议:
|
||||
|
||||
1. **SSE (Server-Sent Events)**: 标准的 HTTP SSE 连接
|
||||
2. **Streamable HTTP**: 支持流式传输的 HTTP 连接(远程)
|
||||
3. **Stdio**: 通过标准输入/输出运行本地进程(支持 Python/Node.js 等)
|
||||
|
||||
#### 配置示例
|
||||
|
||||
以下是通过管理界面添加 MCP 服务器时的常见配置参数示例(对应 API 请求体):
|
||||
|
||||
##### 1. 远程 HTTP/SSE 服务器
|
||||
|
||||
* **Server Name**: `sequentialthinking`
|
||||
* **Transport**: `streamable_http` (或 `sse`)
|
||||
* **URL**: `https://remote.mcpservers.org/sequentialthinking/mcp`
|
||||
|
||||
**特点**:
|
||||
- 无需本地安装,适合公开可用的 MCP 服务
|
||||
- 启动速度快,无需本地依赖
|
||||
|
||||
##### 2. 使用 npx 运行 Node.js 包
|
||||
|
||||
* **Server Name**: `mcp-server-chart`
|
||||
* **Transport**: `stdio`
|
||||
* **Command**: `npx`
|
||||
* **Args**: `["-y", "@antv/mcp-server-chart"]`
|
||||
|
||||
**特点**:
|
||||
- 自动下载并运行 Node.js 包
|
||||
- 适合 Node.js 生态的 MCP 服务
|
||||
|
||||
##### 3. 使用 uvx 运行 Python 包
|
||||
|
||||
* **Server Name**: `mysql-mcp-server`
|
||||
* **Transport**: `stdio`
|
||||
* **Command**: `uvx`
|
||||
* **Args**: `["mysql_mcp_server"]`
|
||||
* **Environment Variables**:
|
||||
```json
|
||||
{
|
||||
"MYSQL_DATABASE": "your_database",
|
||||
"MYSQL_HOST": "localhost",
|
||||
"MYSQL_PASSWORD": "your_password",
|
||||
"MYSQL_PORT": "3306",
|
||||
"MYSQL_USER": "your_username"
|
||||
}
|
||||
```
|
||||
|
||||
**特点**:
|
||||
- 自动管理 Python 虚拟环境和依赖
|
||||
- 适合 PyPI 上已发布的 MCP 服务
|
||||
|
||||
##### 4. 使用 uv 运行本地仓库
|
||||
|
||||
* **Server Name**: `arxiv-mcp-server`
|
||||
* **Transport**: `stdio`
|
||||
* **Command**: `uv`
|
||||
* **Args**:
|
||||
```json
|
||||
[
|
||||
"tool",
|
||||
"run",
|
||||
"arxiv-mcp-server",
|
||||
"--storage-path", "src/agents/mcp_repos/arxiv-mcp-server"
|
||||
]
|
||||
```
|
||||
|
||||
**特点**:
|
||||
- 直接运行本地 git 仓库中的 MCP 服务
|
||||
- 支持热重载,适合开发调试
|
||||
|
||||
### 动态工具加载与管理
|
||||
|
||||
系统提供统一的 MCP 服务层 (`src/services/mcp_service.py`) 封装所有 MCP 相关操作。
|
||||
|
||||
#### 1. 智能体获取工具
|
||||
|
||||
智能体开发时,应使用 `get_enabled_mcp_tools()` 获取工具。该函数会自动根据数据库中的配置,过滤掉被禁用的工具。
|
||||
|
||||
```python
|
||||
from src.services.mcp_service import get_enabled_mcp_tools
|
||||
|
||||
# 获取指定服务器的工具(自动过滤掉在管理界面禁用的工具)
|
||||
tools = await get_enabled_mcp_tools("sequentialthinking")
|
||||
```
|
||||
|
||||
#### 2. 工具粒度控制
|
||||
|
||||
通过管理界面或 API (`PUT /system/mcp-servers/{name}/tools/{tool_name}/toggle`),管理员可以启用或禁用特定的 MCP 工具。禁用后的工具不会出现在 `get_enabled_mcp_tools` 的返回列表中,从而防止智能体调用不需要的能力。
|
||||
|
||||
#### 3. 默认服务器配置
|
||||
|
||||
系统首次启动时,会加载 `src/services/mcp_service.py` 中 `_DEFAULT_MCP_SERVERS` 定义的默认服务器(如 `sequentialthinking` 和 `mcp-server-chart`)到数据库中。后续的修改将以数据库为准。
|
||||
|
||||
### MySQL 数据库
|
||||
|
||||
在 数据库报表助手(SqlReporterAgent) 中,可以通过配置下面环境变量,让 Agent 能够连接到 MySQL 数据库。并通过执行 SQL 查询,获取数据库中的数据。
|
||||
|
||||
设置数据库连接时,在 `.env` 中提供以下字段:
|
||||
|
||||
```env
|
||||
MYSQL_HOST=192.168.1.100
|
||||
MYSQL_USER=username
|
||||
MYSQL_PASSWORD=your_secure_password
|
||||
MYSQL_DATABASE=database_name
|
||||
MYSQL_DATABASE_DESCRIPTION=业务主库(可选提示)
|
||||
MYSQL_PORT=3306
|
||||
MYSQL_CHARSET=utf8mb4
|
||||
```
|
||||
|
||||
所有查询限定在只读范围(SELECT、SHOW、DESCRIBE、EXPLAIN),请求会经过表名校验与超时控制,默认限制 60 秒与 100 行输出,并可通过配置调整上限。连接信息会反馈给 LangGraph,智能体可以自动陈述数据库用途并选择更准确的检索策略。详见代码部分 `src/agents/common/toolkits/mysql/`
|
||||
|
||||
### 多模态图片支持
|
||||
|
||||
系统支持接收图片作为输入,与文本结合形成多模态查询。图片支持的核心特性如下:
|
||||
|
||||
#### 1. 图片上传与处理
|
||||
- 通过 `/chat/image/upload` 接口上传图片
|
||||
- 自动处理图片格式转换和压缩
|
||||
- 返回 base64 编码的图片数据
|
||||
- 图片大小限制为 10MB
|
||||
- 支持的图片格式:JPEG、PNG、WebP、GIF、BMP
|
||||
- 自动压缩超过 5MB 的图片
|
||||
|
||||
当发送包含图片的请求时,消息格式为:
|
||||
```json
|
||||
{
|
||||
"query": "这张图片里有什么?",
|
||||
"image_content": "<base64编码的图片数据>",
|
||||
"config": {},
|
||||
"meta": {}
|
||||
}
|
||||
```
|
||||
|
||||
智能体会自动识别多模态消息并将其传递给支持图片的模型。如果模型不支持图片,会自动忽略图片内容,只处理文本部分。系统会将图片转换为符合模型要求的格式(通常是 base64 编码的 JPEG 或 PNG),确保与主流多模态模型兼容。
|
||||
|
||||
目前仅支持上传单个图片,图片以 base64 编码形式存储在数据库。系统会自动处理图片的格式转换和压缩,并生成缩略图以优化性能。
|
||||
|
||||
### 图片上传响应格式
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"image_content": "<base64编码的原始图片数据>",
|
||||
"thumbnail_content": "<base64编码的缩略图数据>",
|
||||
"width": 1024,
|
||||
"height": 768,
|
||||
"format": "JPEG",
|
||||
"mime_type": "image/jpeg"
|
||||
}
|
||||
```
|
||||
|
||||
系统会将图片信息与用户查询一同传递给支持多模态的模型,并自动适配模型要求的格式。
|
||||
@ -1,30 +1,29 @@
|
||||
# 品牌自定义
|
||||
|
||||
系统支持完整的品牌信息自定义,包括 Logo、组织名称、版权信息等。
|
||||
Yuxi-Know 支持完整的品牌自定义,包括 Logo、组织名称、版权信息等,方便企业用户进行品牌定制。
|
||||
|
||||
## 配置方法
|
||||
## 品牌信息配置
|
||||
|
||||
### 1. 复制模板文件
|
||||
### 步骤 1:复制模板文件
|
||||
|
||||
```bash
|
||||
cp src/config/static/info.template.yaml src/config/static/info.local.yaml
|
||||
```
|
||||
|
||||
### 2. 编辑品牌信息
|
||||
### 步骤 2:编辑品牌信息
|
||||
|
||||
在 `src/config/static/info.local.yaml` 中配置:
|
||||
在 `src/config/static/info.local.yaml` 中配置你的品牌信息:
|
||||
|
||||
<<< @/../src/config/static/info.template.yaml
|
||||
- 应用名称
|
||||
- 组织名称
|
||||
- Logo
|
||||
- 版权信息等
|
||||
|
||||
上述中提到的 ICON 预设了下面这些,如果需要更多的 ICONS,可以手动从 `lucide-vue-next` 中引入。
|
||||
### 步骤 3:指定配置文件
|
||||
|
||||
<<< @/../web/src/views/HomeView.vue#icon_mapping{js}
|
||||
在 `.env` 中指定配置文件路径:
|
||||
|
||||
### 3. 环境变量配置
|
||||
|
||||
在 `.env` 文件中指定配置文件路径:
|
||||
|
||||
```bash
|
||||
```env
|
||||
YUXI_BRAND_FILE_PATH=src/config/static/info.local.yaml
|
||||
```
|
||||
|
||||
@ -32,16 +31,13 @@ YUXI_BRAND_FILE_PATH=src/config/static/info.local.yaml
|
||||
`info.local.yaml` > `info.template.yaml`(默认)
|
||||
:::
|
||||
|
||||
### Icon 定制
|
||||
|
||||
系统预设了多种 Icon,如需更多图标,可以从 `lucide-vue-next` 中引入。
|
||||
|
||||
## 样式定制
|
||||
|
||||
系统配色主要保存在 `web/src/assets/css/base.css` 中:
|
||||
|
||||
- 替换 `--main-*` 相关变量即可改变配色
|
||||
- 支持主题色、辅助色等完整定制
|
||||
- 实时预览,无需重启服务
|
||||
|
||||
**主要变量**:
|
||||
系统支持完整的主题色定制。配置文件位于 `web/src/assets/css/base.css`:
|
||||
|
||||
```css
|
||||
:root {
|
||||
@ -50,11 +46,12 @@ YUXI_BRAND_FILE_PATH=src/config/static/info.local.yaml
|
||||
--main-900: #e6f7ff; /* 色板 */
|
||||
/* ... 其他色板 */
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
**此外**,`web/src/stores/theme.js` 中也包含了主题相关的配置(需要修改 `colorPrimary`),可根据需要修改。
|
||||
修改配色变量后,界面会实时更新,无需重启服务。
|
||||
|
||||
## 修改首页
|
||||
此外,`web/src/stores/theme.js` 中的 `colorPrimary` 也需要同步修改。
|
||||
|
||||
首页提供了一个插槽组件 `web/src/components/ProjectOverview.vue`,可以在该组件中自定义项目介绍,当前为空文件。(借助 AI 编程可以设计出更好看的首页的)
|
||||
## 首页定制
|
||||
|
||||
首页的「项目介绍」部分是一个插槽组件,位于 `web/src/components/ProjectOverview.vue`。可以根据需要自定义展示内容。
|
||||
|
||||
@ -1,34 +1,42 @@
|
||||
# 配置系统详解
|
||||
|
||||
## 概述
|
||||
Yuxi-Know 采用了现代化的配置管理系统,基于 Pydantic BaseModel 和 TOML 格式,提供了类型安全、智能提示和选择性持久化等特性。这套系统既满足了开发者对灵活配置的需求,又保证了运行时的稳定性。
|
||||
|
||||
Yuxi-Know 从 v0.3.x 版本开始采用了全新的配置系统,基于 Pydantic BaseModel 和 TOML 格式,提供了类型安全、智能提示和选择性持久化等现代化特性。
|
||||
## 设计理念
|
||||
|
||||
## 架构设计
|
||||
传统的配置文件往往存在以下问题:格式不统一、类型安全缺失、难以追踪哪些配置是用户修改过的。Yuxi-Know 的配置系统针对这些问题给出了解决方案:
|
||||
|
||||
### 配置层次结构
|
||||
- **类型安全**:基于 Pydantic,所有配置项都有明确的类型定义
|
||||
- **智能提示**:IDE 可以根据类型定义提供自动补全
|
||||
- **选择性持久化**:只保存用户修改过的配置,避免版本冲突
|
||||
- **多层覆盖**:代码默认值 → TOML 文件 → 环境变量,按优先级覆盖
|
||||
|
||||
## 配置层次
|
||||
|
||||
系统采用三层配置结构,每一层都有不同的优先级和适用场景:
|
||||
|
||||
```
|
||||
配置系统架构
|
||||
├── 默认配置 (代码定义)
|
||||
│ ├── src/config/static/models.py (模型配置)
|
||||
│ └── src/config/app.py (应用配置)
|
||||
├── 用户配置 (TOML 文件)
|
||||
│ └── saves/config/base.toml (仅保存用户修改)
|
||||
└── 环境变量 (运行时覆盖)
|
||||
└── .env 文件
|
||||
配置优先级(从低到高)
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
环境变量 (.env) → 最高优先级,用于运行时覆盖
|
||||
用户配置 (TOML) → 持久化的用户修改
|
||||
代码默认值 → 最低优先级,定义在 Python 代码中
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
```
|
||||
|
||||
### 核心组件
|
||||
### 各层作用
|
||||
|
||||
#### 1. Config 类 (`src/config/app.py`)
|
||||
1. **代码默认值**:定义在 `src/config/app.py` 和 `src/config/static/models.py` 中,提供所有配置项的初始值
|
||||
|
||||
主配置类,继承自 Pydantic BaseModel,提供:
|
||||
2. **用户配置**:保存在 `saves/config/base.toml`,只包含用户实际修改过的配置项。这种设计的好处是:当代码更新添加了新配置项时,不会被用户的旧配置文件覆盖
|
||||
|
||||
- **类型验证**: 自动检查配置项类型
|
||||
- **默认值管理**: 内置合理的默认配置
|
||||
- **选择性持久化**: 仅保存用户修改的配置项
|
||||
- **向后兼容**: 支持旧的字典式访问方式
|
||||
3. **环境变量**:适用于容器化部署场景,可以方便地在启动时覆盖任意配置项
|
||||
|
||||
## 核心组件
|
||||
|
||||
### 应用配置
|
||||
|
||||
主配置类 `Config` 继承自 Pydantic BaseModel:
|
||||
|
||||
```python
|
||||
class Config(BaseModel):
|
||||
@ -37,67 +45,13 @@ class Config(BaseModel):
|
||||
enable_content_guard: bool = Field(default=False, description="是否启用内容审查")
|
||||
|
||||
# 模型配置
|
||||
default_model: str = Field(default="siliconflow/deepseek-ai/DeepSeek-V3.2")
|
||||
default_model: str = Field(default="siliconflow/Pro/deepseek-ai/DeepSeek-V3.2")
|
||||
embed_model: str = Field(default="siliconflow/BAAI/bge-m3")
|
||||
|
||||
# 运行时状态 (不持久化)
|
||||
model_provider_status: dict[str, bool] = Field(exclude=True)
|
||||
```
|
||||
|
||||
#### 2. 模型配置类 (`src/config/static/models.py`)
|
||||
### 模型配置
|
||||
|
||||
定义了三种类型的模型配置:
|
||||
|
||||
- **ChatModelProvider**: 聊天模型提供商
|
||||
- **EmbedModelInfo**: 嵌入模型信息
|
||||
- **RerankerInfo**: 重排序模型信息
|
||||
|
||||
```python
|
||||
class ChatModelProvider(BaseModel):
|
||||
name: str = Field(..., description="提供商显示名称")
|
||||
url: str = Field(..., description="提供商文档或模型列表 URL")
|
||||
base_url: str = Field(..., description="API 基础 URL")
|
||||
default: str = Field(..., description="默认模型名称")
|
||||
env: str = Field(..., description="API Key 环境变量名")
|
||||
models: list[str] = Field(default_factory=list, description="支持的模型列表")
|
||||
```
|
||||
|
||||
添加配置:
|
||||
|
||||
|
||||
```python
|
||||
# 1. 在 DEFAULT_CHAT_MODEL_PROVIDERS 中添加
|
||||
"new-provider": ChatModelProvider(
|
||||
name="新提供商",
|
||||
url="https://provider.com/docs",
|
||||
base_url="https://api.provider.com/v1",
|
||||
default="default-model",
|
||||
env="NEW_PROVIDER_API_KEY",
|
||||
models=["model1", "model2"],
|
||||
),
|
||||
|
||||
# 2. 在 .env 中配置 API Key
|
||||
# NEW_PROVIDER_API_KEY=your_api_key
|
||||
|
||||
# 3. 重启服务或重新加载配置
|
||||
```
|
||||
|
||||
## 配置管理特性
|
||||
|
||||
系统只会保存用户修改过的配置项:
|
||||
|
||||
```python
|
||||
# 用户只修改了 enable_reranker
|
||||
config.enable_reranker = True
|
||||
config.save() # 只保存 enable_reranker 到 TOML 文件
|
||||
|
||||
# TOML 文件内容
|
||||
# enable_reranker = true
|
||||
```
|
||||
|
||||
### 默认模型配置 (`src/config/static/models.py`)
|
||||
|
||||
包含所有支持的模型提供商的默认配置,开发者可以直接修改此文件添加新的模型:
|
||||
模型配置独立管理,支持多种模型提供商:
|
||||
|
||||
```python
|
||||
DEFAULT_CHAT_MODEL_PROVIDERS: dict[str, ChatModelProvider] = {
|
||||
@ -107,72 +61,102 @@ DEFAULT_CHAT_MODEL_PROVIDERS: dict[str, ChatModelProvider] = {
|
||||
base_url="https://api.siliconflow.cn/v1",
|
||||
default="deepseek-ai/DeepSeek-V3.2",
|
||||
env="SILICONFLOW_API_KEY",
|
||||
models=[
|
||||
"deepseek-ai/DeepSeek-V3.2",
|
||||
"Qwen/Qwen3-235B-A22B-Instruct-2507",
|
||||
# ...
|
||||
],
|
||||
models=["deepseek-ai/DeepSeek-V3.2", "Qwen/Qwen3-235B-A22B-Instruct-2507"],
|
||||
),
|
||||
# 更多提供商...
|
||||
# 其他提供商...
|
||||
}
|
||||
```
|
||||
|
||||
### 用户配置 (`saves/config/base.toml`)
|
||||
## 使用指南
|
||||
|
||||
只包含用户修改过的配置项,使用 TOML 格式:
|
||||
|
||||
```toml
|
||||
# 用户只修改了这些配置项
|
||||
enable_reranker = true
|
||||
default_agent_id = "MyCustomAgent"
|
||||
enable_content_guard = true
|
||||
|
||||
# 模型配置修改
|
||||
[model_names.siliconflow]
|
||||
models = [
|
||||
"deepseek-ai/DeepSeek-V3.2",
|
||||
"custom-model-name",
|
||||
]
|
||||
```
|
||||
|
||||
## 高级配置
|
||||
|
||||
### 动态配置更新
|
||||
### 读取配置
|
||||
|
||||
```python
|
||||
from src.config import config
|
||||
|
||||
# 更新配置
|
||||
# 访问配置项
|
||||
model = config.default_model
|
||||
reranker_enabled = config.enable_reranker
|
||||
```
|
||||
|
||||
### 修改配置
|
||||
|
||||
```python
|
||||
from src.config import config
|
||||
|
||||
# 修改配置
|
||||
config.enable_reranker = True
|
||||
config.default_agent_id = "CustomAgent"
|
||||
config.default_model = "custom-model-name"
|
||||
|
||||
# 更新模型列表
|
||||
config.model_names["siliconflow"].models.append("new-model")
|
||||
|
||||
# 保存配置
|
||||
# 保存到 TOML 文件
|
||||
config.save()
|
||||
|
||||
# 或者只保存特定提供商的模型配置
|
||||
config._save_models_to_file("siliconflow")
|
||||
```
|
||||
|
||||
### 配置验证
|
||||
|
||||
```python
|
||||
# 验证配置
|
||||
from src.config import config
|
||||
|
||||
# 检查模型提供商可用性
|
||||
for provider, status in config.model_provider_status.items():
|
||||
print(f"{provider}: {'✅' if status else '❌'}")
|
||||
print(f"{provider}: {'可用' if status else '不可用'}")
|
||||
|
||||
# 获取可用模型列表
|
||||
available_models = config.get_model_choices()
|
||||
available_embed_models = config.get_embed_model_choices()
|
||||
available_rerankers = config.get_reranker_choices()
|
||||
models = config.get_model_choices()
|
||||
embed_models = config.get_embed_model_choices()
|
||||
```
|
||||
|
||||
### 配置导出
|
||||
## 添加新模型提供商
|
||||
|
||||
需要支持新的模型提供商时,按以下步骤操作:
|
||||
|
||||
### 步骤 1:添加提供商配置
|
||||
|
||||
在 `src/config/static/models.py` 的 `DEFAULT_CHAT_MODEL_PROVIDERS` 字典中添加新条目:
|
||||
|
||||
```python
|
||||
"new-provider": ChatModelProvider(
|
||||
name="新提供商",
|
||||
url="https://provider.com/docs",
|
||||
base_url="https://api.provider.com/v1",
|
||||
default="default-model",
|
||||
env="NEW_PROVIDER_API_KEY",
|
||||
models=["model1", "model2"],
|
||||
),
|
||||
```
|
||||
|
||||
### 步骤 2:配置 API Key
|
||||
|
||||
在 `.env` 文件中添加对应的环境变量:
|
||||
|
||||
```env
|
||||
NEW_PROVIDER_API_KEY=your_api_key_here
|
||||
```
|
||||
|
||||
### 步骤 3:重启服务
|
||||
|
||||
配置完成后,重启服务使配置生效。
|
||||
|
||||
## 高级特性
|
||||
|
||||
### 动态更新
|
||||
|
||||
配置可以动态修改,无需重启服务:
|
||||
|
||||
```python
|
||||
from src.config import config
|
||||
|
||||
# 更新单个配置项
|
||||
config.enable_reranker = True
|
||||
|
||||
# 更新模型列表
|
||||
config.model_names["siliconflow"].models.append("new-model")
|
||||
|
||||
# 保存修改
|
||||
config.save()
|
||||
```
|
||||
|
||||
### 导出配置
|
||||
|
||||
```python
|
||||
# 导出完整配置(包含运行时状态)
|
||||
@ -183,3 +167,46 @@ user_config = {
|
||||
field: getattr(config, field)
|
||||
for field in config._user_modified_fields
|
||||
}
|
||||
```
|
||||
|
||||
### 选择性持久化机制
|
||||
|
||||
系统会跟踪哪些配置项被修改过:
|
||||
|
||||
```python
|
||||
# 假设用户只修改了 enable_reranker
|
||||
config.enable_reranker = True
|
||||
config.save() # 只保存 enable_reranker 到 TOML 文件
|
||||
```
|
||||
|
||||
生成的 TOML 文件只包含修改过的项:
|
||||
|
||||
```toml
|
||||
enable_reranker = true
|
||||
```
|
||||
|
||||
这种设计的优势:
|
||||
- 用户升级程序时,新配置项会自动使用默认值
|
||||
- 避免配置文件版本冲突
|
||||
- 便于查看用户做了哪些自定义修改
|
||||
|
||||
## 常见问题
|
||||
|
||||
**Q:新增的配置项没有生效?**
|
||||
|
||||
A:请检查:
|
||||
1. 配置项名称是否正确拼写
|
||||
2. 环境变量是否正确设置(环境变量优先级最高)
|
||||
3. 是否需要重启服务
|
||||
|
||||
**Q:如何查看当前所有配置?**
|
||||
|
||||
A:访问 `/api/config` 接口或查看 `config.dump_config()` 的输出。
|
||||
|
||||
**Q:配置文件格式错误导致启动失败?**
|
||||
|
||||
A:可以删除 `saves/config/base.toml` 文件,让系统重新生成默认配置。
|
||||
|
||||
---
|
||||
|
||||
配置系统的设计遵循了「约定优于配置」的原则,大多数情况下使用默认值即可工作。当需要自定义行为时,只需要修改少量配置项即可。理解这套系统的层次结构和优先级,能够帮助你更好地控制和调试应用行为。
|
||||
|
||||
@ -1,55 +1,55 @@
|
||||
# 生产部署指南
|
||||
|
||||
本指南介绍了如何在生产环境中部署 Yuxi-Know。
|
||||
本文档介绍如何在生产环境中部署 Yuxi-Know。
|
||||
|
||||
## 前置要求
|
||||
|
||||
- **Docker Engine** (v24.0+)
|
||||
- **Docker Compose** (v2.20+)
|
||||
- **NVIDIA Container Toolkit** (如果在生产环境使用 GPU 服务)
|
||||
- Docker Engine (v24.0+)
|
||||
- Docker Compose (v2.20+)
|
||||
- NVIDIA Container Toolkit(如需使用 GPU 服务)
|
||||
|
||||
注意事项:
|
||||
|
||||
1. 生产环境和开发环境最好是两台独立的机器,不然会存在端口和资源的冲突问题。
|
||||
2. 虽然名为“生产环境”,但实际上只是做了一些基本的配置而已,真要上线业务,需要根据实际情况进行调整。
|
||||
3. 前端有个**调试面板**,长按侧边栏触发,生产环境不建议开启。
|
||||
::: warning 注意事项
|
||||
1. 生产环境和开发环境建议使用不同的机器,避免端口和资源冲突
|
||||
2. 虽然名为「生产环境」,但这只是基本配置,真正上线需要根据实际情况调整
|
||||
3. 前端有调试面板(长按侧边栏触发),生产环境建议关闭
|
||||
:::
|
||||
|
||||
## 部署步骤
|
||||
|
||||
### 1. 配置环境变量
|
||||
### 1. 准备配置文件
|
||||
|
||||
为了避免与开发环境的冲突,建议在生产环境中使用 `.env.prod` 文件。请确保你已经从模板创建了该文件并填写了必要的密钥。
|
||||
为避免与开发环境冲突,生产环境建议使用 `.env.prod` 文件:
|
||||
|
||||
```bash
|
||||
cp .env.template .env.prod
|
||||
```
|
||||
|
||||
编辑 `.env.prod` 文件,设置强密码并配置必要的 API 密钥:
|
||||
编辑 `.env.prod`,设置强密码和必要的 API 密钥:
|
||||
|
||||
- `NEO4J_PASSWORD`: 修改默认密码
|
||||
- `MINIO_ACCESS_KEY` / `MINIO_SECRET_KEY`: 修改默认密钥
|
||||
- `NEO4J_PASSWORD`:修改默认密码
|
||||
- `MINIO_ACCESS_KEY` / `MINIO_SECRET_KEY`:修改默认密钥
|
||||
- `SILICONFLOW_API_KEY` 等模型密钥
|
||||
|
||||
### 2. 启动服务
|
||||
|
||||
使用 `docker-compose.prod.yml` 文件启动生产环境:
|
||||
使用生产环境配置文件启动:
|
||||
|
||||
```bash
|
||||
# 仅启动核心服务 (CPU 模式)
|
||||
# 仅启动核心服务(CPU 模式)
|
||||
docker compose -f docker-compose.prod.yml up -d --build
|
||||
|
||||
# 启动所有服务 (包含 GPU OCR 服务)
|
||||
# 启动所有服务(包含 GPU OCR)
|
||||
docker compose -f docker-compose.prod.yml --profile all up -d --build
|
||||
```
|
||||
|
||||
### 3. 验证部署
|
||||
|
||||
- **Web 访问**: `http://localhost` (直接通过 80 端口访问,无需 :5173)
|
||||
- **API 健康检查**: `curl http://localhost/api/system/health`
|
||||
- Web 访问:http://localhost(直接通过 80 端口)
|
||||
- API 健康检查:`curl http://localhost/api/system/health`
|
||||
|
||||
## 维护与更新
|
||||
|
||||
### 更新代码并重新部署
|
||||
### 更新代码
|
||||
|
||||
```bash
|
||||
# 拉取最新代码
|
||||
@ -62,9 +62,9 @@ docker compose -f docker-compose.prod.yml up -d --build
|
||||
### 查看日志
|
||||
|
||||
```bash
|
||||
# 查看 API 日志
|
||||
# API 日志
|
||||
docker logs -f api-prod
|
||||
|
||||
# 查看 Nginx 访问日志
|
||||
# Nginx 访问日志
|
||||
docker logs -f web-prod
|
||||
```
|
||||
|
||||
@ -1,159 +1,126 @@
|
||||
# 文档处理与 OCR
|
||||
|
||||
系统提供 5 种文档处理选项:
|
||||
|
||||
- **RapidOCR**: CPU 友好,无需 GPU,适合基础文字识别
|
||||
- **MinerU**: 本地化高精度 VLM 解析,适合复杂 PDF 和表格文档
|
||||
- **MinerU Official**: 官方云服务 API,无需本地部署,开箱即用
|
||||
- **PP-StructureV3**: 结构化解析,适合表格、票据等特殊格式
|
||||
- **DeepSeek OCR**: 基于 SiliconFlow API 的 DeepSeek OCR OCR 服务
|
||||
Yuxi-Know 支持多种文档格式的智能解析,从简单的文本文件到复杂的 PDF 文档,都能自动提取内容并转换为可检索的格式。
|
||||
|
||||
## 支持的文件类型
|
||||
|
||||
### 常规文档格式
|
||||
- **文本文档**: `.txt`, `.md`, `.html`, `.htm`
|
||||
- **Word 文档**: `.docx`
|
||||
- **PDF 文档**: `.pdf`
|
||||
- **电子表格**: `.csv`, `.xls`, `.xlsx`
|
||||
- **JSON 数据**: `.json`
|
||||
### 常规文档
|
||||
|
||||
::: tip 图片显示
|
||||
文档中的图片会自动上传到对象存储并替换为可访问的 URL。但是如果想要在外部正常显示图片,需要配置 `HOST_IP` 环境变量,将其设置为您的服务器 IP 地址。
|
||||
:::
|
||||
| 类型 | 格式 | 说明 |
|
||||
|------|------|------|
|
||||
| 文本 | .txt, .md, .html | 直接提取内容 |
|
||||
| Word | .docx | 保留格式和结构 |
|
||||
| PDF | .pdf | 支持文本和图片 PDF |
|
||||
| 表格 | .csv, .xls, .xlsx | 识别表格结构 |
|
||||
| JSON | .json | 结构化数据 |
|
||||
|
||||
### 图像格式(需要 OCR)
|
||||
- **常见图片**: `.jpg`, `.jpeg`, `.png`, `.bmp`, `.tiff`, `.tif`, `.gif`, `.webp`
|
||||
### 图片文件
|
||||
|
||||
### ZIP 压缩包
|
||||
- **ZIP 文档**: `.zip` - 支持包含 Markdown 文件和图片的压缩包
|
||||
- 自动提取和处理 ZIP 包中的 `.md` 文件
|
||||
- 自动处理 ZIP 包中的图片文件并上传到对象存储(MINIO)
|
||||
- 图片链接会自动替换为可访问的 URL
|
||||
- 优先处理名为 `full.md` 的文件,否则使用第一个 `.md` 文件
|
||||
- 支持图片目录的智能识别(`images/`、`../images/` 等)
|
||||
对于图片文件,需要启用 OCR 才能提取文字:
|
||||
- .jpg, .jpeg, .png, .bmp, .tiff, .tif, .gif, .webp
|
||||
|
||||
### URL 网页内容
|
||||
- **网页链接**: `http://` 或 `https://`
|
||||
- 自动抓取网页 HTML 内容并转换为 Markdown
|
||||
- **白名单机制**: 出于安全考虑,必须配置环境变量 `YUXI_URL_WHITELIST` 才能使用此功能
|
||||
- **内网保护**: 默认禁止抓取私有 IP 地址(如 127.0.0.1, 192.168.x.x)
|
||||
- **去重机制**: 自动检测 URL 是否已存在,以及内容 Hash 是否重复
|
||||
### 压缩包
|
||||
|
||||
支持上传 ZIP 压缩包,系统会:
|
||||
- 自动提取并处理其中的 Markdown 文件
|
||||
- 处理图片并上传到对象存储
|
||||
- 智能识别 `full.md` 或第一个 `.md` 文件
|
||||
|
||||
### 网页内容
|
||||
|
||||
支持通过 URL 直接抓取网页内容:
|
||||
|
||||
1. 配置 `YUXI_URL_WHITELIST` 环境变量启用白名单机制
|
||||
2. 系统自动将 HTML 转换为 Markdown
|
||||
3. 内置去重机制,避免重复抓取
|
||||
|
||||
::: tip URL 白名单配置
|
||||
在 `.env` 文件中配置允许抓取的域名列表,用逗号分隔。支持通配符。
|
||||
例如:`YUXI_URL_WHITELIST=github.com,*.wikipedia.org,docs.python.org`
|
||||
示例:`YUXI_URL_WHITELIST=github.com,*.wikipedia.org,docs.python.org`
|
||||
:::
|
||||
|
||||
## OCR 方案选择
|
||||
|
||||
系统提供多种 OCR 方案,适用于不同场景:
|
||||
|
||||
### 方案对比
|
||||
|
||||
| 方案 | 适用场景 | 硬件要求 | 特点 |
|
||||
|------|----------|----------|------|
|
||||
| RapidOCR | 基础文字识别 | CPU | 免费开源,速度快 |
|
||||
| MinerU | 复杂 PDF、表格 | GPU | 精度高,版面分析好 |
|
||||
| MinerU Official | 复杂文档 | 无 | 官方云服务,开箱即用 |
|
||||
| PP-StructureV3 | 表格、票据 | GPU | 专业版面解析 |
|
||||
| DeepSeek OCR | 智能理解 | 无 | 云端服务,Markdown 输出 |
|
||||
|
||||
### 选择建议
|
||||
|
||||
- **个人使用或 CPU 环境**:选择 RapidOCR,免费且资源占用低
|
||||
- **高精度需求**:选择 MinerU(需要 GPU)或 MinerU Official
|
||||
- **表格密集型文档**:选择 PP-StructureV3
|
||||
- **简单云服务**:选择 DeepSeek OCR
|
||||
|
||||
## 快速配置
|
||||
|
||||
### 1. 基础 OCR (RapidOCR)
|
||||
### RapidOCR(推荐入门)
|
||||
|
||||
```bash
|
||||
# 下载模型
|
||||
hf download SWHL/RapidOCR --local-dir ./models/SWHL/RapidOCR
|
||||
|
||||
# 配置环境变量
|
||||
MODEL_DIR=./models
|
||||
|
||||
# 启动服务
|
||||
docker compose up -d api
|
||||
```
|
||||
|
||||
需要确保 `MODEL_DIR` 环境变量指向 RapidOCR 上层目录,例如 `./models`。
|
||||
### MinerU(高精度)
|
||||
|
||||
### 2. 高精度 OCR (MinerU)
|
||||
```env
|
||||
# .env 配置
|
||||
MINERU_VL_SERVER=http://localhost:30000
|
||||
MINERU_API_URI=http://localhost:30001
|
||||
|
||||
需要在 `.env` 文件中配置:
|
||||
|
||||
```bash
|
||||
MINERU_VL_SERVER=http://localhost:30000 # 对应 docker compose 中的 mineru-vllm-server 服务
|
||||
MINERU_API_URI=http://localhost:30001 # 对应 docker compose 中的 mineru-api 服务
|
||||
```
|
||||
|
||||
然后启动相关服务
|
||||
|
||||
```bash
|
||||
# 需要 GPU,启动 MinerU 服务
|
||||
# 启动服务(需要 GPU)
|
||||
docker compose up mineru-vllm-server mineru-api -d
|
||||
|
||||
# 启动主服务
|
||||
docker compose up api -d
|
||||
```
|
||||
|
||||
::: tip 处理超时
|
||||
文档解析超时时间默认 1800 秒,可通过 `MINERU_TIMEOUT` 环境变量调整。
|
||||
:::
|
||||
### MinerU Official(云服务)
|
||||
|
||||
```env
|
||||
# .env 配置
|
||||
MINERU_API_KEY=your-api-key-here
|
||||
```
|
||||
|
||||
### 3. 官方云服务 (MinerU Official)
|
||||
从 [MinerU 官网](https://mineru.net) 获取 API 密钥。
|
||||
|
||||
API 密钥可以从 [MinerU 官网](https://mineru.net) 申请。
|
||||
|
||||
然后在 `.env` 文件中添加
|
||||
### PP-StructureV3(结构化)
|
||||
|
||||
```bash
|
||||
# 设置 API 密钥环境变量
|
||||
MINERU_API_KEY="your-api-key-here"
|
||||
# 启动服务(需要 GPU)
|
||||
docker compose up paddlex -d
|
||||
```
|
||||
|
||||
然后使用 `docker compose up api -d` 重启后端服务。
|
||||
### DeepSeek OCR(简单云服务)
|
||||
|
||||
### 4. 结构化解析 (PP-StructureV3)
|
||||
|
||||
```bash
|
||||
# 需要 GPU,启动 PP-StructureV3 服务
|
||||
docker compose up -d paddlex
|
||||
|
||||
# 启动主服务
|
||||
docker compose up -d api
|
||||
```env
|
||||
# .env 配置(使用已有的 SiliconFlow 密钥)
|
||||
SILICONFLOW_API_KEY=your-api-key-here
|
||||
```
|
||||
|
||||
### 5. DeepSeek OCR (SiliconFlow)
|
||||
## 图片显示配置
|
||||
|
||||
DeepSeek OCR 基于 SiliconFlow API,提供智能文档理解和 Markdown 格式输出。
|
||||
上传文档中的图片需要正确配置才能在外部显示:
|
||||
|
||||
API 密钥可以从 [SiliconFlow](https://cloud.siliconflow.cn/i/Eo5yTHGJ) 申请。
|
||||
在 `.env` 中设置服务器 IP:
|
||||
|
||||
然后在 `.env` 文件中添加:
|
||||
|
||||
```bash
|
||||
# 设置 SiliconFlow API 密钥
|
||||
SILICONFLOW_API_KEY="your-api-key-here"
|
||||
```env
|
||||
HOST_IP=your_server_ip
|
||||
```
|
||||
|
||||
重启后端服务即可使用:
|
||||
## 注意事项
|
||||
|
||||
```bash
|
||||
docker compose restart api
|
||||
```
|
||||
|
||||
注:当前还不支持保存其中的图片信息,mineru 当前版本已支持。
|
||||
|
||||
## 处理器选择
|
||||
|
||||
| 处理器 | 适用场景 | 硬件要求 | 特点 |
|
||||
|--------|----------|------------|------|
|
||||
| **RapidOCR** | 基础文字识别 | CPU | 速度快,资源占用低 |
|
||||
| **MinerU** | 复杂 PDF、表格、公式 | GPU | 精度高,版面分析好 |
|
||||
| **MinerU Official** | 复杂文档解析(云服务) | 无特殊要求 | 官方云服务,开箱即用,有 API 配额 |
|
||||
| **PP-StructureV3** | 表格、票据、结构化文档 | GPU | 专业版面解析 |
|
||||
| **DeepSeek OCR** | 智能文档理解和 Markdown 输出 | 无特殊要求 | 云端服务 |
|
||||
|
||||
## 参数说明
|
||||
|
||||
### enable_ocr 选项
|
||||
|
||||
对应网页中的 `使用 OCR` 选项
|
||||
|
||||
- `disable`: 不启用 OCR(PDF 按文本提取,图片**必须选择 OCR 方式**)
|
||||
- `onnx_rapid_ocr`: RapidOCR 处理
|
||||
- `mineru_ocr`: MinerU HTTP API 处理
|
||||
- `mineru_official`: MinerU 官方云服务 API 处理
|
||||
- `paddlex_ocr`: PP-StructureV3 处理
|
||||
- `deepseek_ocr`: DeepSeek OCR(SiliconFlow API)处理
|
||||
|
||||
### 注意事项
|
||||
- **图片文件必须启用 OCR**,否则无法提取内容
|
||||
- MinerU 和 PP-StructureV3 需要 GPU 支持
|
||||
- MinerU Official 需要设置 `MINERU_API_KEY` 环境变量
|
||||
- DeepSeek OCR 需要设置 `SILICONFLOW_API_KEY` 环境变量
|
||||
- RapidOCR 适合 CPU 环境和基础识别需求
|
||||
1. **图片文件必须启用 OCR**:否则无法提取内容
|
||||
2. **GPU 要求**:MinerU 和 PP-StructureV3 需要 GPU 支持
|
||||
3. **API 密钥**:部分服务需要额外的 API 密钥配置
|
||||
4. **超时处理**:复杂文档解析可能耗时较长,可通过 `MINERU_TIMEOUT` 环境变量调整超时时间
|
||||
|
||||
@ -1,54 +1,83 @@
|
||||
# 其他配置
|
||||
|
||||
本文档介绍 Yuxi-Know 的其他配置选项,包括内容安全、网页搜索和服务端口等。
|
||||
|
||||
## 内容安全
|
||||
|
||||
系统内置内容审查机制(默认是关闭状态),保障服务内容的合规性。目前配置了关键词过滤以及 LLM 对内容进行审查。管理员可在 `设置` → `基本设置` 页面中进行配置并选择安全模型。
|
||||
系统内置内容审查机制,帮助保障服务内容的合规性。
|
||||
|
||||
检测流程为,接收到用户输入之后,就对用户的输入进行检测是否合规,同时在流式传输的过程中进行实时检测(仅关键词)。当流式输出结束之后,则开始检测整个内容。
|
||||
**注意**,使用 LLM 检测虽然可以大大缓解提示词注入带来的问题,但也会在用户交互上带来延迟影响,需要考虑是否启用。
|
||||
### 启用方式
|
||||
|
||||
对于关键词检测,敏感词词库位于 `src/config/static/bad_keywords.txt` 文件,每行一个关键词,实时生效,无需重启服务。
|
||||
在「系统设置」→「基本设置」页面中配置,可选择启用关键词过滤和 LLM 内容审查。
|
||||
|
||||
对于 LLM 检测,Prompt 可以看到 `src/plugins/guard.py`:
|
||||
### 检测流程
|
||||
|
||||
<<< @/../src/plugins/guard.py#guard_prompt
|
||||
系统会在以下时机进行检测:
|
||||
|
||||
1. **用户输入检测**:接收到用户消息后立即检测
|
||||
2. **流式输出检测**:实时检测输出的关键词(仅关键词模式)
|
||||
3. **输出完成检测**:流式输出结束后进行全面检测
|
||||
|
||||
### 检测模式
|
||||
|
||||
**关键词检测**
|
||||
|
||||
敏感词库位于 `src/config/static/bad_keywords.txt`,每行一个关键词。修改后实时生效,无需重启服务。
|
||||
|
||||
**LLM 检测**
|
||||
|
||||
使用大模型对内容进行审查,可以更好地识别提示词注入等复杂问题,但会增加响应延迟。
|
||||
|
||||
::: warning 性能考虑
|
||||
LLM 检测会增加用户交互的延迟,请根据实际需求选择是否启用。
|
||||
:::
|
||||
|
||||
## 网页搜索
|
||||
|
||||
系统内置了基于 Tavily 的联网搜索能力,配置完成后,大模型会自动在需要时调用对应的工具,为回答提供实时网页信息。
|
||||
系统集成了 Tavily 联网搜索能力,让大模型能够获取实时网页信息。
|
||||
|
||||
### 配置步骤
|
||||
|
||||
1. 前往 [Tavily 官网](https://app.tavily.com/) 注册并在控制台创建 API Key。
|
||||
2. 在项目根目录的 `.env`(或 `docker-compose.yml` 中的对应环境变量段)写入:
|
||||
1. 访问 [Tavily 官网](https://app.tavily.com/) 注册并创建 API Key
|
||||
2. 在 `.env` 文件中添加:
|
||||
```env
|
||||
TAVILY_API_KEY=sk-xxxxxxxxxxxxxxxx
|
||||
```
|
||||
3. 重新加载服务使密钥生效,推荐执行:
|
||||
3. 重启服务:
|
||||
```bash
|
||||
docker compose up -d api-dev web-dev
|
||||
```
|
||||
若服务已运行,则使用 `docker compose restart api-dev` 即可。
|
||||
|
||||
完成以上步骤后,在智能体的工具配置区域即可看到这个工具,展示 Tavily 返回的实时结果。若需要关闭该能力,删除或清空 `TAVILY_API_KEY` 后再次重启服务即可。
|
||||
### 使用方式
|
||||
|
||||
配置完成后,在智能体的工具配置区域会看到 Tavily 搜索工具。模型会自动判断何时需要调用搜索来获取最新信息。
|
||||
|
||||
如需关闭,删除或清空 `TAVILY_API_KEY` 后重启服务即可。
|
||||
|
||||
## 服务端口
|
||||
|
||||
系统使用多个端口提供不同服务,以下是完整的端口映射:
|
||||
系统各服务通过以下端口提供访问:
|
||||
|
||||
| 端口 | 服务 | 容器名称 | 说明 |
|
||||
|------|------|----------|------|
|
||||
| **5173** | Web 前端 | web-dev | 用户界面 |
|
||||
| **5050** | API 后端 | api-dev | 核心服务 |
|
||||
| **7474/7687** | Neo4j | graph | 图数据库 |
|
||||
| **9000/9001** | MinIO | milvus-minio | 对象存储 |
|
||||
| **19530/9091** | Milvus | milvus | 向量数据库 |
|
||||
| **5432** | postgres | postgres | PostgreSQL 数据库 |
|
||||
| **30000** | MinerU | mineru | PDF 解析(可选)|
|
||||
| **8080** | PP-StructureV3 | paddlex-ocr | OCR 服务(可选)|
|
||||
| **8081** | vLLM | - | 本地推理(可选)|
|
||||
| 端口 | 服务 | 说明 |
|
||||
|------|------|------|
|
||||
| 5173 | Web 前端 | 用户界面 |
|
||||
| 5050 | API 后端 | 核心服务接口 |
|
||||
| 7474 | Neo4j HTTP | 图数据库管理界面 |
|
||||
| 7687 | Neo4j Bolt | 图数据库连接 |
|
||||
| 9000/9001 | MinIO | 对象存储 |
|
||||
| 19530/9091 | Milvus | 向量数据库 |
|
||||
| 5432 | PostgreSQL | 业务数据库 |
|
||||
|
||||
::: tip 端口访问
|
||||
- Web 界面: `http://localhost:5173`
|
||||
- API 文档: `http://localhost:5050/docs`
|
||||
- Neo4j 管理: `http://localhost:7474`
|
||||
:::
|
||||
### 可选服务端口
|
||||
|
||||
| 端口 | 服务 | 说明 |
|
||||
|------|------|------|
|
||||
| 30000 | MinerU | PDF 解析服务 |
|
||||
| 8080 | PP-StructureV3 | OCR 服务 |
|
||||
| 8081 | vLLM | 本地推理服务 |
|
||||
|
||||
### 快速访问
|
||||
|
||||
- Web 界面:http://localhost:5173
|
||||
- API 文档:http://localhost:5050/docs
|
||||
- Neo4j 管理:http://localhost:7474
|
||||
|
||||
@ -1,86 +0,0 @@
|
||||
# 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` 才生效。
|
||||
|
||||
## 依赖类型说明
|
||||
|
||||
每个 skill 支持三类依赖,均在 Skills 管理页维护:
|
||||
|
||||
1. `tool_dependencies`:该 skill 需要的内置工具名列表。
|
||||
2. `mcp_dependencies`:该 skill 需要的 MCP 服务器名列表。
|
||||
3. `skill_dependencies`:该 skill 依赖的其他 skill slug 列表。
|
||||
|
||||
约束与语义:
|
||||
|
||||
1. 依赖在保存时做合法性校验,不允许引用不存在的工具/MCP/skill。
|
||||
2. `skill_dependencies` 不允许包含自身。
|
||||
3. `skill_dependencies` 按递归闭包生效,自动去重、去环、保序。
|
||||
|
||||
## 渐进式加载流程
|
||||
|
||||
系统不会在会话开始时一次性加载全部依赖,而是按阶段渐进加载:
|
||||
|
||||
### 阶段 1:会话启动前(构建 skill 可见集)
|
||||
|
||||
1. 读取 `context.skills` 作为用户显式选择的 skills(selected)。
|
||||
2. `SkillResolver` 递归展开 `skill_dependencies`,得到 `visible_skills`(selected + 依赖闭包)。
|
||||
3. 把快照写入 `runtime.context.skill_session_snapshot`。
|
||||
4. 基于 `visible_skills` 构建 skills prompt 段,并在 `abefore_agent` 预拼接到 `system_prompt`。
|
||||
5. `/skills` 只挂载 `visible_skills`,所以被依赖 skill 从会话首轮起即可被读取。
|
||||
|
||||
结论:`skill_dependencies` 是“会话启动即生效”的。
|
||||
|
||||
### 阶段 2:技能激活时(按需激活)
|
||||
|
||||
1. Agent 通过 `read_file` 读取 `/skills/<slug>/SKILL.md` 时,视为激活该 skill。
|
||||
2. 仅当 `<slug>` 在 `skill_session_snapshot.visible_skills` 内,激活才被接受。
|
||||
3. 激活结果写入 `activated_skills`(去重保序)。
|
||||
|
||||
结论:只有“真正被读取并使用”的 skill 才会进入后续依赖注入计算。
|
||||
|
||||
### 阶段 3:后续模型轮次(注入工具与 MCP 依赖)
|
||||
|
||||
1. 在 `awrap_model_call` 中,基于 `activated_skills` 计算依赖闭包。
|
||||
2. 聚合闭包内 skill 的 `tool_dependencies` 与 `mcp_dependencies`。
|
||||
3. 仅把这些依赖工具/MCP 合并进本轮可用工具集。
|
||||
|
||||
结论:`tool_dependencies` 与 `mcp_dependencies` 是“激活后按需加载”的,不会在会话首轮全量注入。
|
||||
146
docs/latest/agents/agents-config.md
Normal file
146
docs/latest/agents/agents-config.md
Normal file
@ -0,0 +1,146 @@
|
||||
# 智能体开发指南
|
||||
|
||||
Yuxi-Know 的智能体系统基于 LangGraph 构建,提供了灵活而强大的 Agent 开发能力。通过统一的 `AgentManager`,系统能够自动发现和管理所有智能体,让开发者能够专注于业务逻辑的实现。
|
||||
|
||||
## 智能体架构
|
||||
|
||||
### 核心概念
|
||||
|
||||
系统的智能体架构围绕几个核心组件展开:
|
||||
|
||||
- **BaseAgent**:所有智能体的基类,定义了统一的接口规范
|
||||
- **AgentContext**:智能体的配置上下文,包含模型、提示词、工具等配置
|
||||
- **Graph**:LangGraph 图结构,定义智能体的执行流程
|
||||
- **Middleware**:中间件系统,用于扩展和定制智能体行为
|
||||
|
||||
### 自动发现机制
|
||||
|
||||
智能体采用自动发现模式。在 `src/agents/__init__.py` 中,系统会遍历 `src/agents` 目录,自动注册所有继承自 `BaseAgent` 的类。这意味着开发者只需要按照规范编写代码,智能体就会自动被系统识别,无需手动配置。
|
||||
|
||||
仓库预置了几个可以直接使用的智能体示例:
|
||||
|
||||
- **chatbot**:通用对话智能体,支持动态工具调度
|
||||
- **reporter**:报表生成智能体,演示多工具协作
|
||||
- **deep_agent**:深度分析智能体,支持复杂推理任务
|
||||
|
||||
这些示例展示了如何组织代码结构、如何定义上下文、如何组合中间件,新增智能体时可以作为参考。
|
||||
|
||||
## 创建自定义智能体
|
||||
|
||||
### 目录结构
|
||||
|
||||
在 `src/agents` 目录下创建新的智能体包,建议保持以下结构:
|
||||
|
||||
```
|
||||
src/agents/
|
||||
└── my_agent/
|
||||
├── __init__.py # 暴露主类
|
||||
├── graph.py # Graph 构造逻辑
|
||||
└── metadata.toml # 元数据配置(可选)
|
||||
```
|
||||
|
||||
### 基本实现
|
||||
|
||||
智能体类需要继承 `BaseAgent` 并实现异步的 `get_graph` 方法:
|
||||
|
||||
```python
|
||||
from src.agents.common import BaseAgent
|
||||
from langgraph.prebuilt import create_agent
|
||||
|
||||
class MyAgent(BaseAgent):
|
||||
async def get_graph(self, **kwargs):
|
||||
# 获取配置上下文
|
||||
context = self.get_context()
|
||||
|
||||
# 获取工具列表
|
||||
tools = await get_tools_from_context(context)
|
||||
|
||||
# 构建 LangGraph 图
|
||||
graph = create_agent(
|
||||
model=load_chat_model(context.model),
|
||||
tools=tools,
|
||||
checkpointer=await self._get_checkpointer(),
|
||||
)
|
||||
|
||||
return graph
|
||||
```
|
||||
|
||||
### 能力配置
|
||||
|
||||
`capabilities` 属性用于声明智能体的前端能力,控制 UI 组件的显示:
|
||||
|
||||
```python
|
||||
class MyAgent(BaseAgent):
|
||||
capabilities = ["file_upload", "files", "todo"] # 支持文件上传、文件管理、待办事项
|
||||
```
|
||||
|
||||
**可用能力:**
|
||||
|
||||
| capability | 说明 | 前端效果 |
|
||||
|------------|------|----------|
|
||||
| `file_upload` | 文件上传 | 显示上传按钮 |
|
||||
| `files` | 文件管理 | 显示文件管理面板 |
|
||||
| `todo` | 待办事项 | 显示待办组件 |
|
||||
|
||||
**示例:**
|
||||
|
||||
```python
|
||||
# 只需要文件上传能力
|
||||
capabilities = ["file_upload"]
|
||||
|
||||
# 需要文件上传和待办事项
|
||||
capabilities = ["file_upload", "todo"]
|
||||
|
||||
# 全部能力
|
||||
capabilities = ["file_upload", "files", "todo"]
|
||||
```
|
||||
|
||||
注意:即使启用了能力,也需要在中间件中正确配置对应的处理逻辑,功能才能正常工作。例如启用 `file_upload` 需要配合 `inject_attachment_context` 中间件。
|
||||
|
||||
### 配置文件
|
||||
|
||||
可以通过 `metadata.toml` 定义智能体的元数据:
|
||||
|
||||
```toml
|
||||
name = "我的智能体"
|
||||
description = "这是一个示例智能体"
|
||||
examples = [
|
||||
"帮我写一首诗",
|
||||
"解释一下量子计算",
|
||||
]
|
||||
```
|
||||
|
||||
这些信息会在前端界面展示,帮助用户了解每个智能体的用途。
|
||||
|
||||
## 相关主题
|
||||
|
||||
- [上下文配置](./context-config.md) - BaseContext 和自定义配置
|
||||
- [工具系统](./tools-system.md) - 工具获取机制和 Skills 集成
|
||||
- [中间件系统](./middleware.md) - 中间件开发与使用
|
||||
- [MCP 集成](./mcp-integration.md) - MCP 服务器配置
|
||||
|
||||
## 开发建议
|
||||
|
||||
### 代码组织
|
||||
|
||||
- 将智能体的核心逻辑放在 `graph.py` 中
|
||||
- 复杂的工具逻辑单独放在 `toolkits` 目录下
|
||||
- 共享的组件放在 `common` 目录下
|
||||
|
||||
### 热重载
|
||||
|
||||
在容器环境中,修改代码后会自动触发热重载。如果需要强制刷新,可以调用:
|
||||
|
||||
```python
|
||||
agent_manager.get_agent(<agent_id>, reload=True)
|
||||
```
|
||||
|
||||
### 调试技巧
|
||||
|
||||
1. 使用前端的「调试面板」查看详细的请求和响应
|
||||
2. 查看后端日志:`docker logs api-dev -f`
|
||||
3. 利用 LangGraph 的可视化能力理解图结构
|
||||
|
||||
---
|
||||
|
||||
智能体系统的设计目标是让开发者能够快速构建和迭代 AI 应用。通过本文档介绍的概念和示例,你应该能够掌握创建自定义智能体的核心方法。遇到问题时,建议先参考预置智能体的实现,它们涵盖了大多数常见场景。
|
||||
60
docs/latest/agents/context-config.md
Normal file
60
docs/latest/agents/context-config.md
Normal file
@ -0,0 +1,60 @@
|
||||
# 上下文配置
|
||||
|
||||
`BaseContext` 是智能体的配置基类,封装了常用的配置字段,定义了智能体的运行时行为。
|
||||
|
||||
## BaseContext 详解
|
||||
|
||||
```python
|
||||
from src.agents.common import BaseContext
|
||||
from dataclasses import dataclass
|
||||
|
||||
@dataclass(kw_only=True)
|
||||
class MyAgentContext(BaseContext):
|
||||
# 继承以下字段:
|
||||
# model: str - 使用的语言模型
|
||||
# system_prompt: str - 系统提示词
|
||||
# tools: list[str] - 启用的工具列表
|
||||
# knowledges: list[str] - 关联的知识库
|
||||
# mcps: list[str] - 启用的 MCP 服务器
|
||||
# skills: list[str] - 关联的 Skills
|
||||
|
||||
# 可在此添加自定义字段
|
||||
custom_field: str = "默认值"
|
||||
```
|
||||
|
||||
### 字段说明
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| model | str | 使用的语言模型 |
|
||||
| system_prompt | str | 系统提示词 |
|
||||
| tools | list[str] | 启用的内置工具列表 |
|
||||
| knowledges | list[str] | 关联的知识库 |
|
||||
| mcps | list[str] | 启用的 MCP 服务器 |
|
||||
| skills | list[str] | 关联的 Skills |
|
||||
|
||||
## 自定义工具选项
|
||||
|
||||
有时需要自定义工具选项,比如 ReporterAgent 需要包含 MySQL 工具:
|
||||
|
||||
```python
|
||||
from src.agents.common import BaseContext, gen_tool_info
|
||||
from src.agents.common.toolkits.buildin import calculator, query_knowledge_graph
|
||||
from src.agents.common.toolkits.mysql import get_mysql_tools
|
||||
|
||||
@dataclass(kw_only=True)
|
||||
class ReporterContext(BaseContext):
|
||||
tools: Annotated[list[dict], {"__template_metadata__": {"kind": "tools"}}] = field(
|
||||
default_factory=lambda: [t.name for t in get_mysql_tools()],
|
||||
metadata={
|
||||
"name": "工具",
|
||||
"options": lambda: gen_tool_info(
|
||||
[calculator, query_knowledge_graph, _create_tavily_search()] + get_mysql_tools()
|
||||
),
|
||||
"description": "包含内置工具和 MySQL 工具包。",
|
||||
},
|
||||
)
|
||||
|
||||
def __post_init__(self):
|
||||
self.mcps = ["mcp-server-chart"] # 默认启用图表 MCP
|
||||
```
|
||||
42
docs/latest/agents/mcp-integration.md
Normal file
42
docs/latest/agents/mcp-integration.md
Normal file
@ -0,0 +1,42 @@
|
||||
# MCP 集成
|
||||
|
||||
MCP(Model Context Protocol)是扩展智能体能力的重要方式。系统支持通过管理界面动态配置 MCP 服务器,无需修改代码。
|
||||
|
||||
## 支持的传输协议
|
||||
|
||||
| 协议 | 说明 | 适用场景 |
|
||||
|------|------|----------|
|
||||
| Streamable HTTP | 流式 HTTP 连接 | 远程 MCP 服务 |
|
||||
| SSE | Server-Sent Events | 标准 HTTP 长连接 |
|
||||
| Stdio | 标准输入输出 | 本地进程 |
|
||||
|
||||
## 配置示例
|
||||
|
||||
### 远程 MCP 服务
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "sequentialthinking",
|
||||
"transport": "streamable_http",
|
||||
"url": "https://remote.mcpservers.org/sequentialthinking/mcp"
|
||||
}
|
||||
```
|
||||
|
||||
### 本地 Python 进程
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "mysql-mcp-server",
|
||||
"transport": "stdio",
|
||||
"command": "uvx",
|
||||
"args": ["mysql_mcp_server"],
|
||||
"env": {
|
||||
"MYSQL_HOST": "localhost",
|
||||
"MYSQL_DATABASE": "your_database"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 工具管理
|
||||
|
||||
MCP 工具支持粒度控制:管理员可以单独启用或禁用某个 MCP 服务器下的特定工具,实现精细化的权限管理。
|
||||
44
docs/latest/agents/middleware.md
Normal file
44
docs/latest/agents/middleware.md
Normal file
@ -0,0 +1,44 @@
|
||||
# 中间件系统
|
||||
|
||||
中间件是扩展智能体行为的重要机制。系统基于 LangChain 1.0 的中间件标准,支持在关键节点插入自定义逻辑。
|
||||
|
||||
## 核心中间件
|
||||
|
||||
### RuntimeConfigMiddleware
|
||||
|
||||
这是系统的默认中间件,负责在每次模型调用前注入运行时配置:
|
||||
|
||||
- 自动注入当前时间到系统提示词
|
||||
- 根据配置动态加载工具列表
|
||||
- 处理模型选择和加载
|
||||
|
||||
### inject_attachment_context
|
||||
|
||||
支持文件上传功能的中间件。如果智能体需要处理用户上传的文档,可以启用此中间件:
|
||||
|
||||
```python
|
||||
from src.agents.common.middlewares import inject_attachment_context
|
||||
|
||||
async def get_graph(self):
|
||||
graph = create_agent(
|
||||
model=load_chat_model("..."),
|
||||
tools=tools,
|
||||
middleware=[
|
||||
inject_attachment_context, # 启用附件处理
|
||||
context_aware_prompt, # 其他中间件
|
||||
],
|
||||
checkpointer=await self._get_checkpointer(),
|
||||
)
|
||||
return graph
|
||||
```
|
||||
|
||||
### 启用文件上传
|
||||
|
||||
启用文件上传能力需要两步:
|
||||
|
||||
1. 在智能体类中声明 `capabilities = ["file_upload"]`
|
||||
2. 添加上述中间件
|
||||
|
||||
## 自定义中间件
|
||||
|
||||
新增中间件时,将其放入 `src/agents/common/middlewares` 目录,然后在智能体的 `middleware` 列表中引用即可。
|
||||
283
docs/latest/agents/skills-management.md
Normal file
283
docs/latest/agents/skills-management.md
Normal file
@ -0,0 +1,283 @@
|
||||
# Skills 管理系统
|
||||
|
||||
Skills 是 Yuxi-Know 系统中用于扩展 Agent 能力的重要机制。通过 Skills,开发者可以将特定的工具、提示词模板或领域知识打包成可复用的技能包,让 Agent 在对话过程中能够调用这些额外能力。
|
||||
|
||||
## 为什么需要 Skills
|
||||
|
||||
在实际业务场景中,我们常常会遇到一些特定的需求:比如需要 Agent 能够查询特定的 API、调用某个外部服务、或者使用特定的提示词模板来完成特定任务。传统的做法是在代码中硬编码这些功能,但这样会导致系统变得越来越臃肿,且难以复用。
|
||||
|
||||
Skills 系统的设计理念就是将这类"可插拔"的能力封装成独立的技能包。每个 Skill 包含完整的实现文件和元数据,Agent 可以根据配置动态加载所需的技能,实现能力的灵活组合。
|
||||
|
||||
## 架构设计
|
||||
|
||||
Skills 系统采用「文件系统存内容,数据库存索引」的分离架构:
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Skills 存储架构 │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ /app/saves/skills/ 数据库索引 │
|
||||
│ ├── skill-a/ ┌──────────────┐ │
|
||||
│ │ ├── SKILL.md │ skills 表 │ │
|
||||
│ │ ├── tools/ │ - slug │ │
|
||||
│ │ └── prompts/ │ - name │ │
|
||||
│ └── skill-b/ │ - description│ │
|
||||
│ ├── SKILL.md │ - dir_path │ │
|
||||
│ └── ... │ - deps... │ │
|
||||
│ └──────────────┘ │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 存储结构
|
||||
|
||||
- **文件系统**:`/app/saves/skills` 目录下,每个 Skill 占用一个子目录
|
||||
- **数据库索引**:`skills` 表存储元数据(slug、name、description、依赖关系等)
|
||||
- **关联机制**:通过 `dir_path` 字段关联文件系统目录与数据库记录
|
||||
|
||||
::: tip 不能直接在文件系统创建
|
||||
由于 Skills 的元数据需要写入数据库,因此不能直接在文件系统中创建 Skill。必须通过系统的导入功能或在线创建功能来完成,系统会自动处理数据库记录的创建。
|
||||
:::
|
||||
|
||||
## 创建方式
|
||||
|
||||
系统提供三种方式创建 Skills:
|
||||
|
||||
1. **ZIP 导入(推荐)**:将 Skill 目录打包成 ZIP,通过管理界面上传导入
|
||||
2. **在线创建**:通过 Skills 管理页面在线创建目录和文件
|
||||
3. **手动导入**:直接操作数据库(不推荐,需要手动同步文件系统和数据库)
|
||||
|
||||
## Skills 来源
|
||||
|
||||
Skills 本质上是提示词和工具的封装,以下是一些可以参考的 Skills 实现:
|
||||
|
||||
- **Anthropic 官方 Tools**:https://github.com/anthropics/skills 可以参考其 skills 的组织方式和提示词设计
|
||||
- **社区 Skills**:各平台分享的 Agent 提示词模板
|
||||
- **自定义开发**:根据业务需求自行开发
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 创建你的第一个 Skill
|
||||
|
||||
一个标准的 Skill 目录结构如下:
|
||||
|
||||
```
|
||||
my-awesome-skill/
|
||||
├── SKILL.md # 必选,Skill 的核心定义文件
|
||||
├── tools/ # 可选,相关的工具脚本
|
||||
│ └── helper.py
|
||||
└── prompts/ # 可选,提示词模板
|
||||
└── system.md
|
||||
```
|
||||
|
||||
其中 `SKILL.md` 是每个 Skill 必须包含的核心文件,它采用 Markdown + Frontmatter 格式:
|
||||
|
||||
```markdown
|
||||
---
|
||||
name: my-awesome-skill
|
||||
description: 这是一个用于处理特定任务的技能
|
||||
---
|
||||
|
||||
# Skill 使用说明
|
||||
|
||||
这里是技能的详细使用文档,Agent 会读取这部分内容来了解如何使用这个技能。
|
||||
|
||||
## 功能列表
|
||||
|
||||
1. 功能一:xxx
|
||||
2. 功能二:yyy
|
||||
|
||||
## 使用示例
|
||||
|
||||
当用户 xxx 时,可以调用此技能...
|
||||
```
|
||||
|
||||
**Frontmatter 字段说明:**
|
||||
|
||||
| 字段 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `name` | 是 | Skill 名称,必须是小写字母、数字、短横线的组合(如 `my-skill`) |
|
||||
| `description` | 是 | Skill 的功能描述,会在 Agent 配置时展示 |
|
||||
|
||||
### 导入 Skill
|
||||
|
||||
有两种方式可以导入 Skill:
|
||||
|
||||
**方式一:通过 ZIP 包导入(推荐)**
|
||||
|
||||
1. 将 Skill 目录打包成 ZIP 文件(注意:ZIP 的根目录就是 Skill 目录)
|
||||
2. 在系统设置的「Skills 管理」页面,点击「导入 Skill」
|
||||
3. 上传 ZIP 文件即可
|
||||
|
||||
系统会自动:
|
||||
- 校验 ZIP 内容和路径安全性
|
||||
- 检查 slug 冲突(如有冲突会自动追加 `-v2` 等后缀)
|
||||
- 解析 SKILL.md 的 frontmatter 并存储到数据库
|
||||
|
||||
**方式二:在线创建**
|
||||
|
||||
在 Skills 管理页面,你可以:
|
||||
- 新建目录或文件
|
||||
- 在线编辑文本文件(支持 .md、.py、.js、.json 等格式)
|
||||
- 直接在网页上编写 SKILL.md 内容
|
||||
|
||||
## 依赖系统
|
||||
|
||||
Skills 之间可以建立依赖关系,形成一个松耦合的技能网络。
|
||||
|
||||
### 依赖类型
|
||||
|
||||
每个 Skill 可以声明三类依赖:
|
||||
|
||||
| 依赖类型 | 说明 | 加载时机 |
|
||||
|----------|------|----------|
|
||||
| `tool_dependencies` | 需要的内置工具 | 激活后按需加载 |
|
||||
| `mcp_dependencies` | 需要的 MCP 服务 | 激活后按需加载 |
|
||||
| `skill_dependencies` | 依赖的其他 Skill | 会话启动即生效 |
|
||||
|
||||
### 渐进式加载机制
|
||||
|
||||
系统采用三级渐进式加载策略,确保资源的高效利用:
|
||||
|
||||
**阶段一:会话启动**
|
||||
|
||||
当 Agent 会话启动时,系统会:
|
||||
1. 读取 Agent 配置中的 `context.skills` 列表
|
||||
2. 递归展开 `skill_dependencies`,构建完整的可见技能集(`visible_skills`)
|
||||
3. 将可见技能列表注入到系统提示词中
|
||||
|
||||
这意味着:只要配置了某个 Skill,它的依赖 Skill 就会立即对 Agent 可见。
|
||||
|
||||
**阶段二:技能激活**
|
||||
|
||||
当 Agent 通过 `read_file` 工具读取 `/skills/<slug>/SKILL.md` 时,视为"激活"该技能。系统会:
|
||||
1. 验证该技能在可见列表中
|
||||
2. 将其添加到 `activated_skills` 列表
|
||||
3. 后续的模型调用会使用激活列表来加载依赖
|
||||
|
||||
**阶段三:按需加载**
|
||||
|
||||
每次模型调用时,系统会:
|
||||
1. 检查 `activated_skills` 中的技能
|
||||
2. 收集这些技能的 `tool_dependencies` 和 `mcp_dependencies`
|
||||
3. 动态将需要的工具和 MCP 服务添加到可用工具集中
|
||||
|
||||
这种设计的好处是:不会在会话开始时加载所有工具,而是根据 Agent 实际使用情况按需加载,既节省资源又保证响应速度。
|
||||
|
||||
### 依赖声明示例
|
||||
|
||||
假设我们有三个 Skills:
|
||||
|
||||
- **base-skill**:基础技能,无依赖
|
||||
- **advanced-skill**:依赖 `base-skill`
|
||||
- **pro-skill**:依赖 `advanced-skill`
|
||||
|
||||
当在 Agent 配置中只选择 `pro-skill` 时:
|
||||
1. 启动阶段:`visible_skills` = [`pro-skill`, `advanced-skill`, `base-skill`](自动展开依赖链)
|
||||
2. Agent 首次调用任何 skill 时:所有三个 Skill 都可见
|
||||
3. 当 Agent 读取 `pro-skill/SKILL.md` 时:触发激活,工具和 MCP 依赖被加载
|
||||
|
||||
## 权限管理
|
||||
|
||||
Skills 管理采用基于角色的权限控制:
|
||||
|
||||
| 角色 | 权限 |
|
||||
|------|------|
|
||||
| 超级管理员 | 完全控制:导入、导出、编辑、删除、配置依赖 |
|
||||
| 管理员 | 只读:查看 Skills 列表(用于 Agent 配置) |
|
||||
| 普通用户 | 无访问权限 |
|
||||
|
||||
管理员可以在创建或编辑 Agent 时,从 Skills 列表中选择需要的能力。
|
||||
|
||||
## 运行时行为
|
||||
|
||||
### Agent 如何使用 Skills
|
||||
|
||||
1. **提示词注入**:系统会在 Agent 的系统提示词开头自动插入可用 Skills 的描述
|
||||
2. **文件访问**:Skills 目录以只读方式挂载到 `/skills/<slug>/...`
|
||||
3. **工具调用**:当 Agent 需要使用某个 Skill 时,会先读取对应的 SKILL.md 了解使用方法
|
||||
|
||||
### 文件操作限制
|
||||
|
||||
运行时 `/skills` 路径有以下限制:
|
||||
- **只读**:Agent 只能读取文件内容
|
||||
- **禁止写入**:不能创建、修改或删除文件
|
||||
- **路径安全**:所有路径都经过安全校验,防止目录穿越攻击
|
||||
|
||||
::: tip 虚拟文件系统限制
|
||||
当前 Skills 目录挂载为虚拟文件系统,**不支持 shell 命令执行**。Skill 中的脚本仅作为提示词参考,Agent 无法直接执行这些脚本。如果需要执行特定功能,建议通过 MCP 工具或自定义工具实现。
|
||||
:::
|
||||
|
||||
### 会话隔离
|
||||
|
||||
每个 Agent 会话都有独立的 Skills 可见集:
|
||||
- 不同会话可以配置不同的 Skills
|
||||
- 同一会话内修改 `context.skills` 会触发快照重建
|
||||
- 后台修改 Skills 内容后,已有会话不会自动刷新
|
||||
|
||||
## 最佳实践
|
||||
|
||||
### Skill 命名规范
|
||||
|
||||
- 使用小写字母、数字和短横线
|
||||
- 具有描述性,如 `weather-query`、`sql-reporter`
|
||||
- 避免过长的名称
|
||||
|
||||
### 依赖管理建议
|
||||
|
||||
- **保持依赖链简洁**:层级不宜过深,一般 1-2 层为宜
|
||||
- **避免循环依赖**:系统会检测并阻止循环依赖
|
||||
- **明确依赖必要性**:只在真正需要共享能力时才建立依赖
|
||||
|
||||
### SKILL.md 编写技巧
|
||||
|
||||
```markdown
|
||||
---
|
||||
name: example-skill
|
||||
description: 简短描述技能功能
|
||||
---
|
||||
|
||||
# 技能名称
|
||||
|
||||
这里是详细的使用说明...
|
||||
|
||||
## 何时使用
|
||||
|
||||
描述在什么场景下应该使用这个技能...
|
||||
|
||||
## 使用方法
|
||||
|
||||
1. 第一步...
|
||||
2. 第二步...
|
||||
|
||||
## 示例
|
||||
|
||||
```
|
||||
具体的使用示例...
|
||||
```
|
||||
```
|
||||
|
||||
## 常见问题
|
||||
|
||||
**Q:为什么我配置的 Skill 没有生效?**
|
||||
|
||||
A:请检查以下几点:
|
||||
1. Skill 的 slug 是否正确配置在 Agent 的 `context.skills` 中
|
||||
2. SKILL.md 是否存在且 frontmatter 格式正确
|
||||
3. 如果使用了依赖,确保依赖链完整
|
||||
|
||||
**Q:如何更新已导入的 Skill?**
|
||||
|
||||
A:可以通过以下方式:
|
||||
1. 导出当前 Skill,修改后重新导入
|
||||
2. 在 Skills 管理页面在线编辑文件
|
||||
3. 直接修改文件系统中的内容(需要重启服务使缓存失效)
|
||||
|
||||
**Q:Skill 依赖的工具/MCP 不存在怎么办?**
|
||||
|
||||
A:系统会在保存依赖配置时进行校验,如果引用的工具或 MCP 不存在,会报错并阻止保存。
|
||||
|
||||
---
|
||||
|
||||
通过 Skills 机制,Yuxi-Know 为 Agent 提供了一个灵活、可扩展的能力扩展框架。你可以将自己积累的业务知识、工具能力封装成 Skills,让不同的 Agent 复用这些能力,极大地提升了系统的可维护性和复用性。
|
||||
66
docs/latest/agents/tools-system.md
Normal file
66
docs/latest/agents/tools-system.md
Normal file
@ -0,0 +1,66 @@
|
||||
# 工具系统
|
||||
|
||||
Yuxi-Know 提供了统一的工具获取机制,支持多种工具类型的动态组装。
|
||||
|
||||
## 工具获取机制
|
||||
|
||||
系统提供统一的工具获取入口 `get_tools_from_context(context)`,它会自动组装三类工具:
|
||||
|
||||
1. **基础工具**:从 `context.tools` 筛选的内置工具
|
||||
2. **知识库工具**:根据 `context.knowledges` 自动生成检索工具
|
||||
3. **MCP 工具**:根据 `context.mcps` 加载并过滤的 MCP 服务器工具
|
||||
|
||||
```python
|
||||
from src.agents.common.tools import get_tools_from_context
|
||||
|
||||
async def get_graph(self, **kwargs):
|
||||
context = self.get_context()
|
||||
tools = await get_tools_from_context(context)
|
||||
```
|
||||
|
||||
## 工具注册机制
|
||||
|
||||
Yuxi-Know 的工具系统基于注册机制而非继承体系,这一点与 LangChain 原生的 `@tool` 装饰器有本质区别。
|
||||
|
||||
LangChain 的 `@tool` 装饰器通常需要继承特定基类或实现特定接口,创建的工具有着强烈的框架耦合。而 Yuxi-Know 的工具注册表是一个独立的全局注册中心,任何符合规范的函数都可以通过 `@tool` 装饰器注册到系统中,无需继承任何基类,也不需要了解框架内部实现。
|
||||
|
||||
需要特别说明的是,Yuxi-Know 的 `@tool` 装饰器并非全新实现,而是基于 LangChain 原生 `@tool` 的扩展。装饰器的核心逻辑继承自 LangChain,新增了 `category`、`tags`、`display_name` 等元数据字段用于前端展示和分类,原有的 LangChain 特性(如函数参数注解、描述文档等)完全兼容。
|
||||
|
||||
注册表的核心位于 `src/agents/common/toolkits/registry.py`,它维护着一个全局的工具实例列表。当系统启动时,所有导入 `toolkits` 包的模块都会自动执行其内部的工具注册逻辑,这意味着开发者只需要在自己的模块中添加装饰器,工具就会自动被发现和使用。
|
||||
|
||||
```python
|
||||
from src.agents.common.toolkits.registry import tool
|
||||
|
||||
@tool(category="buildin", tags=["计算"], display_name="计算器")
|
||||
def calculator(a: float, b: float, operation: str) -> float:
|
||||
"""计算器:对给定的2个数字进行基本数学运算"""
|
||||
if operation == "add":
|
||||
return a + b
|
||||
# ...
|
||||
```
|
||||
|
||||
使用这个装饰器时,需要指定 `category` 和 `tags`,前者用于工具分类,后者用于前端展示。装饰器内部仍然调用 LangChain 的工具封装逻辑,因此 LangChain 工具的所有特性(如多参数支持、参数类型注解等)都保持兼容。
|
||||
|
||||
获取工具时,通过 `get_all_tool_instances()` 可以拿到所有已注册的工具实例列表,这个函数会被 `get_tools_from_context` 调用,根据上下文配置筛选出需要使用的工具。
|
||||
|
||||
## 内置工具
|
||||
|
||||
系统内置了几类常用工具。计算类包括 calculator,可进行加减乘除运算。搜索类包括 tavily_search,需要在环境变量中配置 `TAVILY_API_KEY` 才能启用。知识图谱类包括 query_knowledge_graph,用于查询通过三元组导入的全局知识图谱。交互类包括 ask_user_question,用于在智能体执行过程中向用户发起交互式提问。数据库类包括 mysql_list_tables、mysql_describe_table 和 mysql_query,用于连接和查询 MySQL 数据库。
|
||||
|
||||
这些工具都通过上述注册机制自动加载,开发者无需手动引入。
|
||||
|
||||
## 知识库工具
|
||||
|
||||
与内置工具不同,知识库工具是动态生成的。当在智能体配置中指定 `context.knowledges` 时,系统会根据指定的 knowledge 名称动态创建对应的检索工具。这种设计使得知识库工具不需要预先注册,而是在运行时按需生成。
|
||||
|
||||
```python
|
||||
from src.agents.common.toolkits.kbs import get_common_kb_tools
|
||||
|
||||
kb_tools = get_common_kb_tools(knowledge_names=["kb1", "kb2"])
|
||||
```
|
||||
|
||||
## Skills 集成
|
||||
|
||||
Skills 与工具是两种不同的扩展机制。工具是具体的功能实现,而 Skills 是包含提示词、工具依赖和元数据的完整技能包。通过 `context.skills` 配置 Skills 时,对应的技能文件会被挂载到 `/skills/<slug>/...`,智能体可以通过读取 SKILL.md 来了解如何使用这些技能。
|
||||
|
||||
关于 Skills 的详细机制,请参阅 [Skills 管理](./skills-management.md)。
|
||||
@ -1,52 +1,51 @@
|
||||
# 参与贡献
|
||||
|
||||
感谢所有贡献者的支持!
|
||||
感谢你对 Yuxi-Know 项目的兴趣!我们欢迎任何形式的贡献,包括但不限于代码提交、功能建议、问题反馈和文档改进。
|
||||
|
||||
<a href="https://github.com/xerrors/Yuxi-Know/contributors">
|
||||
<img src="https://contributors.nn.ci/api?repo=xerrors/Yuxi-Know" alt="贡献者名单">
|
||||
</a>
|
||||
|
||||
## 如何贡献
|
||||
## 贡献流程
|
||||
|
||||
### 1. Fork 项目
|
||||
|
||||
在 GitHub 上 Fork 本项目到你的账户。
|
||||
在 GitHub 上点击 Fork 按钮,将项目复制到你的账户。
|
||||
|
||||
### 2. 创建分支
|
||||
### 2. 创建功能分支
|
||||
|
||||
```bash
|
||||
git checkout -b feature/amazing-feature
|
||||
```
|
||||
|
||||
### 3. 提交更改
|
||||
### 3. 开发并提交
|
||||
|
||||
```bash
|
||||
git commit -m 'feat: Add some amazing feature'
|
||||
git commit -m 'feat: 添加新功能'
|
||||
```
|
||||
|
||||
### 4. 推送分支
|
||||
### 4. 推送代码
|
||||
|
||||
```bash
|
||||
git push origin feature/amazing-feature
|
||||
```
|
||||
|
||||
### 5. 创建 PR
|
||||
### 5. 创建 Pull Request
|
||||
|
||||
在 GitHub 上创建 Pull Request,详细描述你的更改内容。
|
||||
在 GitHub 上创建 PR,详细描述你的更改内容和动机。
|
||||
|
||||
## 开发指南
|
||||
## 代码规范
|
||||
|
||||
### 代码规范
|
||||
项目对代码质量有一定要求,提交前请确保:
|
||||
|
||||
- 遵循项目代码规范
|
||||
- Python 代码使用 `make format` 格式化
|
||||
- 使用 `make lint` 检查代码质量
|
||||
- 添加必要的测试用例
|
||||
- 更新相关文档
|
||||
|
||||
### 提交规范
|
||||
## 提交信息规范
|
||||
|
||||
使用清晰的提交信息:
|
||||
使用清晰规范的提交信息:
|
||||
|
||||
```
|
||||
feat: 添加新功能
|
||||
@ -58,29 +57,28 @@ test: 添加测试
|
||||
chore: 构建过程或辅助工具的变动
|
||||
```
|
||||
|
||||
## Bug 修复发布流程
|
||||
|
||||
## 🐞 Bug 修复发布流程
|
||||
当发布后发现 bug 需要修复时:
|
||||
|
||||
如果在发布 `v0.3.0` 后发现 bug:
|
||||
|
||||
### ✅ 情况 1:main 上没有未完成的新功能
|
||||
### 情况 1:main 上没有未完成的新功能
|
||||
|
||||
直接在 main 修复并发布:
|
||||
|
||||
```bash
|
||||
git commit -m "fix: resolve config parser crash"
|
||||
git commit -m "fix: 解决配置解析器崩溃问题"
|
||||
git tag -a v0.3.1 -m "Hotfix v0.3.1"
|
||||
git push origin main --tags
|
||||
```
|
||||
|
||||
### ⚙️ 情况 2:main 上已有新功能未完成
|
||||
### 情况 2:main 上已有新功能未完成
|
||||
|
||||
从上一个 tag 建立 hotfix 分支:
|
||||
|
||||
```bash
|
||||
git checkout -b hotfix/0.3.1 v0.3.0
|
||||
# 修复问题
|
||||
git commit -m "fix: resolve config parser crash"
|
||||
git commit -m "fix: 解决配置解析器崩溃问题"
|
||||
git push origin hotfix/0.3.1
|
||||
|
||||
# 测试后合并回 main 并打 tag
|
||||
@ -94,32 +92,29 @@ git branch -d hotfix/0.3.1
|
||||
git push origin --delete hotfix/0.3.1
|
||||
```
|
||||
|
||||
## 测试指南
|
||||
|
||||
### 测试要求
|
||||
|
||||
::: tip 测试
|
||||
- `make lint` / `make format` 保持代码整洁
|
||||
- `cp test/.env.test.example test/.env.test` 配置测试凭据
|
||||
- `make router-tests` 运行集成路由测试,支持 `PYTEST_ARGS="-k chat_router"`
|
||||
- `uv run --group test pytest test/api` 可直接运行 pytest(容器内)
|
||||
:::
|
||||
|
||||
<details>
|
||||
<summary>常用命令</summary>
|
||||
### 运行测试
|
||||
|
||||
```bash
|
||||
# 全量路由测试
|
||||
make router-tests
|
||||
|
||||
# 仅运行知识库相关用例
|
||||
# 运行特定测试
|
||||
make router-tests PYTEST_ARGS="-k knowledge_router"
|
||||
|
||||
# 不经过 Makefile,直接调用 pytest
|
||||
# 直接运行 pytest
|
||||
uv run --group test pytest test/api -vv
|
||||
```
|
||||
|
||||
</details>
|
||||
### 测试配置
|
||||
|
||||
## 许可证
|
||||
首次运行测试前,需要配置测试凭据:
|
||||
|
||||
本项目基于 MIT License 开源,贡献的代码将遵循相同的许可证。
|
||||
```bash
|
||||
cp test/.env.test.example test/.env.test
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
感谢每一位贡献者的付出!
|
||||
|
||||
@ -1,70 +1,98 @@
|
||||
# 常见问题
|
||||
|
||||
以下为最常见的安装与使用问题,更多细节请参阅相应章节链接。
|
||||
以下是 Yuxi-Know 在安装和使用过程中最常见的问题及其解决方案。
|
||||
|
||||
## Docker与启动相关问题
|
||||
## Docker 与启动问题
|
||||
|
||||
### 镜像拉取/构建失败?
|
||||
镜像拉取:可使用以下脚本辅助拉取
|
||||
- **Linux/macOS**: `docker/pull_image.sh`
|
||||
- **Windows PowerShell**: `docker/pull_image.ps1`
|
||||
构建失败:若配置了代理仍失败,可尝试以下步骤:
|
||||
1. 注释 `api.Dockerfile` 中的代理环境变量设置:
|
||||
```dockerfile
|
||||
# 注释掉以下代理配置
|
||||
# ENV HTTP_PROXY=$HTTP_PROXY \
|
||||
# HTTPS_PROXY=$HTTPS_PROXY \
|
||||
# http_proxy=$HTTP_PROXY \
|
||||
# https_proxy=$HTTPS_PROXY
|
||||
```
|
||||
2. 注释 `docker-compose.yml` 中的代理构建参数:
|
||||
```yaml
|
||||
services:
|
||||
api:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: docker/api.Dockerfile
|
||||
# 注释掉代理构建参数
|
||||
# args:
|
||||
# HTTP_PROXY: ${HTTP_PROXY:-}
|
||||
# HTTPS_PROXY: ${HTTPS_PROXY:-}
|
||||
```
|
||||
3. 在 `api.Dockerfile` 中添加国内镜像源加速依赖安装:
|
||||
```dockerfile
|
||||
RUN --mount=type=cache,target=/root/.cache/uv \
|
||||
uv sync --no-dev --index-url https://pypi.tuna.tsinghua.edu.cn/simple
|
||||
### 镜像拉取或构建失败
|
||||
|
||||
**镜像拉取问题**:
|
||||
|
||||
```bash
|
||||
# Linux/macOS
|
||||
bash docker/pull_image.sh
|
||||
|
||||
# Windows PowerShell
|
||||
powershell -ExecutionPolicy Bypass -File docker/pull_image.ps1
|
||||
```
|
||||
|
||||
**构建失败问题**:
|
||||
|
||||
如果配置了代理仍然失败,尝试以下步骤:
|
||||
|
||||
1. 注释 `api.Dockerfile` 中的代理配置
|
||||
2. 注释 `docker-compose.yml` 中的代理构建参数
|
||||
3. 添加国内镜像源加速:
|
||||
|
||||
```dockerfile
|
||||
RUN --mount=type=cache,target=/root/.cache/uv \
|
||||
uv sync --no-dev --index-url https://pypi.tuna.tsinghua.edu.cn/simple
|
||||
```
|
||||
|
||||
### 服务启动失败
|
||||
|
||||
1. 检查端口占用:`lsof -i :5050` 或 `netstat -tuln | grep 5050`
|
||||
2. 确认 Docker 服务状态
|
||||
3. 查看日志定位问题:
|
||||
```bash
|
||||
docker logs --tail=100 api-dev
|
||||
docker logs --tail=100 web-dev
|
||||
```
|
||||
|
||||
### 数据库服务问题
|
||||
|
||||
### 服务启动失败?
|
||||
- 检查端口占用情况:使用 `lsof -i :5050` 或 `netstat -tuln | grep 5050` 查看端口使用
|
||||
- 确认 Docker 服务状态:`systemctl status docker`(Linux)或 `Docker Desktop` 应用状态(Windows/macOS)
|
||||
- 参考日志定位问题:`docker logs --tail=100 api-dev`、`docker logs --tail=100 web-dev`
|
||||
**Milvus / Neo4j 启动失败**:
|
||||
|
||||
### 服务端口与访问地址?
|
||||
- Web: `http://localhost:5173`;API 文档: `http://localhost:5050/docs`
|
||||
```bash
|
||||
# 重启服务
|
||||
docker compose up milvus -d && docker restart api-dev
|
||||
```
|
||||
|
||||
### Milvus/Neo4j 启动或连接失败?
|
||||
- 重启:`docker compose up milvus -d && docker restart api-dev`
|
||||
- Neo4j 默认:用户名 `neo4j`、密码 `0123456789`、管理界面 `http://localhost:7474`
|
||||
- Milvus 检查:`docker logs milvus -f` 查看启动状态
|
||||
**Neo4j 连接信息**:
|
||||
- 用户名:neo4j
|
||||
- 密码:0123456789
|
||||
- 管理界面:http://localhost:7474
|
||||
|
||||
### 首次运行如何创建管理员?
|
||||
- Web 首次启动会引导初始化;也可调用 API:
|
||||
- `GET /api/auth/check-first-run` → `first_run=true` 时
|
||||
- `POST /api/auth/initialize` 提交 `user_id` 与 `password`
|
||||
- 无默认账号,初始化后使用创建的超级管理员登录
|
||||
### 账号相关问题
|
||||
|
||||
### 如何查看日志和状态?
|
||||
- `docker ps` 查看整体服务状态
|
||||
- `docker logs api-dev -f`、`docker logs web-dev -f` 查看实时服务日志
|
||||
- `docker compose logs --tail=100` 查看所有服务日志
|
||||
**首次运行创建管理员**:
|
||||
|
||||
## 其他常见问题
|
||||
Web 首次启动会引导初始化。也可以通过 API 创建:
|
||||
|
||||
### OCR 模型或服务不可用?
|
||||
- RapidOCR 本地模型:确保 `MODEL_DIR/SWHL/RapidOCR` 下存在 `PP-OCRv4` 模型
|
||||
- MinerU/PP-StructureV3:检查健康检查接口与 GPU/CUDA 版本
|
||||
```bash
|
||||
# 检查是否首次运行
|
||||
GET /api/auth/check-first-run
|
||||
|
||||
### 登录失败被锁定?
|
||||
- 多次失败会临时锁定账户,请根据提示等待后重试
|
||||
# 初始化管理员账号
|
||||
POST /api/auth/initialize
|
||||
# Body: {"user_id": "your_username", "password": "your_password"}
|
||||
```
|
||||
|
||||
### 日志查看
|
||||
|
||||
```bash
|
||||
# 查看所有容器状态
|
||||
docker ps
|
||||
|
||||
# 查看实时日志
|
||||
docker logs api-dev -f
|
||||
docker logs web-dev -f
|
||||
|
||||
# 查看所有服务日志
|
||||
docker compose logs --tail=100
|
||||
```
|
||||
|
||||
## 功能使用问题
|
||||
|
||||
### OCR 服务不可用
|
||||
|
||||
- **RapidOCR**:确保 `MODEL_DIR/SWHL/RapidOCR` 下存在 `PP-OCRv4` 模型
|
||||
- **MinerU / PP-StructureV3**:检查 GPU 和 CUDA 版本是否兼容
|
||||
|
||||
### 登录失败被锁定
|
||||
|
||||
多次登录失败会临时锁定账户,请根据页面提示等待后重试。
|
||||
|
||||
---
|
||||
|
||||
如果以上问题无法解决你的问题,欢迎在 GitHub Issues 中提问。
|
||||
|
||||
@ -1,57 +1,80 @@
|
||||
# 知识库评估使用与开发指南
|
||||
# 知识库评估指南
|
||||
|
||||
知识库评估功能用于测试 RAG 系统的检索和生成质量。通过预设的测试问题和标准答案(或自动生成评估),量化评估系统在不同场景下的表现。
|
||||
知识库评估是 RAG 系统开发中的重要环节。通过量化评估,我们可以了解检索和生成的质量,发现问题并持续优化。
|
||||
|
||||
**适用场景**:验证知识库上线前的效果、对比不同配置下的检索效果、定期监控知识库质量变化、调优检索参数。
|
||||
## 为什么需要评估
|
||||
|
||||
**注意**:当前版本支持 Milvus 类型的知识库。
|
||||
在构建知识库系统时,你可能会遇到这些问题:
|
||||
|
||||
## 如何创建评估基准
|
||||
- 检索结果不准确,用户找不到想要的内容
|
||||
- 生成答案与文档不符,存在幻觉
|
||||
- 调整了分块策略或模型,效果是变好还是变差了?
|
||||
|
||||
### 1. 上传评估文件
|
||||
评估功能就是为了回答这些问题。它通过预设的测试问题和标准答案,量化系统的表现,帮助你做出数据驱动的优化决策。
|
||||
|
||||
准备 JSONL 格式的文件,每行一个测试样本:
|
||||
## 评估指标解读
|
||||
|
||||
系统提供以下核心指标:
|
||||
|
||||
| 指标 | 含义 | 参考值 |
|
||||
|------|------|--------|
|
||||
| Recall@1 | 第一个检索结果包含正确文档的比例 | > 0.6 为佳 |
|
||||
| Recall@5 | 前5个检索结果包含正确文档的比例 | > 0.8 为佳 |
|
||||
| F1@K | 精确率和召回率的调和平均 | 用于横向对比 |
|
||||
| 答案准确性 | 生成答案与标准答案的一致性 | 越高越好 |
|
||||
|
||||
## 创建评估基准
|
||||
|
||||
### 手动准备数据
|
||||
|
||||
准备 JSONL 格式的评估文件,每行一个样本:
|
||||
|
||||
```json
|
||||
{"query": "什么是人工智能?", "gold_chunk_ids": ["chunk_001", "chunk_002"], "gold_answer": "人工智能是计算机科学的一个分支"}
|
||||
{"query": "机器学习的主要类型有哪些?", "gold_chunk_ids": ["chunk_005"], "gold_answer": "主要包括监督学习、无监督学习和强化学习"}
|
||||
{"query": "深度学习的应用领域", "gold_chunk_ids": ["chunk_010", "chunk_011"]}
|
||||
{"query": "什么是人工智能?", "gold_chunk_ids": ["chunk_001"], "gold_answer": "人工智能是..."}
|
||||
{"query": "机器学习有哪些类型?", "gold_chunk_ids": ["chunk_005"], "gold_answer": "主要包括监督学习..."}
|
||||
```
|
||||
|
||||
**字段说明**:
|
||||
- `query`(必需):测试问题,用于触发 RAG 系统的检索
|
||||
- `gold_chunk_ids`(可选):相关文档块的 ID 列表,用于验证检索效果
|
||||
- `gold_answer`(可选):标准答案,用于验证生成效果
|
||||
字段说明:
|
||||
- `query`:测试问题,必需
|
||||
- `gold_chunk_ids`:期望被检索到的文档块 ID,可选
|
||||
- `gold_answer`:标准答案,用于评估生成质量,可选
|
||||
|
||||
::: tip 数据集构建
|
||||
可以使用 [EasyDataset](https://github.com/ConardLi/easy-dataset) 从文档批量生成问答对、可视化编辑、导出多种格式和数据质量检查。挺好用的,推荐。注意导出的时候的字段需要修改为 `query`、`gold_answer`。
|
||||
::: tip 推荐工具
|
||||
可以使用 [EasyDataset](https://github.com/ConardLi/easy-dataset) 从文档批量生成问答对。注意导出时将字段名改为 `query` 和 `gold_answer`。
|
||||
:::
|
||||
|
||||
### 自动生成
|
||||
|
||||
### 2. 自动生成评估基准
|
||||
系统也支持自动生成评估数据:随机采样知识库中的文档块,用嵌入模型查找相似内容,最后用大模型生成问答对。
|
||||
|
||||
Yuxi 也实现了一个简易的、可以基于现有知识库自动生成测试数据。流程是:随机采样一个 chunk → 用嵌入模型找相似 chunk → 用 LLM 生成问题和答案。
|
||||
|
||||
**推荐参数设置**:
|
||||
推荐参数:
|
||||
- 问题数量:10-50 个
|
||||
- 相似文档数:每个问题 2-5 个
|
||||
- 相似文档数:2-5 个
|
||||
|
||||
## 运行评估任务
|
||||
## 运行评估
|
||||
|
||||
1. 选择评估基准后在知识库页面点击"评估"标签
|
||||
在知识库详情页点击「评估」标签,选择评估基准后配置:
|
||||
|
||||
2. 配置参数:
|
||||
- **答案生成模型(可选)**:如果选择了,则会基于检索的 chunk 生成答案,然后用评判模型评估答案的准确性
|
||||
- **评判模型(可选)**:如果选择了,则会用评判模型评估答案的准确性,判断是否与标准答案一致,因此选择评判模型时,必须选择答案生成模型。
|
||||
1. **答案生成模型**(可选):基于检索到的文档块生成答案
|
||||
2. **评判模型**(可选):评估生成答案与标准答案的一致性
|
||||
|
||||
3. 点击"开始评估":
|
||||
系统会逐个处理测试问题,执行检索和生成,计算各项指标。评估在后台运行,可以继续其他操作。
|
||||
点击「开始评估」,系统在后台执行,完成后会显示各项指标结果。
|
||||
|
||||
**主要指标**:
|
||||
## 评估结果分析
|
||||
|
||||
| 指标 | 含义 | 如何看待 |
|
||||
|------|------|----------|
|
||||
| Recall@1 | 第一个结果包含正确文档的比例 | 最重要的指标,反映用户第一眼看到的准确率 |
|
||||
| Recall@5 | 前5个结果包含正确文档的比例 | 综合检索效果,应该大于 0.8 |
|
||||
| F1@K | 精确率和召回率的调和平均 | 平衡指标,用于对比不同配置 |
|
||||
| 答案准确性 | 生成答案是否与标准答案一致 | 检查 LLM 理解和表达能力 |
|
||||
拿到评估结果后,可以从以下几个角度分析:
|
||||
|
||||
- **Recall@1 低**:说明最相关的内容没有被首先检索到,可能需要调整嵌入模型或分块策略
|
||||
- **Recall@5 低**:说明相关文档没有被检索到,可能需要增加检索数量或优化查询
|
||||
- **答案准确性低**:说明生成质量有问题,可能需要调整提示词或更换模型
|
||||
|
||||
## 使用场景
|
||||
|
||||
- **上线前验证**:知识库建设完成后,评估效果是否满足要求
|
||||
- **配置对比**:调整分块策略、嵌入模型后,对比评估结果
|
||||
- **定期监控**:定期评估,及时发现质量下降
|
||||
- **参数调优**:通过多次评估找到最优参数组合
|
||||
|
||||
---
|
||||
|
||||
评估是一个持续的过程。建议在初始建设时就建立评估基准,后续每次重大变更都进行评估,形成数据驱动的优化闭环。
|
||||
|
||||
@ -1,145 +1,164 @@
|
||||
# 知识库与知识图谱
|
||||
|
||||
项目中的知识库与知识图谱,即是知识管理组织的方式,同时会被封装为工具供 AgenticRAG 系统调用。
|
||||
Yuxi-Know 提供了强大的知识管理能力,将知识以向量和图谱两种形式存储,既支持传统的语义检索,又能构建结构化的知识关系网络。
|
||||
|
||||
## 知识库介绍
|
||||
## 为什么需要知识库
|
||||
|
||||
系统支持多种知识库存储形式,满足不同场景需求:
|
||||
在大模型应用场景中,仅依靠模型的内部知识往往不够准确和全面。通过构建知识库,我们可以:
|
||||
|
||||
- **注入私有知识**:让模型能够回答基于私有文档的问题
|
||||
- **降低幻觉**:回答内容可追溯到原始文档
|
||||
- **知识复用**:一次上传,多轮对话中重复使用
|
||||
|
||||
## 知识库类型
|
||||
|
||||
系统支持两种知识库存储形式,各有不同的适用场景:
|
||||
|
||||
| 存储类型 | 特点 | 适用场景 |
|
||||
|----------|------|----------|
|
||||
| **Milvus** | 高性能向量数据库 | 大规模生产环境、高性能查询 |
|
||||
| **LightRAG** | 图增强检索 | 复杂知识关系,构建成本较高 |
|
||||
| **Milvus** | 高性能向量数据库 | 大规模生产环境,需要快速检索 |
|
||||
| **LightRAG** | 图增强检索 | 复杂知识关系,需要图结构理解 |
|
||||
|
||||
访问 Web 界面:`http://localhost:5173`,进入"知识库管理"页面,点击"新建知识库",填写知识库信息。
|
||||
选择建议:如果是简单的文档问答,Milvus 就足够了;如果需要理解实体之间的关系,构建知识图谱,LightRAG 是更好的选择。
|
||||
|
||||
这里需要**注意**的是,这里的知识库的标题和描述都会作为智能体选择工具的依据,因此尽量详尽的描述该知识库。
|
||||
## 创建知识库
|
||||
|
||||
### 文件上传流程
|
||||
访问 Web 界面的「知识库管理」页面,点击「新建知识库」:
|
||||
|
||||
文件的处理总共分为三个过程,分别是 上传、解析、入库。上传就是将文件从本地上传到服务器(运行本项目的机器)中,此时文件是以原始文件存储的,比如 PDF 还是 PDF。
|
||||
然后会进行第二步解析,即将文件解析成 markdown 格式,其中文件中的图片会被提取出来上传到 minio 数据库中,并在 markdown 文件中的对应位置,添加 url `` 这样;
|
||||
第三步就是入库,这里的入库在 CommonRAG 知识库中,指代的是对 markdown 内容 chunk 后将向量保存到 milvus 中,对于 LightRAG 知识库,则指代的是,提取图谱并保存到知识库中。
|
||||
1. 填写知识库名称和描述
|
||||
2. 选择存储类型(Milvus 或 LightRAG)
|
||||
3. 配置访问权限
|
||||
4. 保存
|
||||
|
||||
在前端界面中,默认完成前两步,即上传后会自动解析,如果想要实现解析后还继续入库的话,需要在上传的时候勾选自动入库。否则需要在上传后手动点击入库。
|
||||
::: tip 提示
|
||||
知识库的名称和描述会被智能体用来判断何时应该使用这个知识库进行检索,所以请尽量详细地描述。
|
||||
:::
|
||||
|
||||
## 文件处理流程
|
||||
|
||||
文件从上传到可检索,经历三个阶段:
|
||||
|
||||
### 1. 上传阶段
|
||||
|
||||
将本地文件上传到服务器。文件保持原始格式存储(PDF 还是 PDF,Word 还是 Word)。
|
||||
|
||||
### 2. 解析阶段
|
||||
|
||||
系统将文件转换为 Markdown 格式:
|
||||
- 提取文本内容
|
||||
- 图片上传到 MinIO,并在 Markdown 中用 URL 引用
|
||||
- 表格、公式等保持结构化
|
||||
|
||||
### 3. 入库阶段
|
||||
|
||||
- **Milvus 知识库**:对 Markdown 内容进行分块,向量存储到 Milvus
|
||||
- **LightRAG 知识库**:提取实体和关系,构建知识图谱到 Neo4j
|
||||
|
||||
在前端界面中,默认会自动完成前两个阶段。如果需要自动入库,勾选「上传后自动入库」选项;否则需要手动点击入库按钮。
|
||||
|
||||
## 知识库权限控制
|
||||
|
||||
每个知识库可以配置独立的访问权限:
|
||||
|
||||
- **共享模式**: 设置知识库是否全局共享
|
||||
- **部门访问**: 配置允许访问该知识库的部门范围
|
||||
- **全局共享**:所有用户可访问
|
||||
- **部门授权**:仅指定部门可访问
|
||||
- **私有**:仅创建者和管理员可访问
|
||||
|
||||
权限规则:
|
||||
- **超级管理员**: 可访问所有知识库
|
||||
- **管理员**: 可访问共享以及本部门所有知识库
|
||||
- **普通用户**: 仅能访问已授权的知识库(通过部门或全局共享)
|
||||
|
||||
创建/编辑知识库时,可在"分享配置"中设置权限。
|
||||
|
||||
## 文档管理
|
||||
|
||||
本系统的“上传 → 解析入库 → 检索/可视化”流程既可通过 Web 界面完成,也可使用 API/脚本批量处理。详见[文档解析](../advanced/document-processing.md)
|
||||
|
||||
接口查询:`GET /api/knowledge/files/supported-types`
|
||||
|
||||
**上传与入库**
|
||||
|
||||
1) 上传文件(返回服务端保存路径)
|
||||
- `POST /api/knowledge/files/upload?db_id=<可选>`
|
||||
- 成功返回:`file_path`(后续入库使用)、`content_hash`(内容去重)
|
||||
|
||||
2) 解析并入库(异步任务)
|
||||
- `POST /api/knowledge/databases/{db_id}/documents`
|
||||
- 返回:`status=queued` 与 `task_id`,可在任务中心查看进度
|
||||
|
||||
去重策略:系统按“内容哈希”判断是否已存在相同文件,避免重复入库。
|
||||
|
||||
## 其他
|
||||
|
||||
### LightRAG 知识库说明
|
||||
|
||||
在本项目中,系统支持基于 [LightRAG](https://github.com/HKUDS/LightRAG) 的知识图谱自动构建,能够从文档中自动提取实体和关系,构建结构化知识图谱。
|
||||
|
||||
**LightRAG 图谱 vs 全局知识图谱的区别:**
|
||||
|
||||
- **LightRAG 图谱**(知识库专属):针对单个知识库由 LightRAG 自动抽取实体/关系,用于该库内的图增强检索与可视化。通过特殊的 label(知识库ID)与全局图谱区分,不会混入全局数据。
|
||||
|
||||
- **全局知识图谱**(系统级):通过三元组文件上传的图谱数据,提供系统级的知识图谱查询和可视化能力,会作为工具供 LLM 使用。
|
||||
|
||||
两者共享同一个 Neo4j 实例,但完全隔离,互不影响。
|
||||
|
||||
LightRAG 知识库可在知识库详情、知识图谱中可视化。由于免费版的 neo4j 只能创建一个图数据库,因此实际上 LightRAG 的节点和边依然是和知识图谱本身构建在了同一个 Neo4j 数据库中,但是使用了特殊的 label `{知识库ID}` 做区分。
|
||||
|
||||
**常见问题**
|
||||
|
||||
1. 只有节点没有边/出现了 TPM 的报错:大概率是由于供应商限制了模型的调用量,解决办法是更换 TPM 更大的模型,或其他供应商。
|
||||
2. 当本地计算资源有限时,可以配置 `EMBEDDING_TIMEOUT=60`, `LLM_TIMEOUT=180` 增加超时时间
|
||||
|
||||
同时项目支持原 LightRAG 的所有环境变量,只需要在项目的 `.env` 文件中配置即可。
|
||||
- 超级管理员可访问所有知识库
|
||||
- 管理员可访问共享知识库和本部门的知识库
|
||||
- 普通用户只能访问已授权的知识库
|
||||
|
||||
## 知识图谱
|
||||
|
||||
本项目存在两类“图谱相关”能力:
|
||||
系统支持两种图谱相关能力,理解它们的区别很重要:
|
||||
|
||||
- 上传的知识图谱(Neo4j):提供三元组检索和系统级可视化。会作为工具供 LLM 使用。
|
||||
- LightRAG 知识库内图谱:针对某个知识库由 LightRAG 自动抽取实体/关系,用于该库内的图增强检索与可视化;与上传的图谱共享同一 Neo4j 实例,但通过特殊 label 区分,不作为全局图谱使用。
|
||||
### LightRAG 图谱
|
||||
|
||||
针对单个知识库由 LightRAG 自动抽取实体和关系。特点:
|
||||
- 自动从文档中提取
|
||||
- 附属于特定知识库
|
||||
- 用于该知识库内的图增强检索
|
||||
- 通过知识库 ID 作为 Label 区分,与全局图谱隔离
|
||||
|
||||
### 1. 以三元组形式导入
|
||||
### 全局知识图谱
|
||||
|
||||
系统支持通过网页导入 `jsonl` 格式的知识图谱数据,支持**简单三元组**和**带属性三元组**两种格式。
|
||||
通过三元组文件上传的图谱数据。特点:
|
||||
- 手动导入
|
||||
- 系统级知识库
|
||||
- 提供图查询和可视化能力
|
||||
- 作为工具供智能体调用
|
||||
|
||||
**简单格式(兼容旧版)**:
|
||||
两者共享同一个 Neo4j 实例,但数据完全隔离,互不影响。
|
||||
|
||||
```jsonl
|
||||
### 导入三元组数据
|
||||
|
||||
系统支持通过网页导入 `jsonl` 格式的图谱数据:
|
||||
|
||||
**简单格式**:
|
||||
```json
|
||||
{"h": "北京", "t": "中国", "r": "首都"}
|
||||
{"h": "上海", "t": "中国", "r": "直辖市"}
|
||||
```
|
||||
|
||||
**扩展格式(支持属性)**:
|
||||
|
||||
支持 `h`(头节点)、`t`(尾节点)和 `r`(关系)为对象结构,其中:
|
||||
- 节点对象必须包含 `name` 字段。
|
||||
- 关系对象必须包含 `type` 字段。
|
||||
- 其他字段将作为**属性**存储在 Neo4j 中。
|
||||
|
||||
```jsonl
|
||||
{"h": {"name": "孙悟空", "title": "齐天大圣", "weapon": "如意金箍棒"}, "t": {"name": "唐僧", "species": "人"}, "r": {"type": "徒弟", "order": 1}}
|
||||
{"h": "猪八戒", "t": {"name": "唐僧"}, "r": {"type": "徒弟", "order": 2}}
|
||||
```json
|
||||
{"h": {"name": "孙悟空", "title": "齐天大圣"}, "t": {"name": "唐僧"}, "r": {"type": "徒弟"}}
|
||||
```
|
||||
|
||||
**格式说明**:
|
||||
- 每行一个数据项。
|
||||
- 系统自动验证数据格式,并自动导入到 Neo4j 数据库。
|
||||
- 自动添加 `Upload`、`Entity` 标签(节点)和 `RELATION` 类型(关系)。
|
||||
- 自动处理重复实体和关系,并合并属性。
|
||||
导入后,可以在图谱可视化页面查看和查询。
|
||||
|
||||
Neo4j 访问信息可以参考 `docker-compose.yml` 中配置对应的环境变量来覆盖。
|
||||
### Neo4j 配置
|
||||
|
||||
- **默认账户**: `neo4j`
|
||||
- **默认密码**: `0123456789`
|
||||
- **管理界面**: `http://localhost:7474`
|
||||
- **连接地址**: bolt://localhost:7687
|
||||
|
||||
::: tip 测试数据
|
||||
可以使用以下文件进行测试导入:
|
||||
- 简单格式:`test/data/A_Dream_of_Red_Mansions_tiny.jsonl`
|
||||
- 扩展属性格式:`test/data/complex_graph_test.jsonl`
|
||||
:::
|
||||
|
||||
### 2. 接入已有 Neo4j 实例
|
||||
|
||||
如需接入已有的 Neo4j 实例,可修改 `.env` 中的配置:
|
||||
|
||||
<<< @/../.env.template#neo4j{bash}
|
||||
|
||||
同时记得注释掉下面的 neo4j 服务:
|
||||
|
||||
<<< @/../docker-compose.yml#neo4j
|
||||
Neo4j 连接信息可以在 `.env` 中配置:
|
||||
|
||||
- 默认账户:`neo4j`
|
||||
- 默认密码:`0123456789`
|
||||
- 管理界面:http://localhost:7474
|
||||
- 连接地址:bolt://localhost:7687
|
||||
|
||||
::: warning 注意事项
|
||||
确保每个节点都有 `Entity` 标签和 `name` 属性,每个关系都有 `RELATION` 类型和 `type` 属性,否则会影响图的检索与构建功能。
|
||||
:::
|
||||
|
||||
## 常见问题
|
||||
|
||||
**Q:只有节点没有边怎么办?**
|
||||
|
||||
A:这通常是因为模型调用量受限。请尝试:
|
||||
- 更换 TPM(每分钟令牌数)更大的模型
|
||||
- 更换模型服务商
|
||||
|
||||
**Q:构建图谱时出现超时错误?**
|
||||
|
||||
A:可以配置增加超时时间:
|
||||
```env
|
||||
EMBEDDING_TIMEOUT=60
|
||||
LLM_TIMEOUT=180
|
||||
```
|
||||
|
||||
**Q:LightRAG 和全局图谱有什么区别?**
|
||||
|
||||
A:简单理解:
|
||||
- LightRAG 图谱 = 自动从知识库文档中提取,附属于知识库
|
||||
- 全局图谱 = 手动导入,系统级图谱查询
|
||||
|
||||
## API 使用
|
||||
|
||||
如果需要通过程序批量处理文件,可以使用以下接口:
|
||||
|
||||
```bash
|
||||
# 1. 上传文件
|
||||
POST /api/knowledge/files/upload?db_id=<知识库ID>
|
||||
# 返回 file_path 和 content_hash
|
||||
|
||||
# 2. 解析并入库
|
||||
POST /api/knowledge/databases/{db_id}/documents
|
||||
# 返回 status=queued 和 task_id
|
||||
```
|
||||
|
||||
系统会自动去重:基于内容哈希判断是否已存在相同文件。
|
||||
|
||||
---
|
||||
|
||||
知识库是 Yuxi-Know 的核心能力之一,通过本文档的介绍,你应该能够掌握创建和使用知识库的基本方法。对于更高级的用法,如评估基准构建、图增强检索优化等,可以进一步探索系统的其他功能。
|
||||
|
||||
@ -32,7 +32,7 @@
|
||||
系统的默认对话模型可以在设置页面配置,也可以通过配置项 `default_model` 指定,格式统一为 `模型提供商/模型名称`,例如:
|
||||
|
||||
```yaml
|
||||
default_model: siliconflow/deepseek-ai/DeepSeek-V3.2
|
||||
default_model: default="siliconflow/Pro/deepseek-ai/DeepSeek-V3.2
|
||||
```
|
||||
|
||||
## 自定义模型供应商
|
||||
@ -152,6 +152,35 @@ models = [
|
||||
3. **权限错误**: 确保用户具有管理员权限
|
||||
4. **配置未生效**: 检查环境变量配置和服务重启状态
|
||||
|
||||
## 多模态模型
|
||||
|
||||
系统支持图片作为输入,与文本结合形成多模态查询。
|
||||
|
||||
### 支持的图片格式
|
||||
|
||||
- JPEG、PNG、WebP、GIF、BMP
|
||||
- 最大 10MB
|
||||
- 超过 5MB 会自动压缩
|
||||
|
||||
### 使用方式
|
||||
|
||||
在对话接口中传入图片数据:
|
||||
|
||||
```json
|
||||
{
|
||||
"query": "这张图片里有什么?",
|
||||
"image_content": "<base64编码的图片数据>",
|
||||
"config": {},
|
||||
"meta": {}
|
||||
}
|
||||
```
|
||||
|
||||
系统会自动将图片转换为符合模型要求的格式,支持多模态的模型会同时处理图片和文本信息。
|
||||
|
||||
### 支持多模态的模型
|
||||
|
||||
大多数主流模型提供商都支持多模态能力,选择模型时需确认模型本身支持图片输入。
|
||||
|
||||
## 嵌入模型和重排序模型
|
||||
|
||||
#### 1. 配置模型信息
|
||||
|
||||
@ -1,24 +1,77 @@
|
||||
# 项目简介
|
||||
|
||||
Yuxi-Know(语析)是一个基于知识图谱和向量数据库的智能知识库系统,融合了 RAG(检索增强生成)技术与知识图谱技术,为用户提供智能问答和知识管理服务。
|
||||
Yuxi-Know(语析)是一个基于大模型的智能知识库与知识图谱智能体开发平台。它融合了 RAG(检索增强生成)技术与知识图谱技术,为用户提供智能问答和知识管理服务。
|
||||
|
||||
**特点**:技术栈简单,易于上手,使用 MIT 开源协议,非常适合二次开发使用。
|
||||
## 设计理念
|
||||
|
||||
### 技术栈选择
|
||||
项目的设计目标是为开发者提供一个易于上手、功能强大的 AI 应用开发框架。我们坚持以下原则:
|
||||
|
||||
- **后端服务**: [FastAPI](https://github.com/tiangolo/fastapi) + Python 3.12+
|
||||
- **前端界面**: [Vue.js 3](https://github.com/vuejs/vue) + [Ant Design Vue](https://github.com/vueComponent/ant-design-vue)
|
||||
- **数据库存储**: [PostgreSQL](https://github.com/postgres/postgres) + [MinIO](https://github.com/minio/minio)
|
||||
- **知识存储**: [Milvus](https://github.com/milvus-io/milvus)(向量数据库)+ [Neo4j](https://github.com/neo4j/neo4j)(图数据库)
|
||||
- **智能体框架**: [LangGraph](https://github.com/langchain-ai/langgraph)
|
||||
- **文档解析**: [LightRAG](https://github.com/HKUDS/LightRAG) + [MinerU](https://github.com/HKUDS/MinerU) + [PP-Structure-V3](https://github.com/PaddlePaddle/PaddleOCR)
|
||||
- **容器编排**: [Docker Compose](https://github.com/docker/compose)
|
||||
- **技术栈简洁**:选择主流且成熟的技术,降低学习和维护成本
|
||||
- **MIT 开源协议**:完全开源,允许自由使用和二次开发
|
||||
- **容器化部署**:通过 Docker Compose 管理,简化部署流程
|
||||
|
||||
### 核心功能
|
||||
## 技术架构
|
||||
|
||||
- **智能问答**: 支持多种大语言模型,提供智能对话和问答服务
|
||||
- **知识库管理**: 支持多种存储形式(Milvus、LightRAG)
|
||||
- **知识图谱**: 自动构建和可视化知识图谱,支持图查询
|
||||
- **文档解析**: 支持 PDF、Word、图片等多种格式的智能解析
|
||||
- **权限管理**: 基于部门的知识库访问控制
|
||||
- **内容安全**: 内置内容审查机制,保障服务合规性
|
||||
### 后端服务
|
||||
|
||||
- **FastAPI**:现代高性能 Python Web 框架
|
||||
- **LangGraph**:基于 LangChain 的智能体编排框架
|
||||
- **PostgreSQL**:业务数据存储
|
||||
- **Milvus**:向量数据库,支持大规模语义检索
|
||||
- **Neo4j**:图数据库,存储知识图谱
|
||||
- **MinIO**:对象存储,用于文件托管
|
||||
|
||||
### 前端界面
|
||||
|
||||
- **Vue.js 3**:渐进式前端框架
|
||||
- **Ant Design Vue**:企业级 UI 组件库
|
||||
|
||||
### 文档处理
|
||||
|
||||
- **LightRAG**:文档理解与知识图谱构建
|
||||
- **MinerU**:文档智能解析
|
||||
- **PP-Structure-V3**:PDF 结构化提取
|
||||
|
||||
## 核心能力
|
||||
|
||||
### 智能问答
|
||||
|
||||
系统支持接入多种大语言模型,通过对话方式提供智能问答服务。模型可配置、工具可组合、提示词可定制,满足不同业务场景需求。
|
||||
|
||||
### 知识库管理
|
||||
|
||||
支持 Milvus 向量数据库和 LightRAG 知识图谱两种存储形式:
|
||||
- Milvus 适合大规模文档检索场景
|
||||
- LightRAG 适合需要理解实体关系的复杂查询
|
||||
|
||||
### 知识图谱
|
||||
|
||||
自动从文档中提取实体和关系,构建结构化知识图谱。支持可视化查看和图查询,帮助理解知识之间的联系。
|
||||
|
||||
### 文档解析
|
||||
|
||||
支持 PDF、Word、图片等多种格式的智能解析,自动提取文本、表格、公式等内容。
|
||||
|
||||
### 权限管理
|
||||
|
||||
基于部门的知识库访问控制,确保数据安全。
|
||||
|
||||
### 内容安全
|
||||
|
||||
内置内容审查机制,保障服务合规性。
|
||||
|
||||
## 适用场景
|
||||
|
||||
Yuxi-Know 适用于以下场景:
|
||||
|
||||
- **企业知识库**:构建私有知识问答系统
|
||||
- **智能客服**:基于文档的自动问答
|
||||
- **知识管理**:文档自动解析、分类、构建图谱
|
||||
- **AI 应用开发**:快速构建基于大模型的应用原型
|
||||
|
||||
## 下一步
|
||||
|
||||
- 快速开始:阅读 [快速开始指南](./quick-start.md)
|
||||
- 模型配置:阅读 [模型配置](./model-config.md)
|
||||
- 知识库使用:阅读 [知识库与知识图谱](./knowledge-base.md)
|
||||
- 智能体开发:阅读 [智能体开发](../agents/agents-config.md)
|
||||
|
||||
@ -1,36 +1,37 @@
|
||||
# 快速开始指南
|
||||
|
||||
Yuxi-Know(语析)是一个基于知识图谱和向量数据库的智能知识库系统。通过本文档,你可以在几分钟内完成环境搭建并开始使用。
|
||||
|
||||
::: tip 提示
|
||||
除了此文档网站外,用户还可以在 [Zread](https://zread.ai/xerrors/Yuxi-Know) 或 [DeepWiki](https://deepwiki.com/xerrors/Yuxi-Know) 平台查看自动生成的详细项目文档。
|
||||
除了此文档网站外,你还可以访问 [Zread](https://zread.ai/xerrors/Yuxi-Know) 或 [DeepWiki](https://deepwiki.com/xerrors/Yuxi-Know) 查看自动生成的详细项目文档。
|
||||
:::
|
||||
|
||||
## 环境要求
|
||||
|
||||
## 快速开始
|
||||
项目采用微服务架构设计,默认服务无需 GPU 支持。如果需要使用 OCR 功能,可以通过环境变量配置外部服务。
|
||||
|
||||
## 快速安装
|
||||
|
||||
### 安装步骤
|
||||
|
||||
项目采用微服务架构,默认服务无需 GPU 支持。GPU 仅用于可选的 OCR 服务,可通过环境变量配置外部服务。
|
||||
|
||||
#### 1. 获取项目代码
|
||||
### 步骤一:获取项目代码
|
||||
|
||||
```bash
|
||||
# 克隆稳定版本
|
||||
git clone --branch v0.5.0-beta4 --depth 1 https://github.com/xerrors/Yuxi-Know.git
|
||||
# 克隆稳定版本(推荐新用户使用 v0.5.1)
|
||||
git clone --branch v0.5.1 --depth 1 https://github.com/xerrors/Yuxi-Know.git
|
||||
cd Yuxi-Know
|
||||
```
|
||||
|
||||
::: warning 版本说明
|
||||
- `v0.4.4`: 稳定版本
|
||||
- `v0.5.0-beta4`: 由于数据库重构使用 postgres,可能会存在数据库迁移问题,建议新用户使用,迁移指南详见 [迁移指南](https://xerrors.github.io/Yuxi-Know/latest/changelog/migrate_to_v0-5)。
|
||||
- `main`: 最新开发版本(不稳定,新特性可能会导致新 bug)
|
||||
:::
|
||||
版本选择建议:
|
||||
|
||||
#### 2. 项目启动
|
||||
| 版本 | 适用场景 |
|
||||
|------|----------|
|
||||
| v0.5.x | 稳定版本,适合生产环境使用 |
|
||||
| main | 开发版本,包含最新特性(可能不稳定) |
|
||||
|
||||
**方法 1**:使用 init 脚本(推荐)
|
||||
### 步骤二:配置环境变量
|
||||
|
||||
我们提供了自动化的初始化脚本,可以帮您完成环境配置和 Docker 镜像拉取:
|
||||
**方式一:使用初始化脚本(推荐)**
|
||||
|
||||
我们提供了自动化脚本,帮你完成环境配置和 Docker 镜像拉取:
|
||||
|
||||
```bash
|
||||
# Linux/macOS
|
||||
@ -40,127 +41,108 @@ cd Yuxi-Know
|
||||
.\scripts\init.ps1
|
||||
```
|
||||
|
||||
脚本会:
|
||||
- 检查并创建 `.env` 文件
|
||||
- 提示您输入 `SILICONFLOW_API_KEY`(必需)
|
||||
- 提示您输入 `TAVILY_API_KEY`(可选,用于搜索服务)
|
||||
- 自动拉取所有必需的 Docker 镜像
|
||||
脚本会引导你完成以下配置:
|
||||
- 创建 `.env` 配置文件
|
||||
- 设置 `SILICONFLOW_API_KEY`(必需,用于调用大模型)
|
||||
- 设置 `TAVILY_API_KEY`(可选,用于搜索服务)
|
||||
- 自动拉取必需的 Docker 镜像
|
||||
|
||||
::: tip API Key 获取
|
||||
- [硅基流动](https://cloud.siliconflow.cn/i/Eo5yTHGJ) 注册即送 14 元额度
|
||||
- [Tavily](https://app.tavily.com/) 获取搜索服务 API Key(可选)
|
||||
- **硅基流动**:访问 [cloud.siliconflow.cn](https://cloud.siliconflow.cn/i/Eo5yTHGJ),注册即送 14 元额度
|
||||
- **Tavily**:访问 [app.tavily.com](https://app.tavily.com/) 获取搜索 API Key(可选)
|
||||
:::
|
||||
|
||||
**方法 2**:手动配置环境变量
|
||||
**方式二:手动配置**
|
||||
|
||||
复制环境变量模板并编辑:
|
||||
如果偏好手动配置:
|
||||
|
||||
```bash
|
||||
# 复制环境变量模板
|
||||
cp .env.template .env
|
||||
|
||||
# 编辑 .env 文件,填入你的 API Key
|
||||
```
|
||||
|
||||
编辑 `.env` 文件,配置必需的 API 密钥,这里强烈建议先使用硅基流动的 API 和模型(DeepSeek)验证平台的功能无误后,再尝试切换到自己的模型:
|
||||
|
||||
|
||||
<<< @/../.env.template#model_provider{bash 5}
|
||||
|
||||
|
||||
::: tip 免费获取 API Key
|
||||
[硅基流动](https://cloud.siliconflow.cn/i/Eo5yTHGJ) 注册即送 14 元额度,支持多种开源模型。
|
||||
:::
|
||||
|
||||
#### 3. 启动服务
|
||||
### 步骤三:启动服务
|
||||
|
||||
```bash
|
||||
# 构建并启动所有服务
|
||||
docker compose up --build
|
||||
|
||||
# 后台运行(推荐)
|
||||
docker compose up --build -d
|
||||
```
|
||||
|
||||
**注意**:启动后,可能还需要一些时间,尤其是后端服务需要一段时间,请耐心等待 2-3 分钟。
|
||||
服务首次启动需要等待镜像拉取和编译,请耐心等待 2-3 分钟。
|
||||
|
||||
#### 4. 访问系统
|
||||
### 步骤四:访问系统
|
||||
|
||||
服务启动完成后,访问以下地址:
|
||||
服务启动后,访问以下地址:
|
||||
|
||||
- **Web 界面**: `http://localhost:5173`
|
||||
- **API 文档**: `http://localhost:5050/docs`
|
||||
| 服务 | 地址 |
|
||||
|------|------|
|
||||
| Web 界面 | http://localhost:5173 |
|
||||
| API 文档 | http://localhost:5050/docs |
|
||||
|
||||
#### 5. 停止服务
|
||||
首次访问时,系统会要求你设置超级管理员账号和密码,请妥善保存。
|
||||
|
||||
```bash
|
||||
docker compose down
|
||||
```
|
||||
## 开始使用
|
||||
|
||||
## 对话
|
||||
|
||||
项目第一次启动后,会要求填写超级管理员账号和密码,请确保填写正确。
|
||||
|
||||
然后在智能体页面可以进行对话,在右侧可以配置提示词、模型、工具等参数。
|
||||
|
||||

|
||||
完成上述配置后,你就可以开始使用了:
|
||||
|
||||
1. 登录系统(使用刚才设置的超级管理员账号)
|
||||
2. 进入「智能体」页面
|
||||
3. 选择或创建一个智能体
|
||||
4. 在右侧面板配置提示词、选择模型和工具
|
||||
5. 开始对话
|
||||
|
||||

|
||||
|
||||
## 故障排除
|
||||
|
||||
::: tip 调试面板
|
||||
前端有个**调试面板**,在头像选项里,生产环境建议删除此特性。
|
||||
:::
|
||||
|
||||
#### 查看服务状态
|
||||
### 查看服务状态
|
||||
|
||||
```bash
|
||||
# 查看所有容器状态
|
||||
docker ps
|
||||
|
||||
# 查看后端服务日志
|
||||
# 实时查看后端日志
|
||||
docker logs api-dev -f
|
||||
|
||||
# 查看前端服务日志
|
||||
# 实时查看前端日志
|
||||
docker logs web-dev -f
|
||||
```
|
||||
|
||||
#### 常见问题
|
||||
### 常见问题
|
||||
|
||||
<details>
|
||||
<summary><strong>Docker 镜像拉取失败</strong></summary>
|
||||
|
||||
如果拉取镜像失败,可以尝试手动拉取:
|
||||
如果网络原因导致镜像拉取失败,可以尝试:
|
||||
|
||||
```bash
|
||||
# Linux/macOS
|
||||
# 手动拉取基础镜像
|
||||
bash docker/pull_image.sh python:3.12-slim
|
||||
|
||||
# Windows PowerShell
|
||||
powershell -ExecutionPolicy Bypass -File docker/pull_image.ps1 python:3.12-slim
|
||||
```
|
||||
|
||||
**离线镜像拉取方案**:
|
||||
**离线环境部署方案**:
|
||||
|
||||
```bash
|
||||
# 在有网络的环境保存镜像(镜像名称需要确认是否和实际一致,现有版本可能不是最新最全,需要检查)
|
||||
bash docker/save_docker_images.sh # Linux/macOS
|
||||
powershell -ExecutionPolicy Bypass -File docker/save_docker_images.ps1 # Windows
|
||||
# 在有网络的环境导出镜像
|
||||
bash docker/save_docker_images.sh
|
||||
|
||||
# 传输到目标设备
|
||||
scp docker_images_xxx.tar <user>@<dev_host>:<path_to_save>
|
||||
# 传输到目标机器
|
||||
scp docker_images_xxx.tar user@host:/path/
|
||||
|
||||
# 在目标设备加载镜像
|
||||
# 导入镜像
|
||||
docker load -i docker_images_xxx.tar
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>构建失败</strong></summary>
|
||||
|
||||
如果构建失败,通常是网络问题,可以配置代理:
|
||||
多数构建失败是由于网络问题。尝试配置代理:
|
||||
|
||||
```bash
|
||||
# Linux / macOS
|
||||
# Linux/macOS
|
||||
export HTTP_PROXY=http://IP:PORT
|
||||
export HTTPS_PROXY=http://IP:PORT
|
||||
|
||||
@ -169,21 +151,25 @@ $env:HTTP_PROXY="http://IP:PORT"
|
||||
$env:HTTPS_PROXY="http://IP:PORT"
|
||||
```
|
||||
|
||||
如果已配置代理但构建失败,尝试移除代理后重试。
|
||||
|
||||
如果出现,FetchError: request to https://registry.npmjs.org/npm failed, reason: connect ECONNREFUSED 127.0.0.1:7890
|
||||
|
||||
新建一个终端重新执行,并确保没有代理干扰。
|
||||
|
||||
如果配置代理后反而失败,尝试移除代理后重试。
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>Milvus 启动失败</strong></summary>
|
||||
<summary><strong>Milvus 服务启动失败</strong></summary>
|
||||
|
||||
```bash
|
||||
# 重启 Milvus 服务
|
||||
docker compose up milvus -d
|
||||
docker restart api-dev
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
::: tip 调试面板
|
||||
前端提供了调试面板(在头像菜单中可找到),可以查看详细的请求和响应信息。生产环境建议关闭此特性。
|
||||
:::
|
||||
|
||||
## 下一步
|
||||
|
||||
- 了解如何配置模型:阅读 [模型配置](./model-config.md)
|
||||
- 探索知识库功能:阅读 [知识库与知识图谱](./knowledge-base.md)
|
||||
- 学习智能体开发:阅读 [智能体开发](../agents/agents-config.md)
|
||||
|
||||
224
package-lock.json
generated
224
package-lock.json
generated
@ -733,9 +733,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@rollup/rollup-android-arm-eabi": {
|
||||
"version": "4.52.4",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.52.4.tgz",
|
||||
"integrity": "sha512-BTm2qKNnWIQ5auf4deoetINJm2JzvihvGb9R6K/ETwKLql/Bb3Eg2H1FBp1gUb4YGbydMA3jcmQTR73q7J+GAA==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz",
|
||||
"integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@ -746,9 +746,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-android-arm64": {
|
||||
"version": "4.52.4",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.52.4.tgz",
|
||||
"integrity": "sha512-P9LDQiC5vpgGFgz7GSM6dKPCiqR3XYN1WwJKA4/BUVDjHpYsf3iBEmVz62uyq20NGYbiGPR5cNHI7T1HqxNs2w==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz",
|
||||
"integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@ -759,9 +759,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-darwin-arm64": {
|
||||
"version": "4.52.4",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.52.4.tgz",
|
||||
"integrity": "sha512-QRWSW+bVccAvZF6cbNZBJwAehmvG9NwfWHwMy4GbWi/BQIA/laTIktebT2ipVjNncqE6GLPxOok5hsECgAxGZg==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz",
|
||||
"integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@ -772,9 +772,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-darwin-x64": {
|
||||
"version": "4.52.4",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.52.4.tgz",
|
||||
"integrity": "sha512-hZgP05pResAkRJxL1b+7yxCnXPGsXU0fG9Yfd6dUaoGk+FhdPKCJ5L1Sumyxn8kvw8Qi5PvQ8ulenUbRjzeCTw==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz",
|
||||
"integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@ -785,9 +785,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-freebsd-arm64": {
|
||||
"version": "4.52.4",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.52.4.tgz",
|
||||
"integrity": "sha512-xmc30VshuBNUd58Xk4TKAEcRZHaXlV+tCxIXELiE9sQuK3kG8ZFgSPi57UBJt8/ogfhAF5Oz4ZSUBN77weM+mQ==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz",
|
||||
"integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@ -798,9 +798,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-freebsd-x64": {
|
||||
"version": "4.52.4",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.52.4.tgz",
|
||||
"integrity": "sha512-WdSLpZFjOEqNZGmHflxyifolwAiZmDQzuOzIq9L27ButpCVpD7KzTRtEG1I0wMPFyiyUdOO+4t8GvrnBLQSwpw==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz",
|
||||
"integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@ -811,9 +811,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm-gnueabihf": {
|
||||
"version": "4.52.4",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.52.4.tgz",
|
||||
"integrity": "sha512-xRiOu9Of1FZ4SxVbB0iEDXc4ddIcjCv2aj03dmW8UrZIW7aIQ9jVJdLBIhxBI+MaTnGAKyvMwPwQnoOEvP7FgQ==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz",
|
||||
"integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@ -824,9 +824,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm-musleabihf": {
|
||||
"version": "4.52.4",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.52.4.tgz",
|
||||
"integrity": "sha512-FbhM2p9TJAmEIEhIgzR4soUcsW49e9veAQCziwbR+XWB2zqJ12b4i/+hel9yLiD8pLncDH4fKIPIbt5238341Q==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz",
|
||||
"integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@ -837,9 +837,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm64-gnu": {
|
||||
"version": "4.52.4",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.52.4.tgz",
|
||||
"integrity": "sha512-4n4gVwhPHR9q/g8lKCyz0yuaD0MvDf7dV4f9tHt0C73Mp8h38UCtSCSE6R9iBlTbXlmA8CjpsZoujhszefqueg==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz",
|
||||
"integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@ -850,9 +850,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm64-musl": {
|
||||
"version": "4.52.4",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.52.4.tgz",
|
||||
"integrity": "sha512-u0n17nGA0nvi/11gcZKsjkLj1QIpAuPFQbR48Subo7SmZJnGxDpspyw2kbpuoQnyK+9pwf3pAoEXerJs/8Mi9g==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz",
|
||||
"integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@ -863,9 +863,22 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-loong64-gnu": {
|
||||
"version": "4.52.4",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.52.4.tgz",
|
||||
"integrity": "sha512-0G2c2lpYtbTuXo8KEJkDkClE/+/2AFPdPAbmaHoE870foRFs4pBrDehilMcrSScrN/fB/1HTaWO4bqw+ewBzMQ==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz",
|
||||
"integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-loong64-musl": {
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz",
|
||||
"integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
@ -876,9 +889,22 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-ppc64-gnu": {
|
||||
"version": "4.52.4",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.52.4.tgz",
|
||||
"integrity": "sha512-teSACug1GyZHmPDv14VNbvZFX779UqWTsd7KtTM9JIZRDI5NUwYSIS30kzI8m06gOPB//jtpqlhmraQ68b5X2g==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz",
|
||||
"integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-ppc64-musl": {
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz",
|
||||
"integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
@ -889,9 +915,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-riscv64-gnu": {
|
||||
"version": "4.52.4",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.52.4.tgz",
|
||||
"integrity": "sha512-/MOEW3aHjjs1p4Pw1Xk4+3egRevx8Ji9N6HUIA1Ifh8Q+cg9dremvFCUbOX2Zebz80BwJIgCBUemjqhU5XI5Eg==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz",
|
||||
"integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
@ -902,9 +928,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-riscv64-musl": {
|
||||
"version": "4.52.4",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.52.4.tgz",
|
||||
"integrity": "sha512-1HHmsRyh845QDpEWzOFtMCph5Ts+9+yllCrREuBR/vg2RogAQGGBRC8lDPrPOMnrdOJ+mt1WLMOC2Kao/UwcvA==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz",
|
||||
"integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
@ -915,9 +941,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-s390x-gnu": {
|
||||
"version": "4.52.4",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.52.4.tgz",
|
||||
"integrity": "sha512-seoeZp4L/6D1MUyjWkOMRU6/iLmCU2EjbMTyAG4oIOs1/I82Y5lTeaxW0KBfkUdHAWN7j25bpkt0rjnOgAcQcA==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz",
|
||||
"integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
@ -928,9 +954,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-x64-gnu": {
|
||||
"version": "4.52.4",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.52.4.tgz",
|
||||
"integrity": "sha512-Wi6AXf0k0L7E2gteNsNHUs7UMwCIhsCTs6+tqQ5GPwVRWMaflqGec4Sd8n6+FNFDw9vGcReqk2KzBDhCa1DLYg==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz",
|
||||
"integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@ -941,9 +967,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-x64-musl": {
|
||||
"version": "4.52.4",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.52.4.tgz",
|
||||
"integrity": "sha512-dtBZYjDmCQ9hW+WgEkaffvRRCKm767wWhxsFW3Lw86VXz/uJRuD438/XvbZT//B96Vs8oTA8Q4A0AfHbrxP9zw==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz",
|
||||
"integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@ -953,10 +979,23 @@
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-openbsd-x64": {
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz",
|
||||
"integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-openharmony-arm64": {
|
||||
"version": "4.52.4",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.52.4.tgz",
|
||||
"integrity": "sha512-1ox+GqgRWqaB1RnyZXL8PD6E5f7YyRUJYnCqKpNzxzP0TkaUh112NDrR9Tt+C8rJ4x5G9Mk8PQR3o7Ku2RKqKA==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz",
|
||||
"integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@ -967,9 +1006,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-arm64-msvc": {
|
||||
"version": "4.52.4",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.52.4.tgz",
|
||||
"integrity": "sha512-8GKr640PdFNXwzIE0IrkMWUNUomILLkfeHjXBi/nUvFlpZP+FA8BKGKpacjW6OUUHaNI6sUURxR2U2g78FOHWQ==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz",
|
||||
"integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@ -980,9 +1019,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-ia32-msvc": {
|
||||
"version": "4.52.4",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.52.4.tgz",
|
||||
"integrity": "sha512-AIy/jdJ7WtJ/F6EcfOb2GjR9UweO0n43jNObQMb6oGxkYTfLcnN7vYYpG+CN3lLxrQkzWnMOoNSHTW54pgbVxw==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz",
|
||||
"integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
@ -993,9 +1032,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-x64-gnu": {
|
||||
"version": "4.52.4",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.52.4.tgz",
|
||||
"integrity": "sha512-UF9KfsH9yEam0UjTwAgdK0anlQ7c8/pWPU2yVjyWcF1I1thABt6WXE47cI71pGiZ8wGvxohBoLnxM04L/wj8mQ==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz",
|
||||
"integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@ -1006,9 +1045,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-x64-msvc": {
|
||||
"version": "4.52.4",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.52.4.tgz",
|
||||
"integrity": "sha512-bf9PtUa0u8IXDVxzRToFQKsNCRz9qLYfR/MpECxl4mRoWYjAeFjgxj1XdZr2M/GNVpT05p+LgQOHopYDlUu6/w==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz",
|
||||
"integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@ -1938,9 +1977,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/rollup": {
|
||||
"version": "4.52.4",
|
||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.52.4.tgz",
|
||||
"integrity": "sha512-CLEVl+MnPAiKh5pl4dEWSyMTpuflgNQiLGhMv8ezD5W/qP8AKvmYpCOKRRNOh7oRKnauBZ4SyeYkMS+1VSyKwQ==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz",
|
||||
"integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/estree": "1.0.8"
|
||||
@ -1953,28 +1992,31 @@
|
||||
"npm": ">=8.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@rollup/rollup-android-arm-eabi": "4.52.4",
|
||||
"@rollup/rollup-android-arm64": "4.52.4",
|
||||
"@rollup/rollup-darwin-arm64": "4.52.4",
|
||||
"@rollup/rollup-darwin-x64": "4.52.4",
|
||||
"@rollup/rollup-freebsd-arm64": "4.52.4",
|
||||
"@rollup/rollup-freebsd-x64": "4.52.4",
|
||||
"@rollup/rollup-linux-arm-gnueabihf": "4.52.4",
|
||||
"@rollup/rollup-linux-arm-musleabihf": "4.52.4",
|
||||
"@rollup/rollup-linux-arm64-gnu": "4.52.4",
|
||||
"@rollup/rollup-linux-arm64-musl": "4.52.4",
|
||||
"@rollup/rollup-linux-loong64-gnu": "4.52.4",
|
||||
"@rollup/rollup-linux-ppc64-gnu": "4.52.4",
|
||||
"@rollup/rollup-linux-riscv64-gnu": "4.52.4",
|
||||
"@rollup/rollup-linux-riscv64-musl": "4.52.4",
|
||||
"@rollup/rollup-linux-s390x-gnu": "4.52.4",
|
||||
"@rollup/rollup-linux-x64-gnu": "4.52.4",
|
||||
"@rollup/rollup-linux-x64-musl": "4.52.4",
|
||||
"@rollup/rollup-openharmony-arm64": "4.52.4",
|
||||
"@rollup/rollup-win32-arm64-msvc": "4.52.4",
|
||||
"@rollup/rollup-win32-ia32-msvc": "4.52.4",
|
||||
"@rollup/rollup-win32-x64-gnu": "4.52.4",
|
||||
"@rollup/rollup-win32-x64-msvc": "4.52.4",
|
||||
"@rollup/rollup-android-arm-eabi": "4.59.0",
|
||||
"@rollup/rollup-android-arm64": "4.59.0",
|
||||
"@rollup/rollup-darwin-arm64": "4.59.0",
|
||||
"@rollup/rollup-darwin-x64": "4.59.0",
|
||||
"@rollup/rollup-freebsd-arm64": "4.59.0",
|
||||
"@rollup/rollup-freebsd-x64": "4.59.0",
|
||||
"@rollup/rollup-linux-arm-gnueabihf": "4.59.0",
|
||||
"@rollup/rollup-linux-arm-musleabihf": "4.59.0",
|
||||
"@rollup/rollup-linux-arm64-gnu": "4.59.0",
|
||||
"@rollup/rollup-linux-arm64-musl": "4.59.0",
|
||||
"@rollup/rollup-linux-loong64-gnu": "4.59.0",
|
||||
"@rollup/rollup-linux-loong64-musl": "4.59.0",
|
||||
"@rollup/rollup-linux-ppc64-gnu": "4.59.0",
|
||||
"@rollup/rollup-linux-ppc64-musl": "4.59.0",
|
||||
"@rollup/rollup-linux-riscv64-gnu": "4.59.0",
|
||||
"@rollup/rollup-linux-riscv64-musl": "4.59.0",
|
||||
"@rollup/rollup-linux-s390x-gnu": "4.59.0",
|
||||
"@rollup/rollup-linux-x64-gnu": "4.59.0",
|
||||
"@rollup/rollup-linux-x64-musl": "4.59.0",
|
||||
"@rollup/rollup-openbsd-x64": "4.59.0",
|
||||
"@rollup/rollup-openharmony-arm64": "4.59.0",
|
||||
"@rollup/rollup-win32-arm64-msvc": "4.59.0",
|
||||
"@rollup/rollup-win32-ia32-msvc": "4.59.0",
|
||||
"@rollup/rollup-win32-x64-gnu": "4.59.0",
|
||||
"@rollup/rollup-win32-x64-msvc": "4.59.0",
|
||||
"fsevents": "~2.3.2"
|
||||
}
|
||||
},
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import traceback
|
||||
import uuid
|
||||
from typing import Any
|
||||
from mimetypes import guess_type
|
||||
|
||||
from fastapi import APIRouter, Body, Depends, HTTPException, Query, UploadFile, File
|
||||
@ -508,19 +509,57 @@ async def update_chat_models(model_provider: str, model_names: list[str], curren
|
||||
async def resume_agent_chat(
|
||||
agent_id: str,
|
||||
thread_id: str = Body(...),
|
||||
approved: bool = Body(...),
|
||||
approved: bool | None = Body(None),
|
||||
answer: dict | list | str | None = Body(None),
|
||||
config: dict = Body({}),
|
||||
current_user: User = Depends(get_required_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""恢复被人工审批中断的对话(需要登录)"""
|
||||
logger.info(f"Resuming agent_id: {agent_id}, thread_id: {thread_id}, approved: {approved}")
|
||||
def normalize_resume_input(raw_answer: Any, raw_approved: bool | None) -> Any:
|
||||
if raw_answer is not None:
|
||||
if isinstance(raw_answer, str):
|
||||
normalized = raw_answer.strip()
|
||||
if not normalized:
|
||||
raise HTTPException(status_code=422, detail="answer 不能为空")
|
||||
return normalized
|
||||
|
||||
if isinstance(raw_answer, list):
|
||||
if len(raw_answer) == 0:
|
||||
raise HTTPException(status_code=422, detail="answer 不能为空")
|
||||
return raw_answer
|
||||
|
||||
if isinstance(raw_answer, dict):
|
||||
if raw_answer.get("type") == "other":
|
||||
text = raw_answer.get("text")
|
||||
if not isinstance(text, str) or not text.strip():
|
||||
raise HTTPException(status_code=422, detail="other 文本不能为空")
|
||||
return raw_answer
|
||||
|
||||
raise HTTPException(status_code=422, detail="answer 类型不支持")
|
||||
|
||||
if raw_approved is not None:
|
||||
return "approve" if raw_approved else "reject"
|
||||
|
||||
raise HTTPException(status_code=422, detail="approved 或 answer 至少提供一个")
|
||||
|
||||
resume_input = normalize_resume_input(answer, approved)
|
||||
|
||||
logger.info(
|
||||
"Resuming agent_id: %s, thread_id: %s, approved: %s, answer_type: %s",
|
||||
agent_id,
|
||||
thread_id,
|
||||
approved,
|
||||
type(answer).__name__ if answer is not None else "None",
|
||||
)
|
||||
|
||||
meta = {
|
||||
"agent_id": agent_id,
|
||||
"thread_id": thread_id,
|
||||
"user_id": current_user.id,
|
||||
"approved": approved,
|
||||
"answer": answer,
|
||||
"resume_input": resume_input,
|
||||
}
|
||||
if "request_id" not in meta or not meta.get("request_id"):
|
||||
meta["request_id"] = str(uuid.uuid4())
|
||||
@ -528,7 +567,7 @@ async def resume_agent_chat(
|
||||
stream_agent_resume(
|
||||
agent_id=agent_id,
|
||||
thread_id=thread_id,
|
||||
approved=approved,
|
||||
resume_input=resume_input,
|
||||
meta=meta,
|
||||
config=config,
|
||||
current_user=current_user,
|
||||
@ -667,6 +706,7 @@ class ThreadResponse(BaseModel):
|
||||
user_id: str
|
||||
agent_id: str
|
||||
title: str | None = None
|
||||
is_pinned: bool = False
|
||||
created_at: str
|
||||
updated_at: str
|
||||
|
||||
@ -737,10 +777,16 @@ async def create_thread(
|
||||
|
||||
@chat.get("/threads", response_model=list[ThreadResponse])
|
||||
async def list_threads(
|
||||
agent_id: str, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_required_user)
|
||||
agent_id: str,
|
||||
limit: int = Query(100, ge=1, le=500),
|
||||
offset: int = Query(0, ge=0),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_required_user),
|
||||
):
|
||||
"""获取用户的所有对话线程 (使用新存储系统)"""
|
||||
return await list_threads_view(agent_id=agent_id, db=db, current_user_id=str(current_user.id))
|
||||
return await list_threads_view(
|
||||
agent_id=agent_id, db=db, current_user_id=str(current_user.id), limit=limit, offset=offset
|
||||
)
|
||||
|
||||
|
||||
@chat.delete("/thread/{thread_id}")
|
||||
@ -753,6 +799,7 @@ async def delete_thread(
|
||||
|
||||
class ThreadUpdate(BaseModel):
|
||||
title: str | None = None
|
||||
is_pinned: bool | None = None
|
||||
|
||||
|
||||
@chat.put("/thread/{thread_id}", response_model=ThreadResponse)
|
||||
@ -766,6 +813,7 @@ async def update_thread(
|
||||
return await update_thread_view(
|
||||
thread_id=thread_id,
|
||||
title=thread_update.title,
|
||||
is_pinned=thread_update.is_pinned,
|
||||
db=db,
|
||||
current_user_id=str(current_user.id),
|
||||
)
|
||||
|
||||
@ -7,7 +7,7 @@ from concurrent.futures import ThreadPoolExecutor # noqa: E402
|
||||
|
||||
from src.config import config as config # noqa: E402
|
||||
|
||||
__version__ = "0.5.0.dev"
|
||||
__version__ = "0.5.1"
|
||||
|
||||
if os.getenv("YUXI_SKIP_APP_INIT") != "1":
|
||||
from src.knowledge import graph_base as graph_base # noqa: E402
|
||||
|
||||
@ -93,7 +93,8 @@ class BaseContext:
|
||||
metadata={
|
||||
"name": "Skills",
|
||||
"options": [],
|
||||
"description": "可选技能列表(由超级管理员维护)。运行时仅挂载并只读暴露选中的 skills。",
|
||||
"description": "可选技能列表(由超级管理员维护)。运行时仅挂载并只读暴露选中的 "
|
||||
"skills。技能依赖的工具和 MCP 服务器也会被自动挂载。",
|
||||
"type": "list",
|
||||
},
|
||||
)
|
||||
|
||||
@ -13,13 +13,13 @@ from langchain.tools.tool_node import ToolCallRequest
|
||||
from langgraph.types import Command
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.agents.common.toolkits import get_all_tool_instances
|
||||
from src.repositories.skill_repository import SkillRepository
|
||||
from src.services.mcp_service import get_enabled_mcp_tools
|
||||
from src.services.skill_service import _normalize_string_list, is_valid_skill_slug
|
||||
from src.storage.postgres.manager import pg_manager
|
||||
from src.utils.logging_config import logger
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 类型定义
|
||||
# =============================================================================
|
||||
@ -171,6 +171,21 @@ class SkillsMiddleware(AgentMiddleware):
|
||||
self.skills_context_name = skills_context_name
|
||||
self.enable_skills_prompt = enable_skills_prompt
|
||||
self.skills_sources_for_prompt = skills_sources_for_prompt or ["/skills/"]
|
||||
# 实例级缓存:避免每次模型调用都查数据库
|
||||
self._dependency_map_cache: dict[str, SkillDependencyNode] | None = None
|
||||
self._prompt_metadata_cache: dict[str, SkillPromptMetadata] | None = None
|
||||
|
||||
async def _get_dependency_map_cached(self) -> dict[str, SkillDependencyNode]:
|
||||
"""获取依赖映射(带缓存)"""
|
||||
if self._dependency_map_cache is None:
|
||||
self._dependency_map_cache = await get_dependency_map()
|
||||
return self._dependency_map_cache
|
||||
|
||||
async def _get_prompt_metadata_cached(self) -> dict[str, SkillPromptMetadata]:
|
||||
"""获取提示词元数据(带缓存)"""
|
||||
if self._prompt_metadata_cache is None:
|
||||
self._prompt_metadata_cache = await get_prompt_metadata()
|
||||
return self._prompt_metadata_cache
|
||||
|
||||
async def abefore_agent(self, state: SkillsState, runtime) -> dict[str, Any] | None:
|
||||
"""在 agent 执行前注入 skills 提示词"""
|
||||
@ -182,8 +197,8 @@ class SkillsMiddleware(AgentMiddleware):
|
||||
if getattr(runtime_context, "_skills_prompt_injected", False):
|
||||
return None
|
||||
|
||||
# 从数据库加载 skills 数据
|
||||
dependency_map = await get_dependency_map()
|
||||
# 从数据库加载 skills 数据(使用缓存)
|
||||
dependency_map = await self._get_dependency_map_cached()
|
||||
|
||||
# 获取配置的 skills
|
||||
configured_skills = getattr(runtime_context, self.skills_context_name, None) or []
|
||||
@ -219,8 +234,8 @@ class SkillsMiddleware(AgentMiddleware):
|
||||
"""包装模型调用,处理动态激活和依赖展开"""
|
||||
runtime_context = request.runtime.context
|
||||
|
||||
# 从数据库加载 skills 数据
|
||||
dependency_map = await get_dependency_map()
|
||||
# 从缓存加载 skills 数据
|
||||
dependency_map = await self._get_dependency_map_cached()
|
||||
|
||||
# 1. 获取配置的 skills
|
||||
configured_skills = getattr(runtime_context, self.skills_context_name, None) or []
|
||||
@ -239,40 +254,47 @@ class SkillsMiddleware(AgentMiddleware):
|
||||
# 4. 更新 runtime_context 中的 visible_skills
|
||||
setattr(runtime_context, "_visible_skills", visible_skills)
|
||||
|
||||
# 5. 构建依赖包
|
||||
deps_bundle = await self._build_dependency_bundle(visible_skills)
|
||||
# 5. 构建依赖包(只从直接激活的 skills 获取依赖,不包含闭包展开的依赖)
|
||||
deps_bundle = await self._build_dependency_bundle(activated)
|
||||
|
||||
# 6. 加载依赖的工具
|
||||
if deps_bundle["tools"] or deps_bundle["mcps"]:
|
||||
enabled_tools = await self._get_tools_from_context(
|
||||
# 6. 加载依赖的工具(普通工具 + MCP 工具)
|
||||
enabled_tools = []
|
||||
|
||||
# 6.1 从 toolkits 获取普通工具
|
||||
if deps_bundle["tools"]:
|
||||
all_tools = get_all_tool_instances()
|
||||
required_tool_names = set(deps_bundle["tools"])
|
||||
enabled_tools = [t for t in all_tools if t.name in required_tool_names]
|
||||
|
||||
# 6.2 获取 MCP 工具
|
||||
if deps_bundle["mcps"]:
|
||||
mcp_tools = await self._get_mcp_tools_from_context(
|
||||
runtime_context,
|
||||
extra_tool_names=deps_bundle["tools"],
|
||||
extra_mcps=deps_bundle["mcps"],
|
||||
)
|
||||
enabled_tools.extend(mcp_tools)
|
||||
|
||||
# 合并工具
|
||||
if enabled_tools:
|
||||
existing_tools = list(request.tools or [])
|
||||
enabled_tool_names = {t.name for t in enabled_tools}
|
||||
merged_tools = []
|
||||
for t_bind in existing_tools:
|
||||
if t_bind.name in enabled_tool_names:
|
||||
merged_tools.append(t_bind)
|
||||
if merged_tools:
|
||||
request = request.override(tools=merged_tools)
|
||||
# 合并工具:保留原有工具 + 追加依赖的新工具
|
||||
if enabled_tools:
|
||||
existing_tool_names = {t.name for t in request.tools or []}
|
||||
merged_tools = list(request.tools or [])
|
||||
for t in enabled_tools:
|
||||
if t.name not in existing_tool_names:
|
||||
merged_tools.append(t)
|
||||
request = request.override(tools=merged_tools)
|
||||
|
||||
return await handler(request)
|
||||
|
||||
async def _build_dependency_bundle(self, visible_skills: list[str]) -> dict[str, list[str]]:
|
||||
"""根据 visible_skills 构建依赖包"""
|
||||
dependency_map = await get_dependency_map()
|
||||
async def _build_dependency_bundle(self, activated_skills: list[str]) -> dict[str, list[str]]:
|
||||
"""根据直接激活的 skills 构建依赖包(不包含闭包展开的依赖)"""
|
||||
dependency_map = await self._get_dependency_map_cached()
|
||||
|
||||
tools: list[str] = []
|
||||
mcps: list[str] = []
|
||||
seen_tools: set[str] = set()
|
||||
seen_mcps: set[str] = set()
|
||||
|
||||
for slug in visible_skills:
|
||||
for slug in activated_skills:
|
||||
dep = dependency_map.get(slug, {})
|
||||
for tool_name in dep.get("tools", []):
|
||||
if tool_name in seen_tools:
|
||||
@ -285,11 +307,11 @@ class SkillsMiddleware(AgentMiddleware):
|
||||
seen_mcps.add(mcp_name)
|
||||
mcps.append(mcp_name)
|
||||
|
||||
return {"tools": tools, "mcps": mcps, "skills": visible_skills}
|
||||
return {"tools": tools, "mcps": mcps, "skills": activated_skills}
|
||||
|
||||
async def _collect_prompt_metadata(self, slugs: list[str]) -> list[SkillPromptMetadata]:
|
||||
"""收集指定 slugs 的提示词元数据"""
|
||||
prompt_metadata = await get_prompt_metadata()
|
||||
prompt_metadata = await self._get_prompt_metadata_cached()
|
||||
|
||||
result: list[SkillPromptMetadata] = []
|
||||
seen: set[str] = set()
|
||||
@ -310,28 +332,16 @@ class SkillsMiddleware(AgentMiddleware):
|
||||
|
||||
return result
|
||||
|
||||
async def _get_tools_from_context(
|
||||
async def _get_mcp_tools_from_context(
|
||||
self,
|
||||
context,
|
||||
*,
|
||||
extra_tool_names: list[str] | None = None,
|
||||
extra_mcps: list[str] | None = None,
|
||||
) -> list:
|
||||
"""从上下文配置中获取工具列表"""
|
||||
"""从上下文配置中获取 MCP 工具列表"""
|
||||
import asyncio
|
||||
|
||||
selected_tools = []
|
||||
|
||||
# 1. 工具(从 extra_tool_names 获取)
|
||||
all_tool_names: list[str] = []
|
||||
for tool_name in extra_tool_names or []:
|
||||
if isinstance(tool_name, str):
|
||||
all_tool_names.append(tool_name)
|
||||
|
||||
# 这里简化处理:假设工具已经在其他 middleware 中加载
|
||||
# SkillsMiddleware 主要负责 MCP 工具的加载
|
||||
|
||||
# 2. MCP 工具(并行加载)
|
||||
# MCP 工具(并行加载)
|
||||
mcps = getattr(context, "mcps", None) or []
|
||||
all_mcp_names: list[str] = []
|
||||
for server_name in mcps:
|
||||
@ -357,6 +367,7 @@ class SkillsMiddleware(AgentMiddleware):
|
||||
|
||||
# 并行加载所有 MCP 工具
|
||||
results = await asyncio.gather(*[load_mcp_tools(name) for name in unique_mcp_names])
|
||||
selected_tools = []
|
||||
for tools in results:
|
||||
selected_tools.extend(tools)
|
||||
|
||||
|
||||
@ -1,7 +1,8 @@
|
||||
# buildin 工具包
|
||||
from .tools import calculator, query_knowledge_graph, text_to_img_qwen_image
|
||||
from .tools import ask_user_question, calculator, query_knowledge_graph, text_to_img_qwen_image
|
||||
|
||||
__all__ = [
|
||||
"ask_user_question",
|
||||
"calculator",
|
||||
"query_knowledge_graph",
|
||||
"text_to_img_qwen_image",
|
||||
|
||||
@ -4,10 +4,10 @@ import uuid
|
||||
from typing import Annotated, Any
|
||||
|
||||
import requests
|
||||
from langgraph.types import interrupt
|
||||
|
||||
from src import config, graph_base
|
||||
from src.agents.common.toolkits.registry import tool
|
||||
from src.agents.common.toolkits.registry import ToolExtraMetadata, _all_tool_instances, _extra_registry
|
||||
from src.agents.common.toolkits.registry import ToolExtraMetadata, _all_tool_instances, _extra_registry, tool
|
||||
from src.storage.minio import aupload_file_to_minio
|
||||
from src.utils import logger
|
||||
|
||||
@ -69,6 +69,73 @@ def calculator(a: float, b: float, operation: str) -> float:
|
||||
raise
|
||||
|
||||
|
||||
ASK_USER_QUESTION_DESCRIPTION = """
|
||||
在执行过程中,当你需要用户做决定或补充需求时,使用这个工具向用户提问。
|
||||
|
||||
适用场景:
|
||||
1. 收集用户偏好或需求(例如风格、范围、优先级)
|
||||
2. 澄清模糊指令(存在多种合理解释时)
|
||||
3. 在实现过程中让用户选择方案方向
|
||||
4. 在有明显权衡时让用户做取舍
|
||||
|
||||
使用规范:
|
||||
1. 问题应当简短、具体、可回答,避免开放式长问句
|
||||
2. options 提供 2-5 个有区分度的选项,每项包含 label 和 value
|
||||
3. 若有推荐选项:把推荐项放在第一位,并在 label 末尾加 "(Recommended)"
|
||||
4. 若需要多选:将 multi_select 设为 true
|
||||
5. allow_other 通常保持 true,用户可通过 Other 输入自定义答案
|
||||
|
||||
注意事项:
|
||||
1. 不要用这个工具询问“是否继续执行”“计划是否准备好”这类流程控制问题
|
||||
2. 不要在信息已充分、无需用户决策时滥用该工具
|
||||
3. 先基于现有上下文自行决策,只有关键不确定性时才提问
|
||||
|
||||
返回结果:
|
||||
answer 可能是 string(单选)、list(多选)或 object(Other 文本)。
|
||||
"""
|
||||
|
||||
|
||||
@tool(
|
||||
category="buildin",
|
||||
tags=["交互"],
|
||||
display_name="向用户提问",
|
||||
description=ASK_USER_QUESTION_DESCRIPTION,
|
||||
)
|
||||
def ask_user_question(
|
||||
question: Annotated[str, "向用户展示的问题"],
|
||||
options: Annotated[list[dict], "候选项列表,格式 [{label, value}],推荐项请在 label 后追加 (Recommended)"],
|
||||
multi_select: Annotated[bool, "是否允许多选"] = False,
|
||||
allow_other: Annotated[bool, "是否允许用户输入 Other 自定义答案"] = True,
|
||||
) -> dict:
|
||||
"""向用户发起问题并等待回答。"""
|
||||
normalized_options: list[dict[str, str]] = []
|
||||
for item in options or []:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
label = str(item.get("label") or item.get("value") or "").strip()
|
||||
value = str(item.get("value") or item.get("label") or "").strip()
|
||||
if label and value:
|
||||
normalized_options.append({"label": label, "value": value})
|
||||
|
||||
interrupt_payload = {
|
||||
"question": question,
|
||||
"question_id": str(uuid.uuid4()),
|
||||
"options": normalized_options,
|
||||
"multi_select": multi_select,
|
||||
"allow_other": allow_other,
|
||||
"source": "ask_user_question",
|
||||
}
|
||||
answer = interrupt(interrupt_payload)
|
||||
|
||||
return {
|
||||
"question": question,
|
||||
"question_id": interrupt_payload["question_id"],
|
||||
"answer": answer,
|
||||
"multi_select": multi_select,
|
||||
"allow_other": allow_other,
|
||||
}
|
||||
|
||||
|
||||
KG_QUERY_DESCRIPTION = """
|
||||
使用这个工具可以查询知识图谱中包含的三元组信息。
|
||||
关键词(query),使用可能帮助回答这个问题的关键词进行查询,不要直接使用用户的原始输入去查询。
|
||||
|
||||
@ -1,6 +1,3 @@
|
||||
# debug 工具包
|
||||
from .tools import get_approved_user_goal
|
||||
|
||||
__all__ = [
|
||||
"get_approved_user_goal",
|
||||
]
|
||||
__all__ = []
|
||||
|
||||
@ -1,41 +0,0 @@
|
||||
from langgraph.types import interrupt
|
||||
|
||||
from src.agents.common.toolkits.registry import tool
|
||||
|
||||
|
||||
@tool(category="debug", tags=["内置", "审批"], display_name="人工审批")
|
||||
def get_approved_user_goal(
|
||||
operation_description: str,
|
||||
) -> dict:
|
||||
"""
|
||||
请求人工审批,在执行重要操作前获得人类确认。
|
||||
|
||||
Args:
|
||||
operation_description: 需要审批的操作描述,例如 "调用知识库工具"
|
||||
Returns:
|
||||
dict: 包含审批结果的字典,格式为 {"approved": bool, "message": str}
|
||||
"""
|
||||
# 构建详细的中断信息
|
||||
interrupt_info = {
|
||||
"question": "是否批准以下操作?",
|
||||
"operation": operation_description,
|
||||
}
|
||||
|
||||
# 触发人工审批
|
||||
is_approved = interrupt(interrupt_info)
|
||||
|
||||
# 返回审批结果
|
||||
if is_approved:
|
||||
result = {
|
||||
"approved": True,
|
||||
"message": f"✅ 操作已批准:{operation_description}",
|
||||
}
|
||||
print(f"✅ 人工审批通过: {operation_description}")
|
||||
else:
|
||||
result = {
|
||||
"approved": False,
|
||||
"message": f"❌ 操作被拒绝:{operation_description}",
|
||||
}
|
||||
print(f"❌ 人工审批被拒绝: {operation_description}")
|
||||
|
||||
return result
|
||||
@ -46,18 +46,11 @@ def get_connection_manager() -> MySQLConnectionManager:
|
||||
return _connection_manager
|
||||
|
||||
|
||||
class TableListModel(BaseModel):
|
||||
"""获取表名列表的参数模型"""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
@tool(
|
||||
category="mysql",
|
||||
tags=["数据库", "查询"],
|
||||
display_name="列出MySQL表",
|
||||
name_or_callable="mysql_list_tables",
|
||||
args_schema=TableListModel,
|
||||
)
|
||||
def mysql_list_tables() -> str:
|
||||
"""【查询表名及说明】获取数据库中的所有表名
|
||||
|
||||
@ -43,7 +43,7 @@ def tool(
|
||||
name_or_callable: str | Callable | None = None,
|
||||
description: str | None = None,
|
||||
args_schema: type | None = None,
|
||||
return_direct: bool = True,
|
||||
return_direct: bool = False,
|
||||
):
|
||||
"""基于 langchain.tool 的拓展装饰器,同时注册元数据
|
||||
|
||||
|
||||
@ -101,7 +101,7 @@ class DeepContext(BaseContext):
|
||||
)
|
||||
|
||||
subagents_model: Annotated[str, {"__template_metadata__": {"kind": "llm"}}] = field(
|
||||
default="siliconflow/deepseek-ai/DeepSeek-V3.2",
|
||||
default="siliconflow/Pro/deepseek-ai/DeepSeek-V3.2",
|
||||
metadata={
|
||||
"name": "Sub-agent Model",
|
||||
"description": "The model used by sub-agents (e.g., critique-agent, research-agent).",
|
||||
|
||||
28
src/agents/skills/reporter/SKILLS.md
Normal file
28
src/agents/skills/reporter/SKILLS.md
Normal file
@ -0,0 +1,28 @@
|
||||
---
|
||||
name: sql-reporter
|
||||
description: "生成 SQL 查询报表并生成可视化图表。当用户需要查询数据库并以报表形式展示结果时使用此技能,包括:统计销售数据、分析用户行为、生成业务报表、查询业务指标等。"
|
||||
---
|
||||
|
||||
# SQL 报表技能
|
||||
|
||||
根据用户的指令,使用数据库工具和图表绘制工具,构建 SQL 查询报告。
|
||||
|
||||
## 操作流程
|
||||
|
||||
1. 理解用户的指令,明确报表的需求和目标
|
||||
2. 使用 MySQL 工具生成正确的 SQL 查询
|
||||
3. 执行查询并获取结果
|
||||
4. 使用 Charts MCP 生成图表
|
||||
5. 将图表以 markdown 图片格式嵌入报表
|
||||
|
||||
## 关键约束
|
||||
|
||||
- 生成的 SQL 查询必须正确且高效,避免全表扫描
|
||||
- 图表生成工具的返回结果不会默认渲染,必须在最终报表中以 `` 格式嵌入
|
||||
- 只返回报表相关的结论,不要返回原始 SQL 查询语句
|
||||
|
||||
## 允许的工具
|
||||
|
||||
- MySQL 工具:执行 SQL 查询
|
||||
- Charts MCP:生成可视化图表
|
||||
- 网络检索工具:必要时补充背景信息
|
||||
@ -1,4 +1,11 @@
|
||||
"""Application configuration."""
|
||||
"""
|
||||
应用配置模块
|
||||
|
||||
使用 Pydantic BaseModel 实现配置管理,支持:
|
||||
- 从 TOML 文件加载用户配置
|
||||
- 仅保存用户修改过的配置项
|
||||
- 默认配置定义在代码中
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@ -22,48 +29,100 @@ from src.utils.logging_config import logger
|
||||
|
||||
|
||||
class Config(BaseModel):
|
||||
save_dir: str = Field(default="saves", description="Storage root directory")
|
||||
model_dir: str = Field(default="", description="Local model directory")
|
||||
"""应用配置类"""
|
||||
|
||||
enable_reranker: bool = Field(default=False)
|
||||
enable_content_guard: bool = Field(default=False)
|
||||
enable_content_guard_llm: bool = Field(default=False)
|
||||
enable_web_search: bool = Field(default=False)
|
||||
# ============================================================
|
||||
# 基础配置
|
||||
# ============================================================
|
||||
save_dir: str = Field(default="saves", description="保存目录")
|
||||
model_dir: str = Field(default="", description="本地模型目录")
|
||||
|
||||
default_model: str = Field(default="siliconflow/deepseek-ai/DeepSeek-V3.2")
|
||||
fast_model: str = Field(default="siliconflow/THUDM/GLM-4-9B-0414")
|
||||
embed_model: str = Field(default="siliconflow/BAAI/bge-m3")
|
||||
reranker: str = Field(default="siliconflow/BAAI/bge-reranker-v2-m3")
|
||||
content_guard_llm_model: str = Field(default="siliconflow/Qwen/Qwen3-235B-A22B-Instruct-2507")
|
||||
# ============================================================
|
||||
# 功能开关
|
||||
# ============================================================
|
||||
enable_reranker: bool = Field(default=False, description="是否开启重排序")
|
||||
enable_content_guard: bool = Field(default=False, description="是否启用内容审查")
|
||||
enable_content_guard_llm: bool = Field(default=False, description="是否启用LLM内容审查")
|
||||
enable_web_search: bool = Field(default=False, description="是否启用网络搜索")
|
||||
|
||||
default_agent_id: str = Field(default="")
|
||||
# ============================================================
|
||||
# 模型配置
|
||||
# ============================================================
|
||||
default_model: str = Field(
|
||||
default="siliconflow/Pro/deepseek-ai/DeepSeek-V3.2",
|
||||
description="默认对话模型",
|
||||
)
|
||||
fast_model: str = Field(
|
||||
default="siliconflow/Qwen/Qwen3.5-9B",
|
||||
description="快速响应模型",
|
||||
)
|
||||
embed_model: str = Field(
|
||||
default="siliconflow/Pro/BAAI/bge-m3",
|
||||
description="默认 Embedding 模型",
|
||||
)
|
||||
reranker: str = Field(
|
||||
default="siliconflow/Pro/BAAI/bge-reranker-v2-m3",
|
||||
description="默认 Re-Ranker 模型",
|
||||
)
|
||||
content_guard_llm_model: str = Field(
|
||||
default="siliconflow/Qwen/Qwen3.5-9B",
|
||||
description="内容审查LLM模型",
|
||||
)
|
||||
|
||||
sandbox_provider: str = Field(default="provisioner")
|
||||
sandbox_provisioner_url: str = Field(default="http://sandbox-provisioner:8002")
|
||||
sandbox_virtual_path_prefix: str = Field(default="/mnt/user-data")
|
||||
sandbox_exec_timeout_seconds: int = Field(default=180)
|
||||
sandbox_max_output_bytes: int = Field(default=262144)
|
||||
sandbox_keepalive_interval_seconds: int = Field(default=30)
|
||||
# ============================================================
|
||||
# 智能体配置
|
||||
# ============================================================
|
||||
default_agent_id: str = Field(default="ChatbotAgent", description="默认智能体ID")
|
||||
|
||||
# ============================================================
|
||||
# Sandbox 配置
|
||||
# ============================================================
|
||||
sandbox_provider: str = Field(default="provisioner", description="沙箱提供者")
|
||||
sandbox_provisioner_url: str = Field(
|
||||
default="http://sandbox-provisioner:8002", description="沙箱服务地址"
|
||||
)
|
||||
sandbox_virtual_path_prefix: str = Field(default="/mnt/user-data", description="沙箱虚拟路径前缀")
|
||||
sandbox_exec_timeout_seconds: int = Field(default=180, description="沙箱执行超时时间(秒)")
|
||||
sandbox_max_output_bytes: int = Field(default=262144, description="沙箱最大输出字节数")
|
||||
sandbox_keepalive_interval_seconds: int = Field(default=30, description="沙箱保活间隔(秒)")
|
||||
|
||||
# ============================================================
|
||||
# 模型信息(只读,不持久化)
|
||||
# ============================================================
|
||||
model_names: dict[str, ChatModelProvider] = Field(
|
||||
default_factory=lambda: DEFAULT_CHAT_MODEL_PROVIDERS.copy(),
|
||||
description="聊天模型提供商配置",
|
||||
exclude=True,
|
||||
)
|
||||
embed_model_names: dict[str, EmbedModelInfo] = Field(
|
||||
default_factory=lambda: DEFAULT_EMBED_MODELS.copy(),
|
||||
description="嵌入模型配置",
|
||||
exclude=True,
|
||||
)
|
||||
reranker_names: dict[str, RerankerInfo] = Field(
|
||||
default_factory=lambda: DEFAULT_RERANKERS.copy(),
|
||||
description="重排序模型配置",
|
||||
exclude=True,
|
||||
)
|
||||
|
||||
model_provider_status: dict[str, bool] = Field(default_factory=dict, exclude=True)
|
||||
valuable_model_provider: list[str] = Field(default_factory=list, exclude=True)
|
||||
# ============================================================
|
||||
# 运行时状态(不持久化)
|
||||
# ============================================================
|
||||
model_provider_status: dict[str, bool] = Field(
|
||||
default_factory=dict,
|
||||
description="模型提供商可用状态",
|
||||
exclude=True,
|
||||
)
|
||||
valuable_model_provider: list[str] = Field(
|
||||
default_factory=list,
|
||||
description="可用的模型提供商列表",
|
||||
exclude=True,
|
||||
)
|
||||
|
||||
# 内部状态
|
||||
_config_file: Path | None = PrivateAttr(default=None)
|
||||
_user_modified_fields: set[str] = PrivateAttr(default_factory=set)
|
||||
_modified_providers: set[str] = PrivateAttr(default_factory=set)
|
||||
_modified_providers: set[str] = PrivateAttr(default_factory=set) # 记录具体修改的模型提供商
|
||||
|
||||
model_config = {"arbitrary_types_allowed": True, "extra": "allow"}
|
||||
|
||||
@ -75,91 +134,131 @@ class Config(BaseModel):
|
||||
self._handle_environment()
|
||||
|
||||
def _setup_paths(self) -> None:
|
||||
"""设置配置文件路径"""
|
||||
self.save_dir = os.getenv("SAVE_DIR") or self.save_dir
|
||||
self._config_file = Path(self.save_dir) / "config" / "base.toml"
|
||||
self._config_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def _load_user_config(self) -> None:
|
||||
"""从 TOML 文件加载用户配置"""
|
||||
if not self._config_file or not self._config_file.exists():
|
||||
logger.info(f"Config file not found, using defaults: {self._config_file}")
|
||||
return
|
||||
|
||||
logger.info(f"Loading config from {self._config_file}")
|
||||
try:
|
||||
with self._config_file.open("rb") as file:
|
||||
user_config = tomli.load(file)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.error(f"Failed to load config from {self._config_file}: {exc}")
|
||||
return
|
||||
with open(self._config_file, "rb") as f:
|
||||
user_config = tomli.load(f)
|
||||
|
||||
self._user_modified_fields = set(user_config.keys())
|
||||
for key, value in user_config.items():
|
||||
if key == "model_names":
|
||||
self._load_model_names(value)
|
||||
elif key in self.model_fields:
|
||||
setattr(self, key, value)
|
||||
else:
|
||||
logger.warning(f"Unknown config key: {key}")
|
||||
# 记录用户修改的字段
|
||||
self._user_modified_fields = set(user_config.keys())
|
||||
|
||||
# 更新配置
|
||||
for key, value in user_config.items():
|
||||
if key == "model_names":
|
||||
# 特殊处理模型配置
|
||||
self._load_model_names(value)
|
||||
elif hasattr(self, key):
|
||||
setattr(self, key, value)
|
||||
else:
|
||||
logger.warning(f"Unknown config key: {key}")
|
||||
|
||||
# 确保默认智能体为 ChatbotAgent(兼容旧配置)
|
||||
if not self.default_agent_id:
|
||||
self.default_agent_id = "ChatbotAgent"
|
||||
logger.info("default_agent_id not set, using default: ChatbotAgent")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to load config from {self._config_file}: {e}")
|
||||
|
||||
def _load_model_names(self, model_names_data: dict[str, Any]) -> None:
|
||||
"""加载用户自定义的模型配置"""
|
||||
for provider, provider_data in (model_names_data or {}).items():
|
||||
try:
|
||||
if provider in self.model_names:
|
||||
# 合并现有提供商的配置
|
||||
merged = self.model_names[provider].model_dump() | dict(provider_data or {})
|
||||
self.model_names[provider] = ChatModelProvider(**merged)
|
||||
else:
|
||||
# 添加新的提供商
|
||||
self.model_names[provider] = ChatModelProvider(**provider_data)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.warning(f"Skip invalid model provider config {provider}: {exc}")
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.warning(f"Skip invalid model provider config {provider}: {e}")
|
||||
|
||||
def _load_custom_providers(self) -> None:
|
||||
if not self._config_file:
|
||||
return
|
||||
"""从独立的TOML文件加载自定义供应商配置"""
|
||||
custom_config_file = self._config_file.parent / "custom_providers.toml"
|
||||
|
||||
if not custom_config_file.exists():
|
||||
logger.info(f"Custom providers config file not found: {custom_config_file}")
|
||||
return
|
||||
|
||||
logger.info(f"Loading custom providers from {custom_config_file}")
|
||||
try:
|
||||
with custom_config_file.open("rb") as file:
|
||||
custom_config = tomli.load(file)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.error(f"Failed to load custom providers from {custom_config_file}: {exc}")
|
||||
return
|
||||
with open(custom_config_file, "rb") as f:
|
||||
custom_config = tomli.load(f)
|
||||
|
||||
model_names = custom_config.get("model_names") or {}
|
||||
self._load_custom_model_providers(model_names)
|
||||
# 加载自定义供应商
|
||||
if "model_names" in custom_config:
|
||||
self._load_custom_model_providers(custom_config["model_names"])
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to load custom providers from {custom_config_file}: {e}")
|
||||
|
||||
def _load_custom_model_providers(self, providers_data: dict[str, Any]) -> None:
|
||||
"""加载自定义模型供应商"""
|
||||
for provider, provider_data in (providers_data or {}).items():
|
||||
try:
|
||||
payload = dict(provider_data or {})
|
||||
payload["custom"] = True
|
||||
self.model_names[provider] = ChatModelProvider(**payload)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.warning(f"Skip invalid custom provider {provider}: {exc}")
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.warning(f"Skip invalid custom provider {provider}: {e}")
|
||||
|
||||
def _handle_environment(self) -> None:
|
||||
"""处理环境变量和运行时状态"""
|
||||
# 处理模型目录
|
||||
self.model_dir = os.environ.get("MODEL_DIR") or self.model_dir
|
||||
if self.model_dir:
|
||||
if os.path.exists(self.model_dir):
|
||||
logger.debug(f"Model directory ({self.model_dir}) contains: {os.listdir(self.model_dir)}")
|
||||
else:
|
||||
logger.warning(
|
||||
f"Model directory ({self.model_dir}) does not exist. If not configured, please ignore it."
|
||||
)
|
||||
|
||||
# 检查模型提供商的环境变量
|
||||
self.model_provider_status = {}
|
||||
for provider, info in self.model_names.items():
|
||||
env_var = (info.env or "").strip()
|
||||
if env_var.upper() == "NO_API_KEY":
|
||||
self.model_provider_status[provider] = True
|
||||
continue
|
||||
api_key = os.environ.get(env_var)
|
||||
self.model_provider_status[provider] = bool(api_key or info.custom)
|
||||
env_var = info.env
|
||||
|
||||
if env_var == "NO_API_KEY":
|
||||
self.model_provider_status[provider] = True
|
||||
else:
|
||||
api_key = os.environ.get(env_var)
|
||||
# 如果获取到的值与环境变量名不同,说明环境变量存在或配置了直接值
|
||||
self.model_provider_status[provider] = bool(api_key or info.custom)
|
||||
|
||||
# 检查网络搜索
|
||||
if os.getenv("TAVILY_API_KEY"):
|
||||
self.enable_web_search = True
|
||||
|
||||
self.valuable_model_provider = [key for key, ok in self.model_provider_status.items() if ok]
|
||||
# 获取可用的模型提供商
|
||||
self.valuable_model_provider = [k for k, v in self.model_provider_status.items() if v]
|
||||
|
||||
self.sandbox_provider = (os.getenv("SANDBOX_PROVIDER") or self.sandbox_provider or "provisioner").strip()
|
||||
# 处理 Sandbox 配置
|
||||
self.sandbox_provider = (
|
||||
os.getenv("SANDBOX_PROVIDER") or self.sandbox_provider or "provisioner"
|
||||
).strip()
|
||||
self.sandbox_provisioner_url = (
|
||||
os.getenv("SANDBOX_PROVISIONER_URL") or self.sandbox_provisioner_url or "http://sandbox-provisioner:8002"
|
||||
os.getenv("SANDBOX_PROVISIONER_URL")
|
||||
or self.sandbox_provisioner_url
|
||||
or "http://sandbox-provisioner:8002"
|
||||
).strip()
|
||||
self.sandbox_virtual_path_prefix = (
|
||||
os.getenv("SANDBOX_VIRTUAL_PATH_PREFIX") or self.sandbox_virtual_path_prefix or "/mnt/user-data"
|
||||
os.getenv("SANDBOX_VIRTUAL_PATH_PREFIX")
|
||||
or self.sandbox_virtual_path_prefix
|
||||
or "/mnt/user-data"
|
||||
).strip()
|
||||
self.sandbox_exec_timeout_seconds = int(
|
||||
os.getenv("SANDBOX_EXEC_TIMEOUT_SECONDS") or self.sandbox_exec_timeout_seconds or 180
|
||||
@ -168,9 +267,12 @@ class Config(BaseModel):
|
||||
os.getenv("SANDBOX_MAX_OUTPUT_BYTES") or self.sandbox_max_output_bytes or 262144
|
||||
)
|
||||
self.sandbox_keepalive_interval_seconds = int(
|
||||
os.getenv("SANDBOX_KEEPALIVE_INTERVAL_SECONDS") or self.sandbox_keepalive_interval_seconds or 30
|
||||
os.getenv("SANDBOX_KEEPALIVE_INTERVAL_SECONDS")
|
||||
or self.sandbox_keepalive_interval_seconds
|
||||
or 30
|
||||
)
|
||||
|
||||
# 验证 Sandbox 配置
|
||||
if self.sandbox_provider.lower() != "provisioner":
|
||||
raise ValueError("Only sandbox_provider=provisioner is supported.")
|
||||
if not self.sandbox_provisioner_url:
|
||||
@ -182,27 +284,41 @@ class Config(BaseModel):
|
||||
raise ValueError("No model provider available, please check your `.env` file.")
|
||||
|
||||
def save(self) -> None:
|
||||
"""保存配置到 TOML 文件(仅保存用户修改的字段)"""
|
||||
if not self._config_file:
|
||||
logger.warning("Config file path not set")
|
||||
return
|
||||
|
||||
logger.info(f"Saving config to {self._config_file}")
|
||||
|
||||
# 获取默认配置
|
||||
default_config = Config.model_construct()
|
||||
user_modified: dict[str, Any] = {}
|
||||
for field_name, field_info in self.model_fields.items():
|
||||
|
||||
# 对比当前配置和默认配置,找出用户修改的字段
|
||||
user_modified = {}
|
||||
for field_name in self.model_fields.keys():
|
||||
# 跳过 exclude=True 的字段
|
||||
field_info = self.model_fields[field_name]
|
||||
if field_info.exclude:
|
||||
continue
|
||||
|
||||
current_value = getattr(self, field_name)
|
||||
default_value = getattr(default_config, field_name)
|
||||
|
||||
# 如果值不同,说明用户修改了
|
||||
if current_value != default_value:
|
||||
user_modified[field_name] = current_value
|
||||
|
||||
# 写入 TOML 文件
|
||||
try:
|
||||
with self._config_file.open("wb") as file:
|
||||
tomli_w.dump(user_modified, file)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.error(f"Failed to save config to {self._config_file}: {exc}")
|
||||
with open(self._config_file, "wb") as f:
|
||||
tomli_w.dump(user_modified, f)
|
||||
logger.info(f"Config saved to {self._config_file}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to save config to {self._config_file}: {e}")
|
||||
|
||||
def dump_config(self) -> dict[str, Any]:
|
||||
"""导出配置为字典(用于 API 返回)"""
|
||||
config_dict = self.model_dump(
|
||||
exclude={
|
||||
"model_names",
|
||||
@ -212,141 +328,282 @@ class Config(BaseModel):
|
||||
"valuable_model_provider",
|
||||
}
|
||||
)
|
||||
|
||||
# 添加模型信息(转换为字典格式供前端使用)
|
||||
config_dict["model_names"] = {provider: info.model_dump() for provider, info in self.model_names.items()}
|
||||
config_dict["embed_model_names"] = {
|
||||
model_id: info.model_dump() for model_id, info in self.embed_model_names.items()
|
||||
}
|
||||
config_dict["reranker_names"] = {model_id: info.model_dump() for model_id, info in self.reranker_names.items()}
|
||||
|
||||
# 添加运行时状态信息
|
||||
config_dict["model_provider_status"] = self.model_provider_status
|
||||
config_dict["valuable_model_provider"] = self.valuable_model_provider
|
||||
|
||||
fields_info: dict[str, Any] = {}
|
||||
fields_info = {}
|
||||
for field_name, field_info in Config.model_fields.items():
|
||||
if field_info.exclude:
|
||||
continue
|
||||
annotation = field_info.annotation
|
||||
fields_info[field_name] = {
|
||||
"des": field_info.description,
|
||||
"default": field_info.default,
|
||||
"type": annotation.__name__ if hasattr(annotation, "__name__") else str(annotation),
|
||||
"exclude": bool(field_info.exclude),
|
||||
}
|
||||
if not field_info.exclude: # 排除内部字段
|
||||
fields_info[field_name] = {
|
||||
"des": field_info.description,
|
||||
"default": field_info.default,
|
||||
"type": field_info.annotation.__name__
|
||||
if hasattr(field_info.annotation, "__name__")
|
||||
else str(field_info.annotation),
|
||||
"exclude": field_info.exclude if hasattr(field_info, "exclude") else False,
|
||||
}
|
||||
config_dict["_config_items"] = fields_info
|
||||
|
||||
return config_dict
|
||||
|
||||
def get_model_choices(self) -> list[str]:
|
||||
choices: list[str] = []
|
||||
"""获取所有可用的聊天模型列表"""
|
||||
choices = []
|
||||
for provider, info in self.model_names.items():
|
||||
if not self.model_provider_status.get(provider, False):
|
||||
continue
|
||||
choices.extend([f"{provider}/{model}" for model in info.models])
|
||||
if self.model_provider_status.get(provider, False):
|
||||
for model in info.models:
|
||||
choices.append(f"{provider}/{model}")
|
||||
return choices
|
||||
|
||||
def get_embed_model_choices(self) -> list[str]:
|
||||
"""获取所有可用的嵌入模型列表"""
|
||||
return list(self.embed_model_names.keys())
|
||||
|
||||
def get_reranker_choices(self) -> list[str]:
|
||||
"""获取所有可用的重排序模型列表"""
|
||||
return list(self.reranker_names.keys())
|
||||
|
||||
# ============================================================
|
||||
# 兼容旧代码的方法
|
||||
# ============================================================
|
||||
|
||||
def __getitem__(self, key: str) -> Any:
|
||||
"""支持字典式访问 config[key]"""
|
||||
logger.warning("Using deprecated dict-style access for Config. Please use attribute access instead.")
|
||||
return getattr(self, key, None)
|
||||
|
||||
def __setitem__(self, key: str, value: Any) -> None:
|
||||
def __setitem__(self, key: str, value: Any):
|
||||
"""支持字典式赋值 config[key] = value"""
|
||||
logger.warning("Using deprecated dict-style assignment for Config. Please use attribute access instead.")
|
||||
setattr(self, key, value)
|
||||
|
||||
def update(self, other: dict[str, Any]) -> None:
|
||||
for key, value in (other or {}).items():
|
||||
if key in self.model_fields:
|
||||
"""批量更新配置(兼容旧代码)"""
|
||||
for key, value in other.items():
|
||||
if hasattr(self, key):
|
||||
setattr(self, key, value)
|
||||
else:
|
||||
logger.warning(f"Unknown config key: {key}")
|
||||
|
||||
def _save_models_to_file(self, provider_name: str | None = None) -> None:
|
||||
"""保存模型配置到主配置文件
|
||||
|
||||
Args:
|
||||
provider_name: 如果提供,只保存特定provider的修改;否则保存所有model_names
|
||||
"""
|
||||
if not self._config_file:
|
||||
logger.warning("Config file path not set")
|
||||
return
|
||||
|
||||
user_config: dict[str, Any] = {}
|
||||
if self._config_file.exists():
|
||||
with self._config_file.open("rb") as file:
|
||||
user_config = tomli.load(file)
|
||||
user_config.setdefault("model_names", {})
|
||||
logger.info(f"Saving models config to {self._config_file}")
|
||||
|
||||
if provider_name:
|
||||
if provider_name in self.model_names:
|
||||
user_config["model_names"][provider_name] = self.model_names[provider_name].model_dump()
|
||||
self._modified_providers.add(provider_name)
|
||||
else:
|
||||
user_config["model_names"] = {provider: info.model_dump() for provider, info in self.model_names.items()}
|
||||
self._user_modified_fields.add("model_names")
|
||||
try:
|
||||
# 读取现有配置
|
||||
user_config = {}
|
||||
if self._config_file.exists():
|
||||
with open(self._config_file, "rb") as f:
|
||||
user_config = tomli.load(f)
|
||||
|
||||
with self._config_file.open("wb") as file:
|
||||
tomli_w.dump(user_config, file)
|
||||
# 初始化 model_names 配置(如果不存在)
|
||||
if "model_names" not in user_config:
|
||||
user_config["model_names"] = {}
|
||||
|
||||
if provider_name:
|
||||
# 只保存特定 provider 的修改
|
||||
if provider_name in self.model_names:
|
||||
user_config["model_names"][provider_name] = self.model_names[provider_name].model_dump()
|
||||
# 记录具体修改的 provider
|
||||
self._modified_providers.add(provider_name)
|
||||
logger.info(f"Saved models config for provider: {provider_name}")
|
||||
else:
|
||||
# 保存所有 model_names
|
||||
user_config["model_names"] = {
|
||||
provider: info.model_dump() for provider, info in self.model_names.items()
|
||||
}
|
||||
# 记录整个 model_names 字段的修改
|
||||
self._user_modified_fields.add("model_names")
|
||||
logger.info("Saved all models config")
|
||||
|
||||
# 写入配置文件
|
||||
with open(self._config_file, "wb") as f:
|
||||
tomli_w.dump(user_config, f)
|
||||
logger.info(f"Models config saved to {self._config_file}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to save models config to {self._config_file}: {e}")
|
||||
|
||||
# ============================================================
|
||||
# 自定义供应商管理方法
|
||||
# ============================================================
|
||||
|
||||
def add_custom_provider(self, provider_id: str, provider_data: dict[str, Any]) -> bool:
|
||||
"""添加自定义供应商
|
||||
|
||||
Args:
|
||||
provider_id: 供应商唯一标识符
|
||||
provider_data: 供应商配置数据
|
||||
|
||||
Returns:
|
||||
是否添加成功
|
||||
"""
|
||||
try:
|
||||
# 处理环境变量,移除 ${} 包裹
|
||||
if "env" in provider_data and provider_data["env"]:
|
||||
env_value = provider_data["env"]
|
||||
if isinstance(env_value, str) and env_value.startswith("${") and env_value.endswith("}"):
|
||||
provider_data["env"] = env_value[2:-1]
|
||||
|
||||
# 确保标记为自定义供应商
|
||||
provider_data["custom"] = True
|
||||
|
||||
# 检查供应商ID是否已存在(无论是内置还是自定义)
|
||||
if provider_id in self.model_names:
|
||||
logger.error(f"Provider ID already exists: {provider_id}")
|
||||
return False
|
||||
|
||||
# 添加到配置中
|
||||
self.model_names[provider_id] = ChatModelProvider(**provider_data)
|
||||
|
||||
# 保存到自定义供应商配置文件
|
||||
self._save_custom_providers()
|
||||
|
||||
# 重新处理环境变量
|
||||
self._handle_environment()
|
||||
|
||||
logger.info(f"Added custom provider: {provider_id}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to add custom provider {provider_id}: {e}")
|
||||
return False
|
||||
|
||||
def update_custom_provider(self, provider_id: str, provider_data: dict) -> bool:
|
||||
"""更新自定义供应商
|
||||
|
||||
Args:
|
||||
provider_id: 供应商唯一标识符
|
||||
provider_data: 新的供应商配置数据
|
||||
|
||||
Returns:
|
||||
是否更新成功
|
||||
"""
|
||||
try:
|
||||
# 处理环境变量,移除 ${} 包裹
|
||||
if "env" in provider_data and provider_data["env"]:
|
||||
env_value = provider_data["env"]
|
||||
if isinstance(env_value, str) and env_value.startswith("${") and env_value.endswith("}"):
|
||||
provider_data["env"] = env_value[2:-1]
|
||||
|
||||
# 检查供应商是否存在且为自定义供应商
|
||||
if provider_id not in self.model_names:
|
||||
logger.error(f"Provider not found: {provider_id}")
|
||||
return False
|
||||
|
||||
if not self.model_names[provider_id].custom:
|
||||
logger.error(f"Cannot update non-custom provider: {provider_id}")
|
||||
return False
|
||||
|
||||
# 确保保持自定义供应商标记
|
||||
provider_data["custom"] = True
|
||||
|
||||
# 更新供应商配置
|
||||
self.model_names[provider_id] = ChatModelProvider(**provider_data)
|
||||
|
||||
# 保存到自定义供应商配置文件
|
||||
self._save_custom_providers()
|
||||
|
||||
# 重新处理环境变量
|
||||
self._handle_environment()
|
||||
|
||||
logger.info(f"Updated custom provider: {provider_id}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to update custom provider {provider_id}: {e}")
|
||||
return False
|
||||
|
||||
def delete_custom_provider(self, provider_id: str) -> bool:
|
||||
"""删除自定义供应商
|
||||
|
||||
Args:
|
||||
provider_id: 供应商唯一标识符
|
||||
|
||||
Returns:
|
||||
是否删除成功
|
||||
"""
|
||||
try:
|
||||
# 检查供应商是否存在且为自定义供应商
|
||||
if provider_id not in self.model_names:
|
||||
logger.error(f"Provider not found: {provider_id}")
|
||||
return False
|
||||
|
||||
if not self.model_names[provider_id].custom:
|
||||
logger.error(f"Cannot delete non-custom provider: {provider_id}")
|
||||
return False
|
||||
|
||||
# 从配置中删除
|
||||
del self.model_names[provider_id]
|
||||
|
||||
# 保存到自定义供应商配置文件
|
||||
self._save_custom_providers()
|
||||
|
||||
# 重新处理环境变量
|
||||
self._handle_environment()
|
||||
|
||||
logger.info(f"Deleted custom provider: {provider_id}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to delete custom provider {provider_id}: {e}")
|
||||
return False
|
||||
|
||||
def get_custom_providers(self) -> dict[str, ChatModelProvider]:
|
||||
return {provider: info for provider, info in self.model_names.items() if info.custom}
|
||||
"""获取所有自定义供应商
|
||||
|
||||
Returns:
|
||||
自定义供应商字典
|
||||
"""
|
||||
return {k: v for k, v in self.model_names.items() if v.custom}
|
||||
|
||||
def _save_custom_providers(self) -> None:
|
||||
"""保存自定义供应商到独立配置文件"""
|
||||
if not self._config_file:
|
||||
logger.warning("Config file path not set")
|
||||
return
|
||||
|
||||
custom_config_file = self._config_file.parent / "custom_providers.toml"
|
||||
custom_providers = self.get_custom_providers()
|
||||
custom_config: dict[str, Any] = {}
|
||||
if custom_providers:
|
||||
custom_config["model_names"] = {provider: info.model_dump() for provider, info in custom_providers.items()}
|
||||
|
||||
custom_config_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
with custom_config_file.open("wb") as file:
|
||||
tomli_w.dump(custom_config, file)
|
||||
try:
|
||||
# 获取所有自定义供应商
|
||||
custom_providers = self.get_custom_providers()
|
||||
|
||||
def add_custom_provider(self, provider_id: str, provider_data: dict[str, Any]) -> bool:
|
||||
if provider_id in self.model_names:
|
||||
logger.error(f"Provider ID already exists: {provider_id}")
|
||||
return False
|
||||
payload = dict(provider_data or {})
|
||||
env_value = payload.get("env")
|
||||
if isinstance(env_value, str) and env_value.startswith("${") and env_value.endswith("}"):
|
||||
payload["env"] = env_value[2:-1]
|
||||
payload["custom"] = True
|
||||
self.model_names[provider_id] = ChatModelProvider(**payload)
|
||||
self._save_custom_providers()
|
||||
self._handle_environment()
|
||||
return True
|
||||
# 创建配置数据
|
||||
custom_config = {}
|
||||
if custom_providers:
|
||||
custom_config["model_names"] = {
|
||||
provider: info.model_dump() for provider, info in custom_providers.items()
|
||||
}
|
||||
|
||||
def update_custom_provider(self, provider_id: str, provider_data: dict[str, Any]) -> bool:
|
||||
if provider_id not in self.model_names:
|
||||
logger.error(f"Provider not found: {provider_id}")
|
||||
return False
|
||||
if not self.model_names[provider_id].custom:
|
||||
logger.error(f"Cannot update non-custom provider: {provider_id}")
|
||||
return False
|
||||
# 确保目录存在
|
||||
custom_config_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
payload = dict(provider_data or {})
|
||||
env_value = payload.get("env")
|
||||
if isinstance(env_value, str) and env_value.startswith("${") and env_value.endswith("}"):
|
||||
payload["env"] = env_value[2:-1]
|
||||
payload["custom"] = True
|
||||
self.model_names[provider_id] = ChatModelProvider(**payload)
|
||||
self._save_custom_providers()
|
||||
self._handle_environment()
|
||||
return True
|
||||
# 写入配置文件
|
||||
with open(custom_config_file, "wb") as f:
|
||||
tomli_w.dump(custom_config, f)
|
||||
|
||||
def delete_custom_provider(self, provider_id: str) -> bool:
|
||||
if provider_id not in self.model_names:
|
||||
logger.error(f"Provider not found: {provider_id}")
|
||||
return False
|
||||
if not self.model_names[provider_id].custom:
|
||||
logger.error(f"Cannot delete non-custom provider: {provider_id}")
|
||||
return False
|
||||
logger.info(f"Custom providers saved to {custom_config_file}")
|
||||
|
||||
del self.model_names[provider_id]
|
||||
self._save_custom_providers()
|
||||
self._handle_environment()
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to save custom providers to {custom_config_file}: {e}")
|
||||
|
||||
|
||||
# 全局配置实例
|
||||
config = Config()
|
||||
|
||||
@ -50,4 +50,4 @@ actions:
|
||||
|
||||
# 页脚信息
|
||||
footer:
|
||||
copyright: "© 江南语析 2026 v0.5.0"
|
||||
copyright: "© 江南语析 2026 v0.5.1"
|
||||
|
||||
@ -477,9 +477,9 @@ class KnowledgeBase(ABC):
|
||||
操作结果
|
||||
"""
|
||||
if db_id in self.databases_meta:
|
||||
from src.knowledge.utils.kb_utils import parse_minio_url
|
||||
from src.repositories.knowledge_base_repository import KnowledgeBaseRepository
|
||||
from src.storage.minio import get_minio_client
|
||||
from src.knowledge.utils.kb_utils import parse_minio_url
|
||||
|
||||
minio_client = get_minio_client()
|
||||
|
||||
|
||||
@ -268,16 +268,21 @@ class KnowledgeBaseManager:
|
||||
return {"databases": []}
|
||||
return await self.get_databases_by_user(user)
|
||||
|
||||
async def get_databases_by_user(self, user: User) -> dict:
|
||||
async def get_databases_by_user(self, user: User | dict) -> dict:
|
||||
"""根据用户权限获取知识库列表"""
|
||||
|
||||
# 构建用户信息字典
|
||||
user_info = {
|
||||
"role": user.role,
|
||||
"department_id": user.department_id,
|
||||
}
|
||||
# 构建用户信息字典(支持 User 对象或 dict)
|
||||
if isinstance(user, dict):
|
||||
user_info = user
|
||||
else:
|
||||
user_info = {
|
||||
"role": user.role,
|
||||
"department_id": user.department_id,
|
||||
}
|
||||
|
||||
logger.info(f"Getting databases for user {user.id} with role {user.role} and department {user.department_id}")
|
||||
user_role = user_info.get("role")
|
||||
user_dept = user_info.get("department_id")
|
||||
logger.info(f"Getting databases for user with role {user_role} and department {user_dept}")
|
||||
|
||||
all_databases = (await self.get_databases()).get("databases", [])
|
||||
|
||||
|
||||
@ -151,6 +151,15 @@ class ConversationRepository:
|
||||
error_message: str | None = None,
|
||||
langgraph_tool_call_id: str | None = None,
|
||||
) -> ToolCall:
|
||||
if langgraph_tool_call_id:
|
||||
existing = await self.get_tool_call_by_langgraph_id(langgraph_tool_call_id)
|
||||
if existing:
|
||||
logger.debug(
|
||||
"Tool call already exists for langgraph_tool_call_id=%s, skip insert",
|
||||
langgraph_tool_call_id,
|
||||
)
|
||||
return existing
|
||||
|
||||
tool_call = ToolCall(
|
||||
message_id=message_id,
|
||||
tool_name=tool_name,
|
||||
@ -196,18 +205,62 @@ class ConversationRepository:
|
||||
return await self.get_messages(conversation.id, limit, offset)
|
||||
|
||||
async def list_conversations(
|
||||
self, user_id: str | None = None, agent_id: str | None = None, status: str = "active"
|
||||
self,
|
||||
user_id: str | None = None,
|
||||
agent_id: str | None = None,
|
||||
status: str = "active",
|
||||
limit: int | None = None,
|
||||
offset: int = 0,
|
||||
) -> list[Conversation]:
|
||||
query = select(Conversation).where(Conversation.status == status)
|
||||
"""List conversations with pinned conversations always included first.
|
||||
|
||||
The limit applies only to non-pinned conversations to ensure pinned
|
||||
conversations are always visible in the list.
|
||||
"""
|
||||
|
||||
base_conditions = [Conversation.status == status]
|
||||
if user_id:
|
||||
query = query.where(Conversation.user_id == str(user_id))
|
||||
base_conditions.append(Conversation.user_id == str(user_id))
|
||||
if agent_id:
|
||||
query = query.where(Conversation.agent_id == agent_id)
|
||||
base_conditions.append(Conversation.agent_id == agent_id)
|
||||
|
||||
query = query.order_by(Conversation.updated_at.desc())
|
||||
result = await self.db.execute(query)
|
||||
return list(result.scalars().all())
|
||||
# First, get all pinned conversations (no limit)
|
||||
pinned_query = (
|
||||
select(Conversation)
|
||||
.where(*base_conditions)
|
||||
.where(Conversation.is_pinned)
|
||||
.order_by(Conversation.updated_at.desc())
|
||||
)
|
||||
result = await self.db.execute(pinned_query)
|
||||
pinned_conversations = list(result.scalars().all())
|
||||
|
||||
# Then, get non-pinned conversations with limit/offset
|
||||
remaining_limit = None
|
||||
remaining_offset = offset
|
||||
|
||||
if limit is not None:
|
||||
# Calculate how many slots are taken by pinned conversations
|
||||
pinned_count = len(pinned_conversations)
|
||||
if pinned_count >= limit:
|
||||
# All slots taken by pinned conversations
|
||||
return pinned_conversations[:limit]
|
||||
remaining_limit = limit - pinned_count
|
||||
|
||||
if remaining_limit is not None and remaining_limit > 0:
|
||||
non_pinned_query = (
|
||||
select(Conversation)
|
||||
.where(*base_conditions)
|
||||
.where(~Conversation.is_pinned)
|
||||
.order_by(Conversation.updated_at.desc())
|
||||
.limit(remaining_limit)
|
||||
.offset(remaining_offset)
|
||||
)
|
||||
result = await self.db.execute(non_pinned_query)
|
||||
non_pinned_conversations = list(result.scalars().all())
|
||||
else:
|
||||
non_pinned_conversations = []
|
||||
|
||||
return pinned_conversations + non_pinned_conversations
|
||||
|
||||
async def update_conversation(
|
||||
self,
|
||||
@ -215,6 +268,7 @@ class ConversationRepository:
|
||||
title: str | None = None,
|
||||
status: str | None = None,
|
||||
metadata: dict | None = None,
|
||||
is_pinned: bool | None = None,
|
||||
) -> Conversation | None:
|
||||
conversation = await self.get_conversation_by_thread_id(thread_id)
|
||||
if not conversation:
|
||||
@ -225,6 +279,8 @@ class ConversationRepository:
|
||||
conversation.title = normalized_title
|
||||
if status is not None:
|
||||
conversation.status = status
|
||||
if is_pinned is not None:
|
||||
conversation.is_pinned = is_pinned
|
||||
|
||||
if metadata is not None:
|
||||
current_metadata = conversation.extra_metadata or {}
|
||||
@ -286,7 +342,10 @@ class ConversationRepository:
|
||||
|
||||
async def get_tool_call_by_langgraph_id(self, langgraph_tool_call_id: str) -> ToolCall | None:
|
||||
result = await self.db.execute(
|
||||
select(ToolCall).where(ToolCall.langgraph_tool_call_id == langgraph_tool_call_id)
|
||||
select(ToolCall)
|
||||
.where(ToolCall.langgraph_tool_call_id == langgraph_tool_call_id)
|
||||
.order_by(ToolCall.created_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
@ -3,11 +3,13 @@ import json
|
||||
import traceback
|
||||
import uuid
|
||||
from collections.abc import AsyncIterator
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
from langchain.messages import AIMessage, AIMessageChunk, HumanMessage
|
||||
from langgraph.types import Command
|
||||
|
||||
from src import config as conf
|
||||
from src import config as conf, knowledge_base
|
||||
from src.agents import agent_manager
|
||||
from src.plugins.guard import content_guard
|
||||
from src.repositories.agent_config_repository import AgentConfigRepository
|
||||
@ -16,29 +18,39 @@ from src.storage.postgres.manager import pg_manager
|
||||
from src.utils.logging_config import logger
|
||||
|
||||
|
||||
def _build_state_uploads(attachments: list[dict]) -> list[dict]:
|
||||
"""Convert persisted attachment metadata into state.uploads entries."""
|
||||
uploads: list[dict] = []
|
||||
def _build_state_files(attachments: list[dict]) -> dict:
|
||||
"""将附件列表转换为 StateBackend 格式的 files 字典
|
||||
|
||||
StateBackend 期望的格式:
|
||||
{
|
||||
"/attachments/file.md": {
|
||||
"content": ["line1", "line2", ...],
|
||||
"created_at": "...",
|
||||
"modified_at": "...",
|
||||
}
|
||||
}
|
||||
"""
|
||||
files = {}
|
||||
for attachment in attachments:
|
||||
file_path = attachment.get("path")
|
||||
if not isinstance(file_path, str) or not file_path.strip():
|
||||
if attachment.get("status") != "parsed":
|
||||
continue
|
||||
|
||||
uploads.append(
|
||||
{
|
||||
"file_id": attachment.get("file_id"),
|
||||
"file_name": attachment.get("file_name"),
|
||||
"file_type": attachment.get("file_type"),
|
||||
"file_size": attachment.get("file_size", 0),
|
||||
"status": attachment.get("status", "uploaded"),
|
||||
"uploaded_at": attachment.get("uploaded_at"),
|
||||
"path": file_path,
|
||||
"artifact_url": attachment.get("artifact_url"),
|
||||
}
|
||||
)
|
||||
file_path = attachment.get("file_path")
|
||||
markdown = attachment.get("markdown")
|
||||
|
||||
return uploads
|
||||
if not file_path or not markdown:
|
||||
continue
|
||||
|
||||
now = datetime.now(UTC).isoformat()
|
||||
# 将 markdown 内容按行拆分
|
||||
content_lines = markdown.split("\n")
|
||||
files[file_path] = {
|
||||
"content": content_lines,
|
||||
"created_at": attachment.get("uploaded_at", now),
|
||||
"modified_at": attachment.get("uploaded_at", now),
|
||||
}
|
||||
|
||||
return files
|
||||
|
||||
|
||||
async def _get_langgraph_messages(agent_instance, config_dict):
|
||||
@ -53,20 +65,20 @@ async def _get_langgraph_messages(agent_instance, config_dict):
|
||||
|
||||
|
||||
def extract_agent_state(values: dict) -> dict:
|
||||
"""Extract agent state payload returned to frontend."""
|
||||
"""从 LangGraph state 中提取 agent 状态"""
|
||||
if not isinstance(values, dict):
|
||||
return {}
|
||||
|
||||
# 直接获取,信任 state 的数据结构
|
||||
todos = values.get("todos")
|
||||
result = {
|
||||
"todos": list(todos)[:20] if todos else [],
|
||||
"uploads": values.get("uploads") or [],
|
||||
"files": values.get("files") or {},
|
||||
}
|
||||
|
||||
return result
|
||||
|
||||
|
||||
|
||||
async def _get_existing_message_ids(conv_repo: ConversationRepository, thread_id: str) -> set[str]:
|
||||
existing_messages = await conv_repo.get_messages_by_thread_id(thread_id)
|
||||
return {
|
||||
@ -182,7 +194,7 @@ async def save_messages_from_langgraph_state(
|
||||
logger.error(traceback.format_exc())
|
||||
|
||||
|
||||
def _extract_interrupt_info(state) -> dict | None:
|
||||
def _extract_interrupt_info(state) -> Any | None:
|
||||
"""从 LangGraph state 中提取中断信息"""
|
||||
if hasattr(state, "tasks") and state.tasks:
|
||||
for task in state.tasks:
|
||||
@ -196,12 +208,65 @@ def _extract_interrupt_info(state) -> dict | None:
|
||||
return None
|
||||
|
||||
|
||||
def _get_interrupt_fields(info) -> tuple[str, str]:
|
||||
"""从中断信息中提取 question 和 operation"""
|
||||
defaults = ("是否批准以下操作?", "需要人工审批的操作")
|
||||
def _coerce_interrupt_payload(info: Any) -> dict:
|
||||
"""将 LangGraph interrupt 对象转换为 dict 结构。"""
|
||||
if isinstance(info, dict):
|
||||
return info.get("question", defaults[0]), info.get("operation", defaults[1])
|
||||
return getattr(info, "question", defaults[0]), getattr(info, "operation", defaults[1])
|
||||
return info
|
||||
|
||||
payload = getattr(info, "value", None)
|
||||
if isinstance(payload, dict):
|
||||
return payload
|
||||
|
||||
question = getattr(info, "question", None)
|
||||
operation = getattr(info, "operation", None)
|
||||
result: dict[str, Any] = {}
|
||||
if isinstance(question, str) and question.strip():
|
||||
result["question"] = question
|
||||
if isinstance(operation, str) and operation.strip():
|
||||
result["operation"] = operation
|
||||
return result
|
||||
|
||||
|
||||
def _normalize_interrupt_options(raw_options: Any) -> list[dict[str, str]]:
|
||||
if not isinstance(raw_options, list):
|
||||
return []
|
||||
|
||||
options: list[dict[str, str]] = []
|
||||
for item in raw_options:
|
||||
if isinstance(item, dict):
|
||||
label = str(item.get("label") or item.get("value") or "").strip()
|
||||
value = str(item.get("value") or item.get("label") or "").strip()
|
||||
else:
|
||||
label = str(item).strip()
|
||||
value = label
|
||||
if label and value:
|
||||
options.append({"label": label, "value": value})
|
||||
return options
|
||||
|
||||
|
||||
def _build_ask_user_question_payload(info: Any, thread_id: str) -> dict[str, Any]:
|
||||
"""将 interrupt 信息标准化为 ask_user_question_required 载荷。"""
|
||||
payload = _coerce_interrupt_payload(info)
|
||||
|
||||
question = str(payload.get("question") or "请选择一个选项").strip()
|
||||
question_id = str(payload.get("question_id") or uuid.uuid4())
|
||||
source = str(payload.get("source") or payload.get("tool_name") or "interrupt")
|
||||
multi_select = bool(payload.get("multi_select", False))
|
||||
allow_other = bool(payload.get("allow_other", True))
|
||||
operation = payload.get("operation")
|
||||
|
||||
options = _normalize_interrupt_options(payload.get("options"))
|
||||
|
||||
return {
|
||||
"question_id": question_id,
|
||||
"question": question,
|
||||
"options": options,
|
||||
"multi_select": multi_select,
|
||||
"allow_other": allow_other,
|
||||
"source": source,
|
||||
"operation": operation if isinstance(operation, str) else "",
|
||||
"thread_id": thread_id,
|
||||
}
|
||||
|
||||
|
||||
def _ensure_full_msg(full_msg: AIMessage | None, accumulated_content: list[str]) -> AIMessage | None:
|
||||
@ -251,13 +316,9 @@ async def check_and_handle_interrupts(
|
||||
|
||||
interrupt_info = _extract_interrupt_info(state)
|
||||
if interrupt_info:
|
||||
question, operation = _get_interrupt_fields(interrupt_info)
|
||||
meta["interrupt"] = {
|
||||
"question": question,
|
||||
"operation": operation,
|
||||
"thread_id": thread_id,
|
||||
}
|
||||
yield make_chunk(status="interrupted", message=question, meta=meta)
|
||||
question_payload = _build_ask_user_question_payload(interrupt_info, thread_id)
|
||||
meta["interrupt"] = question_payload
|
||||
yield make_chunk(status="ask_user_question_required", meta=meta, **question_payload)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error checking interrupts: {e}")
|
||||
@ -346,6 +407,8 @@ async def stream_agent_chat(
|
||||
"agent_config_id": agent_config_id,
|
||||
"agent_config": agent_config,
|
||||
}
|
||||
full_msg = None
|
||||
accumulated_content: list[str] = []
|
||||
|
||||
try:
|
||||
conv_repo = ConversationRepository(db)
|
||||
@ -499,7 +562,7 @@ async def stream_agent_resume(
|
||||
*,
|
||||
agent_id: str,
|
||||
thread_id: str,
|
||||
approved: bool,
|
||||
resume_input: Any,
|
||||
meta: dict,
|
||||
config: dict,
|
||||
current_user,
|
||||
@ -525,10 +588,10 @@ async def stream_agent_resume(
|
||||
)
|
||||
return
|
||||
|
||||
init_msg = {"type": "system", "content": f"Resume with approved: {approved}"}
|
||||
init_msg = {"type": "system", "content": f"Resume with input: {resume_input}"}
|
||||
yield make_resume_chunk(status="init", meta=meta, msg=init_msg)
|
||||
|
||||
resume_command = Command(resume=approved)
|
||||
resume_command = Command(resume=resume_input)
|
||||
graph = await agent.get_graph()
|
||||
|
||||
user_id = str(current_user.id)
|
||||
@ -637,15 +700,22 @@ async def get_agent_state_view(
|
||||
state = await graph.aget_state(langgraph_config)
|
||||
agent_state = extract_agent_state(getattr(state, "values", {})) if state else {}
|
||||
|
||||
# 如果 state 中暂时没有 uploads,则从持久化附件记录回填
|
||||
if not isinstance(agent_state.get("uploads"), list) or not agent_state["uploads"]:
|
||||
# 如果 state 中没有 files,从附件构建
|
||||
# 这确保了上传附件后立即可以在文件列表中看到文件
|
||||
if not agent_state.get("files") or agent_state["files"] == {}:
|
||||
try:
|
||||
attachments = await conv_repo.get_attachments_by_thread_id(thread_id)
|
||||
logger.info(f"[get_agent_state_view] found {len(attachments)} attachments in DB")
|
||||
if attachments:
|
||||
uploads = _build_state_uploads(attachments)
|
||||
agent_state["uploads"] = uploads
|
||||
logger.info(f"[get_agent_state_view] Built uploads from attachments: {len(uploads)} files")
|
||||
first_status = attachments[0].get("status")
|
||||
first_has_markdown = bool(attachments[0].get("markdown"))
|
||||
logger.info(
|
||||
f"[get_agent_state_view] first attachment status: {first_status}, "
|
||||
f"has markdown: {first_has_markdown}"
|
||||
)
|
||||
files = _build_state_files(attachments)
|
||||
agent_state["files"] = files
|
||||
logger.info(f"[get_agent_state_view] Built files from attachments: {len(files)} files")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to fetch attachments for thread {thread_id}: {e}")
|
||||
|
||||
|
||||
@ -132,6 +132,8 @@ async def list_threads_view(
|
||||
agent_id: str,
|
||||
db: AsyncSession,
|
||||
current_user_id: str,
|
||||
limit: int | None = None,
|
||||
offset: int = 0,
|
||||
) -> list[dict]:
|
||||
if not agent_id:
|
||||
raise HTTPException(status_code=422, detail="agent_id 不能为空")
|
||||
@ -141,6 +143,8 @@ async def list_threads_view(
|
||||
user_id=str(current_user_id),
|
||||
agent_id=agent_id,
|
||||
status="active",
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
|
||||
return [
|
||||
@ -149,6 +153,7 @@ async def list_threads_view(
|
||||
"user_id": conv.user_id,
|
||||
"agent_id": conv.agent_id,
|
||||
"title": conv.title,
|
||||
"is_pinned": bool(conv.is_pinned),
|
||||
"created_at": conv.created_at.isoformat(),
|
||||
"updated_at": conv.updated_at.isoformat(),
|
||||
}
|
||||
@ -173,13 +178,14 @@ async def delete_thread_view(
|
||||
async def update_thread_view(
|
||||
*,
|
||||
thread_id: str,
|
||||
title: str | None,
|
||||
title: str | None = None,
|
||||
is_pinned: bool | None = None,
|
||||
db: AsyncSession,
|
||||
current_user_id: str,
|
||||
) -> dict:
|
||||
conv_repo = ConversationRepository(db)
|
||||
await require_user_conversation(conv_repo, thread_id, str(current_user_id))
|
||||
updated_conv = await conv_repo.update_conversation(thread_id, title=title)
|
||||
updated_conv = await conv_repo.update_conversation(thread_id, title=title, is_pinned=is_pinned)
|
||||
if not updated_conv:
|
||||
raise HTTPException(status_code=500, detail="更新失败")
|
||||
return {
|
||||
@ -187,6 +193,7 @@ async def update_thread_view(
|
||||
"user_id": updated_conv.user_id,
|
||||
"agent_id": updated_conv.agent_id,
|
||||
"title": updated_conv.title,
|
||||
"is_pinned": bool(updated_conv.is_pinned),
|
||||
"created_at": updated_conv.created_at.isoformat(),
|
||||
"updated_at": updated_conv.updated_at.isoformat(),
|
||||
}
|
||||
|
||||
@ -275,6 +275,14 @@ async def process_agent_run(ctx, run_id: str):
|
||||
error_message=chunk.get("message"),
|
||||
)
|
||||
terminal_set = True
|
||||
elif status == "ask_user_question_required":
|
||||
await mark_run_terminal(
|
||||
run_id,
|
||||
"interrupted",
|
||||
error_type="ask_user_question_required",
|
||||
error_message=chunk.get("question") or "需要用户回答问题",
|
||||
)
|
||||
terminal_set = True
|
||||
|
||||
if await run_ctx.is_cancelled():
|
||||
raise asyncio.CancelledError(f"run {run_id} cancelled")
|
||||
|
||||
@ -188,6 +188,7 @@ class PostgresManager(metaclass=SingletonMeta):
|
||||
"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 conversations ADD COLUMN IF NOT EXISTS is_pinned BOOLEAN NOT NULL DEFAULT FALSE",
|
||||
"ALTER TABLE IF EXISTS mcp_servers ADD COLUMN IF NOT EXISTS env JSONB",
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS agent_runs (
|
||||
@ -209,6 +210,7 @@ class PostgresManager(metaclass=SingletonMeta):
|
||||
"CREATE INDEX IF NOT EXISTS idx_agent_runs_user_created ON agent_runs(user_id, created_at DESC)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_agent_runs_thread_created ON agent_runs(thread_id, created_at DESC)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_agent_runs_status_updated ON agent_runs(status, updated_at)",
|
||||
"CREATE INDEX IF NOT EXISTS ix_conversations_is_pinned ON conversations(is_pinned)",
|
||||
]
|
||||
async with self.async_engine.begin() as conn:
|
||||
for stmt in stmts:
|
||||
|
||||
@ -217,6 +217,7 @@ class Conversation(Base):
|
||||
agent_id = Column(String(64), index=True, nullable=False, comment="Agent ID")
|
||||
title = Column(String(255), nullable=True, comment="Conversation title")
|
||||
status = Column(String(20), default="active", comment="Status: active/archived/deleted")
|
||||
is_pinned = Column(Boolean, default=False, nullable=False, index=True, comment="Is pinned to top")
|
||||
created_at = Column(DateTime, default=utc_now_naive, comment="Creation time")
|
||||
updated_at = Column(DateTime, default=utc_now_naive, onupdate=utc_now_naive, comment="Update time")
|
||||
extra_metadata = Column(JSON, nullable=True, comment="Additional metadata")
|
||||
@ -235,6 +236,7 @@ class Conversation(Base):
|
||||
"agent_id": self.agent_id,
|
||||
"title": self.title,
|
||||
"status": self.status,
|
||||
"is_pinned": bool(self.is_pinned),
|
||||
"created_at": format_utc_datetime(self.created_at),
|
||||
"updated_at": format_utc_datetime(self.updated_at),
|
||||
"metadata": self.extra_metadata or {},
|
||||
|
||||
161
test/test_chat_stream_interrupt.py
Normal file
161
test/test_chat_stream_interrupt.py
Normal file
@ -0,0 +1,161 @@
|
||||
"""测试 chat_stream_service 中的 interrupt 相关函数"""
|
||||
import pytest
|
||||
import sys
|
||||
import os
|
||||
|
||||
sys.path.insert(0, os.getcwd())
|
||||
|
||||
from src.services.chat_stream_service import (
|
||||
_normalize_interrupt_options,
|
||||
_build_ask_user_question_payload,
|
||||
_coerce_interrupt_payload,
|
||||
)
|
||||
|
||||
|
||||
class TestNormalizeInterruptOptions:
|
||||
"""测试 _normalize_interrupt_options 函数"""
|
||||
|
||||
def test_empty_input(self):
|
||||
assert _normalize_interrupt_options(None) == []
|
||||
assert _normalize_interrupt_options([]) == []
|
||||
|
||||
def test_dict_options(self):
|
||||
raw = [
|
||||
{"label": "选项1", "value": "option1"},
|
||||
{"label": "选项2", "value": "option2"},
|
||||
]
|
||||
result = _normalize_interrupt_options(raw)
|
||||
assert len(result) == 2
|
||||
assert result[0] == {"label": "选项1", "value": "option1"}
|
||||
assert result[1] == {"label": "选项2", "value": "option2"}
|
||||
|
||||
def test_string_options(self):
|
||||
raw = ["选项1", "选项2", "选项3"]
|
||||
result = _normalize_interrupt_options(raw)
|
||||
assert len(result) == 3
|
||||
assert result[0] == {"label": "选项1", "value": "选项1"}
|
||||
|
||||
def test_mixed_options(self):
|
||||
raw = [{"label": "选项1", "value": "option1"}, "选项2"]
|
||||
result = _normalize_interrupt_options(raw)
|
||||
assert len(result) == 2
|
||||
assert result[0] == {"label": "选项1", "value": "option1"}
|
||||
assert result[1] == {"label": "选项2", "value": "选项2"}
|
||||
|
||||
def test_invalid_options(self):
|
||||
raw = [{"label": "只有label"}, {}, " "]
|
||||
result = _normalize_interrupt_options(raw)
|
||||
assert len(result) == 1 # 只有有效的选项
|
||||
assert result[0] == {"label": "只有label", "value": "只有label"}
|
||||
|
||||
def test_value_only(self):
|
||||
raw = [{"value": "only_value"}]
|
||||
result = _normalize_interrupt_options(raw)
|
||||
assert len(result) == 1
|
||||
assert result[0] == {"label": "only_value", "value": "only_value"}
|
||||
|
||||
|
||||
class TestBuildAskUserQuestionPayload:
|
||||
"""测试 _build_ask_user_question_payload 函数"""
|
||||
|
||||
def test_basic_question(self):
|
||||
info = {
|
||||
"question": "请确认是否继续?",
|
||||
"options": [
|
||||
{"label": "确认", "value": "yes"},
|
||||
{"label": "取消", "value": "no"},
|
||||
],
|
||||
}
|
||||
result = _build_ask_user_question_payload(info, "thread-123")
|
||||
|
||||
assert result["question"] == "请确认是否继续?"
|
||||
assert len(result["options"]) == 2
|
||||
assert result["options"][0] == {"label": "确认", "value": "yes"}
|
||||
assert result["options"][1] == {"label": "取消", "value": "no"}
|
||||
assert result["source"] == "interrupt"
|
||||
assert result["thread_id"] == "thread-123"
|
||||
assert result["multi_select"] is False
|
||||
assert result["allow_other"] is True
|
||||
|
||||
def test_question_with_source(self):
|
||||
info = {
|
||||
"question": "选择一个选项",
|
||||
"options": ["A", "B", "C"],
|
||||
"source": "ask_user_question",
|
||||
}
|
||||
result = _build_ask_user_question_payload(info, "thread-456")
|
||||
|
||||
assert result["source"] == "ask_user_question"
|
||||
assert len(result["options"]) == 3
|
||||
|
||||
def test_multi_select(self):
|
||||
info = {
|
||||
"question": "选择多个",
|
||||
"options": ["A", "B", "C"],
|
||||
"multi_select": True,
|
||||
}
|
||||
result = _build_ask_user_question_payload(info, "thread-789")
|
||||
|
||||
assert result["multi_select"] is True
|
||||
|
||||
def test_disable_allow_other(self):
|
||||
info = {
|
||||
"question": "只能选择",
|
||||
"options": ["A", "B"],
|
||||
"allow_other": False,
|
||||
}
|
||||
result = _build_ask_user_question_payload(info, "thread-000")
|
||||
|
||||
assert result["allow_other"] is False
|
||||
|
||||
def test_with_operation(self):
|
||||
info = {
|
||||
"question": "是否执行操作?",
|
||||
"operation": "删除文件",
|
||||
"options": [{"label": "批准", "value": "approve"}, {"label": "拒绝", "value": "reject"}],
|
||||
}
|
||||
result = _build_ask_user_question_payload(info, "thread-op")
|
||||
|
||||
assert result["operation"] == "删除文件"
|
||||
|
||||
def test_no_options(self):
|
||||
"""测试没有 options 的情况 - 不再自动填充 legacy 选项"""
|
||||
info = {
|
||||
"question": "请确认?",
|
||||
}
|
||||
result = _build_ask_user_question_payload(info, "thread-no-opt")
|
||||
|
||||
# 不再有默认的 approve/reject 选项
|
||||
assert result["options"] == []
|
||||
assert result["source"] == "interrupt"
|
||||
|
||||
def test_question_id_generation(self):
|
||||
"""测试 question_id 自动生成"""
|
||||
info = {"question": "测试?"}
|
||||
result = _build_ask_user_question_payload(info, "thread-id")
|
||||
|
||||
# 应该生成了 UUID
|
||||
assert result["question_id"] != ""
|
||||
assert len(result["question_id"]) > 0
|
||||
|
||||
|
||||
class TestCoerceInterruptPayload:
|
||||
"""测试 _coerce_interrupt_payload 函数"""
|
||||
|
||||
def test_dict_input(self):
|
||||
info = {"question": "test?", "options": ["a", "b"]}
|
||||
result = _coerce_interrupt_payload(info)
|
||||
assert result == info
|
||||
|
||||
def test_string_input(self):
|
||||
info = "just a string"
|
||||
result = _coerce_interrupt_payload(info)
|
||||
assert isinstance(result, dict)
|
||||
|
||||
def test_none_input(self):
|
||||
result = _coerce_interrupt_payload(None)
|
||||
assert isinstance(result, dict)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-v"])
|
||||
@ -6,7 +6,7 @@ import sys
|
||||
# Add project root to path
|
||||
sys.path.append(os.getcwd())
|
||||
|
||||
from src.knowledge.services.upload_graph_service import UploadGraphService
|
||||
from src.knowledge.graphs.upload_graph_service import UploadGraphService
|
||||
|
||||
# For backward compatibility with the existing test
|
||||
GraphDatabase = UploadGraphService
|
||||
@ -40,64 +40,68 @@ async def test_txt_add_vector_entity_parsing():
|
||||
mock_embed_model = MagicMock()
|
||||
mock_select_model.return_value = mock_embed_model
|
||||
|
||||
# Instantiate GraphDatabase with mocked driver
|
||||
# We also need to patch Neo4jConnectionManager in the init
|
||||
with patch("src.knowledge.services.upload_graph_service.Neo4jConnectionManager") as mock_connection_manager:
|
||||
# Mock the connection manager to return our mocked driver
|
||||
mock_connection_manager.return_value.driver = mock_driver
|
||||
mock_connection_manager.return_value.status = "open"
|
||||
# Create a mock connection object with driver and status as attributes
|
||||
# (not a ConnectionManager class, just a simple mock object)
|
||||
mock_connection = MagicMock()
|
||||
mock_connection.driver = mock_driver
|
||||
mock_connection.status = "open"
|
||||
|
||||
gd = GraphDatabase(mock_connection_manager.return_value)
|
||||
# Manually set properties just in case init didn't work as expected due to other mocks
|
||||
gd.driver = mock_driver
|
||||
gd.status = "open"
|
||||
gd.embed_model_name = "test_model" # avoid config check issues if possible
|
||||
# Instantiate GraphDatabase with mocked connection
|
||||
gd = GraphDatabase(mock_connection)
|
||||
# Set embed_model_name directly (this is a settable attribute)
|
||||
gd.embed_model_name = "test_model"
|
||||
|
||||
# Mock config to match
|
||||
with patch("src.config.embed_model", "test_model"):
|
||||
with patch("src.config.embed_model_names", {"test_model": MagicMock(dimension=1024)}):
|
||||
# Test data: Mixed format
|
||||
triples = [
|
||||
# Legacy format
|
||||
{"h": "A", "r": "KNOWS", "t": "B"},
|
||||
# Extended format
|
||||
{
|
||||
"h": {"name": "C", "age": 30},
|
||||
"r": {"type": "LIKES", "weight": 0.8},
|
||||
"t": {"name": "D", "role": "User"},
|
||||
},
|
||||
]
|
||||
# Mock config where it's imported in upload_graph_service.py
|
||||
# The import is `from src import config`, so patch at the usage location
|
||||
mock_config = MagicMock()
|
||||
mock_config.embed_model = "test_model"
|
||||
mock_embed_info = MagicMock()
|
||||
mock_embed_info.dimension = 1024
|
||||
mock_config.embed_model_names = {"test_model": mock_embed_info}
|
||||
|
||||
# Run the method
|
||||
await gd.txt_add_vector_entity(triples)
|
||||
with patch("src.knowledge.graphs.upload_graph_service.config", mock_config):
|
||||
# Test data: Mixed format
|
||||
triples = [
|
||||
# Legacy format
|
||||
{"h": "A", "r": "KNOWS", "t": "B"},
|
||||
# Extended format
|
||||
{
|
||||
"h": {"name": "C", "age": 30},
|
||||
"r": {"type": "LIKES", "weight": 0.8},
|
||||
"t": {"name": "D", "role": "User"},
|
||||
},
|
||||
]
|
||||
|
||||
# Verify calls to mock_tx.run
|
||||
merge_calls = []
|
||||
for call in mock_tx.run.call_args_list:
|
||||
args, kwargs = call
|
||||
query = args[0] if args else kwargs.get("query", "")
|
||||
if "MERGE (h:Entity:Upload" in query:
|
||||
# The args are passed as kwargs to run: h_name=..., etc.
|
||||
merge_calls.append(kwargs)
|
||||
# Run the method
|
||||
await gd.txt_add_vector_entity(triples)
|
||||
|
||||
assert len(merge_calls) == 2, f"Expected 2 merge calls, got {len(merge_calls)}"
|
||||
# Verify calls to mock_tx.run
|
||||
merge_calls = []
|
||||
for call in mock_tx.run.call_args_list:
|
||||
args, kwargs = call
|
||||
query = args[0] if args else kwargs.get("query", "")
|
||||
if "MERGE (h:Entity:Upload" in query:
|
||||
# The args are passed as kwargs to run: h_name=..., etc.
|
||||
merge_calls.append(kwargs)
|
||||
|
||||
# Call 1 (Legacy)
|
||||
call1 = merge_calls[0]
|
||||
assert call1["h_name"] == "A"
|
||||
assert call1["h_props"] == {}
|
||||
assert call1["t_name"] == "B"
|
||||
assert call1["t_props"] == {}
|
||||
assert call1["r_type"] == "KNOWS"
|
||||
assert call1["r_props"] == {}
|
||||
assert len(merge_calls) == 2, f"Expected 2 merge calls, got {len(merge_calls)}"
|
||||
|
||||
# Call 2 (Extended)
|
||||
call2 = merge_calls[1]
|
||||
assert call2["h_name"] == "C"
|
||||
assert call2["h_props"] == {"age": 30}
|
||||
assert call2["t_name"] == "D"
|
||||
assert call2["t_props"] == {"role": "User"}
|
||||
assert call2["r_type"] == "LIKES"
|
||||
assert call2["r_props"] == {"weight": 0.8}
|
||||
# Call 1 (Legacy)
|
||||
call1 = merge_calls[0]
|
||||
assert call1["h_name"] == "A"
|
||||
assert call1["h_props"] == {}
|
||||
assert call1["t_name"] == "B"
|
||||
assert call1["t_props"] == {}
|
||||
assert call1["r_type"] == "KNOWS"
|
||||
assert call1["r_props"] == {}
|
||||
|
||||
print("Verification passed!")
|
||||
# Call 2 (Extended)
|
||||
call2 = merge_calls[1]
|
||||
assert call2["h_name"] == "C"
|
||||
assert call2["h_props"] == {"age": 30}
|
||||
assert call2["t_name"] == "D"
|
||||
assert call2["t_props"] == {"role": "User"}
|
||||
assert call2["r_type"] == "LIKES"
|
||||
assert call2["r_props"] == {"weight": 0.8}
|
||||
|
||||
print("Verification passed!")
|
||||
|
||||
@ -49,20 +49,19 @@ async def test_delete_knowledge_base_cleanup():
|
||||
|
||||
# 上传到 kb-documents (原始文件)
|
||||
test_object_name = f"{db_id}/test_file_{db_id[:8]}.txt"
|
||||
upload_result = minio_client.upload_file(
|
||||
minio_client.upload_file(
|
||||
bucket_name="kb-documents",
|
||||
object_name=test_object_name,
|
||||
data=test_file_content,
|
||||
content_type="text/plain",
|
||||
)
|
||||
test_file_url = upload_result.url
|
||||
print(f" 文件上传到 kb-documents: {test_object_name}")
|
||||
|
||||
# 上传到 kb-parsed (解析后的 markdown)
|
||||
test_file_id = f"file_test_{db_id[:8]}"
|
||||
parsed_object_name = f"{db_id}/{test_file_id}/parsed.md"
|
||||
parsed_content = b"# Test Parsed Content\n\nThis is parsed markdown."
|
||||
upload_result_parsed = minio_client.upload_file(
|
||||
minio_client.upload_file(
|
||||
bucket_name="kb-parsed",
|
||||
object_name=parsed_object_name,
|
||||
data=parsed_content,
|
||||
|
||||
@ -1,286 +0,0 @@
|
||||
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: dict[str, Any] | None = None,
|
||||
skill_snapshot: dict[str, Any] | None = None,
|
||||
) -> _FakeRequest:
|
||||
context = SimpleNamespace(
|
||||
system_prompt=system_prompt,
|
||||
skills=skills,
|
||||
tools=[],
|
||||
knowledges=[],
|
||||
mcps=[],
|
||||
)
|
||||
if skill_snapshot is not None:
|
||||
context.skill_session_snapshot = skill_snapshot
|
||||
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,
|
||||
skill_session_snapshot=_build_snapshot(selected=skills, visible=visible_skills),
|
||||
)
|
||||
),
|
||||
state={},
|
||||
)
|
||||
|
||||
|
||||
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],
|
||||
visible: list[str] | None = None,
|
||||
metadata: dict[str, dict[str, str]] | None = None,
|
||||
dependency_map: dict[str, dict[str, list[str]]] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
"selected_skills": selected,
|
||||
"visible_skills": visible if visible is not None else selected,
|
||||
"prompt_metadata": metadata or {},
|
||||
"dependency_map": dependency_map or {},
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_abefore_agent_resolves_visible_skills_and_preinjects_prompt(monkeypatch: pytest.MonkeyPatch):
|
||||
async def fake_resolve(selected):
|
||||
assert selected == ["deliver-prd"]
|
||||
return _build_snapshot(
|
||||
selected=["deliver-prd"],
|
||||
visible=["deliver-prd", "brainstorming"],
|
||||
metadata={
|
||||
"deliver-prd": {
|
||||
"name": "deliver-prd",
|
||||
"description": "deliver prd",
|
||||
"path": "/skills/deliver-prd/SKILL.md",
|
||||
},
|
||||
"brainstorming": {
|
||||
"name": "brainstorming",
|
||||
"description": "brainstorming desc",
|
||||
"path": "/skills/brainstorming/SKILL.md",
|
||||
},
|
||||
},
|
||||
dependency_map={
|
||||
"deliver-prd": {"tools": [], "mcps": [], "skills": ["brainstorming"]},
|
||||
"brainstorming": {"tools": [], "mcps": [], "skills": []},
|
||||
},
|
||||
)
|
||||
|
||||
monkeypatch.setattr(runtime_middleware, "resolve_session_snapshot", fake_resolve)
|
||||
middleware = _build_middleware()
|
||||
request = _build_request(skills=["deliver-prd"], tools=["read_file"], system_prompt="你是助手")
|
||||
|
||||
await middleware.abefore_agent(request.state, request.runtime)
|
||||
|
||||
snapshot = request.runtime.context.skill_session_snapshot
|
||||
assert snapshot["selected_skills"] == ["deliver-prd"]
|
||||
assert snapshot["visible_skills"] == ["deliver-prd", "brainstorming"]
|
||||
assert snapshot["dependency_map"]["deliver-prd"]["skills"] == ["brainstorming"]
|
||||
assert request.runtime.context._skills_prompt_injected is True
|
||||
assert "## Skills System" in request.runtime.context.system_prompt
|
||||
assert "- **deliver-prd**: deliver prd" in request.runtime.context.system_prompt
|
||||
assert "- **brainstorming**: brainstorming desc" in request.runtime.context.system_prompt
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_abefore_agent_injection_is_idempotent(monkeypatch: pytest.MonkeyPatch):
|
||||
async def fake_resolve(_selected):
|
||||
return _build_snapshot(
|
||||
selected=["alpha"],
|
||||
metadata={"alpha": {"name": "alpha", "description": "alpha desc", "path": "/skills/alpha/SKILL.md"}},
|
||||
)
|
||||
|
||||
monkeypatch.setattr(runtime_middleware, "resolve_session_snapshot", fake_resolve)
|
||||
middleware = _build_middleware()
|
||||
request = _build_request(skills=["alpha"], tools=["read_file"], system_prompt="base prompt")
|
||||
|
||||
await middleware.abefore_agent(request.state, request.runtime)
|
||||
await middleware.abefore_agent(request.state, request.runtime)
|
||||
|
||||
assert request.runtime.context.system_prompt.count("## Skills System") == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_awrap_model_call_keeps_preinjected_skills_prompt(monkeypatch: pytest.MonkeyPatch):
|
||||
middleware = _build_middleware()
|
||||
request = _build_request(
|
||||
skills=["alpha"],
|
||||
tools=["read_file"],
|
||||
system_prompt="base prompt\n\n## Skills System\n- **alpha**: alpha desc",
|
||||
)
|
||||
|
||||
def raise_if_called(_skills_meta):
|
||||
raise AssertionError("_build_skills_section should not be called in awrap_model_call")
|
||||
|
||||
monkeypatch.setattr(middleware, "_build_skills_section", raise_if_called)
|
||||
result = await middleware.awrap_model_call(request, _echo_handler)
|
||||
prompt = _extract_appended_prompt(result)
|
||||
|
||||
assert "当前时间:" in prompt
|
||||
assert "## Skills System" in prompt
|
||||
assert "- **alpha**: alpha desc" in prompt
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_abefore_agent_degrades_when_resolver_fails(monkeypatch: pytest.MonkeyPatch):
|
||||
async def fake_resolve(_selected):
|
||||
raise RuntimeError("boom")
|
||||
|
||||
monkeypatch.setattr(runtime_middleware, "resolve_session_snapshot", fake_resolve)
|
||||
middleware = _build_middleware()
|
||||
request = _build_request(skills=["alpha"], tools=["read_file"], system_prompt="base prompt")
|
||||
|
||||
await middleware.abefore_agent(request.state, request.runtime)
|
||||
|
||||
snapshot = request.runtime.context.skill_session_snapshot
|
||||
assert snapshot["selected_skills"] == ["alpha"]
|
||||
assert snapshot["visible_skills"] == []
|
||||
assert "## Skills System" not in request.runtime.context.system_prompt
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_awrap_tool_call_activates_skill_when_visible_in_context():
|
||||
middleware = _build_middleware()
|
||||
request = _build_tool_request(
|
||||
skills=["deliver-prd"],
|
||||
visible_skills=["deliver-prd", "brainstorming"],
|
||||
file_path="/skills/brainstorming/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"] == ["brainstorming"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_awrap_tool_call_denies_invisible_skill():
|
||||
middleware = _build_middleware()
|
||||
request = _build_tool_request(
|
||||
skills=["deliver-prd"],
|
||||
visible_skills=["deliver-prd"],
|
||||
file_path="/skills/brainstorming/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_kb_based_tools", lambda db_names=None: [])
|
||||
|
||||
snapshot = _build_snapshot(
|
||||
selected=["alpha"],
|
||||
visible=["alpha", "beta"],
|
||||
dependency_map={"alpha": {"tools": ["dep-tool"], "mcps": ["mcp-a"], "skills": ["beta"]}},
|
||||
)
|
||||
|
||||
def fake_build_dependency_bundle(received_snapshot, activated):
|
||||
assert received_snapshot == snapshot
|
||||
assert activated == ["alpha"]
|
||||
return {"tools": ["dep-tool"], "mcps": ["mcp-a"], "skills": ["alpha", "beta"]}
|
||||
|
||||
monkeypatch.setattr(runtime_middleware, "build_dependency_bundle", fake_build_dependency_bundle)
|
||||
|
||||
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=["alpha"],
|
||||
tools=["calculator", "dep-tool", "mcp_tool", "read_file"],
|
||||
state={"activated_skills": ["alpha"]},
|
||||
skill_snapshot=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
|
||||
@ -1,85 +0,0 @@
|
||||
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
|
||||
@ -7,6 +7,7 @@ from pathlib import Path
|
||||
import pytest
|
||||
|
||||
from src.services import skill_service as svc
|
||||
from src.services import tool_service
|
||||
from src.storage.postgres.models_business import Skill
|
||||
|
||||
|
||||
@ -31,15 +32,26 @@ def test_parse_skill_markdown_requires_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")
|
||||
def test_is_valid_skill_slug():
|
||||
# Test valid slugs
|
||||
assert svc.is_valid_skill_slug("demo-skill") is True
|
||||
assert svc.is_valid_skill_slug("valid-name-123") is True
|
||||
# Test invalid slugs
|
||||
assert svc.is_valid_skill_slug("../bad") is False
|
||||
assert svc.is_valid_skill_slug("Invalid") is False # uppercase not allowed
|
||||
assert svc.is_valid_skill_slug("") is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_skill_dependency_options(monkeypatch: pytest.MonkeyPatch):
|
||||
monkeypatch.setattr(svc, "_get_buildin_tool_names", lambda: ["calculator", "search"])
|
||||
# Mock get_tool_metadata to return tool list
|
||||
def fake_get_tool_metadata(category=None):
|
||||
return [
|
||||
{"id": "calculator", "name": "Calculator"},
|
||||
{"id": "search", "name": "Search"},
|
||||
]
|
||||
|
||||
monkeypatch.setattr(tool_service, "get_tool_metadata", fake_get_tool_metadata)
|
||||
monkeypatch.setattr(svc, "get_mcp_server_names", lambda: ["mcp-a", "mcp-b"])
|
||||
|
||||
class FakeRepo:
|
||||
@ -55,7 +67,7 @@ async def test_get_skill_dependency_options(monkeypatch: pytest.MonkeyPatch):
|
||||
monkeypatch.setattr(svc, "SkillRepository", FakeRepo)
|
||||
|
||||
result = await svc.get_skill_dependency_options(None)
|
||||
assert result["tools"] == ["calculator", "search"]
|
||||
assert result["tools"] == [{"id": "calculator", "name": "Calculator"}, {"id": "search", "name": "Search"}]
|
||||
assert result["mcps"] == ["mcp-a", "mcp-b"]
|
||||
assert result["skills"] == ["alpha", "beta"]
|
||||
|
||||
@ -202,7 +214,12 @@ async def test_update_skill_dependencies(monkeypatch: pytest.MonkeyPatch):
|
||||
mcp_dependencies=[],
|
||||
skill_dependencies=[],
|
||||
)
|
||||
monkeypatch.setattr(svc, "_get_buildin_tool_names", lambda: ["calculator"])
|
||||
|
||||
# Mock get_tool_metadata to return tool list
|
||||
def fake_get_tool_metadata(category=None):
|
||||
return [{"id": "calculator", "name": "Calculator"}]
|
||||
|
||||
monkeypatch.setattr(tool_service, "get_tool_metadata", fake_get_tool_metadata)
|
||||
monkeypatch.setattr(svc, "get_mcp_server_names", lambda: ["mcp-a"])
|
||||
|
||||
async def fake_get_skill_or_raise(_db, slug: str):
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
from src.agents.common.backends import skills_backend
|
||||
|
||||
@ -44,69 +43,3 @@ def test_selected_skills_backend_readonly_and_visible_only_selected(tmp_path, mo
|
||||
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"],
|
||||
skill_session_snapshot={
|
||||
"selected_skills": ["alpha"],
|
||||
"visible_skills": ["alpha", "beta"],
|
||||
"prompt_metadata": {},
|
||||
"dependency_map": {},
|
||||
},
|
||||
),
|
||||
state={},
|
||||
)
|
||||
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/"]
|
||||
|
||||
|
||||
def test_composite_backend_reads_visible_skills_from_runtime_context(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"],
|
||||
skill_session_snapshot={
|
||||
"selected_skills": ["alpha"],
|
||||
"visible_skills": ["alpha", "beta"],
|
||||
"prompt_metadata": {},
|
||||
"dependency_map": {},
|
||||
},
|
||||
),
|
||||
state=None,
|
||||
)
|
||||
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/", "/skills/beta/"]
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "yuxi-know-web",
|
||||
"version": "0.5.0.web",
|
||||
"version": "0.5.1.web",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
@ -13,40 +13,39 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@ant-design/icons-vue": "^7.0.1",
|
||||
"@antv/g6": "^5.0.49",
|
||||
"@antv/g6": "^5.0.51",
|
||||
"@sigma/edge-curve": "^3.1.0",
|
||||
"@sigma/node-border": "^3.0.0",
|
||||
"@vueuse/core": "^13.9.0",
|
||||
"ant-design-vue": "^4.2.6",
|
||||
"d3": "^7.9.0",
|
||||
"dayjs": "^1.11.18",
|
||||
"dayjs": "^1.11.19",
|
||||
"echarts": "^6.0.0",
|
||||
"echarts-gl": "^2.0.9",
|
||||
"graphology": "^0.26.0",
|
||||
"graphology-generators": "^0.11.2",
|
||||
"highlight.js": "^11.11.1",
|
||||
"less": "^4.4.1",
|
||||
"less": "^4.5.1",
|
||||
"lucide-vue-next": "^0.542.0",
|
||||
"marked": "^16.2.1",
|
||||
"marked-highlight": "^2.2.2",
|
||||
"marked": "^16.4.2",
|
||||
"marked-highlight": "^2.2.3",
|
||||
"markmap-lib": "^0.18.12",
|
||||
"markmap-view": "^0.18.12",
|
||||
"md-editor-v3": "^5.8.4",
|
||||
"pinia": "^3.0.3",
|
||||
"md-editor-v3": "^5.8.5",
|
||||
"pinia": "^3.0.4",
|
||||
"pinia-plugin-persistedstate": "^4.7.1",
|
||||
"sigma": "^3.0.2",
|
||||
"vue": "^3.5.21",
|
||||
"vue-router": "^4.5.1"
|
||||
"vue": "^3.5.29",
|
||||
"vue-router": "^4.6.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.2",
|
||||
"@vitejs/plugin-vue": "^6.0.1",
|
||||
"@eslint/js": "^9.39.3",
|
||||
"@vitejs/plugin-vue": "^6.0.4",
|
||||
"@vue/eslint-config-prettier": "^10.2.0",
|
||||
"eslint": "^9.34.0",
|
||||
"eslint-plugin-vue": "^10.4.0",
|
||||
"globals": "^17.0.0",
|
||||
"prettier": "^3.6.2",
|
||||
"vite": "^7.1.5"
|
||||
"eslint": "^9.39.3",
|
||||
"eslint-plugin-vue": "^10.8.0",
|
||||
"globals": "^17.4.0",
|
||||
"prettier": "^3.8.1",
|
||||
"vite": "^7.3.1"
|
||||
},
|
||||
"packageManager": "pnpm@10.11.0"
|
||||
}
|
||||
|
||||
2282
web/pnpm-lock.yaml
2282
web/pnpm-lock.yaml
File diff suppressed because it is too large
Load Diff
@ -286,10 +286,12 @@ export const threadApi = {
|
||||
/**
|
||||
* 获取对话线程列表
|
||||
* @param {string} agentId - 智能体ID
|
||||
* @param {number} limit - 返回数量限制,默认100
|
||||
* @param {number} offset - 偏移量,默认0
|
||||
* @returns {Promise} - 对话线程列表
|
||||
*/
|
||||
getThreads: (agentId) => {
|
||||
const url = `/api/chat/threads?agent_id=${agentId}`
|
||||
getThreads: (agentId, limit = 100, offset = 0) => {
|
||||
const url = `/api/chat/threads?agent_id=${agentId}&limit=${limit}&offset=${offset}`
|
||||
return apiGet(url)
|
||||
},
|
||||
|
||||
@ -311,13 +313,13 @@ export const threadApi = {
|
||||
* 更新对话线程
|
||||
* @param {string} threadId - 对话线程ID
|
||||
* @param {string} title - 对话标题
|
||||
* @param {string} description - 对话描述
|
||||
* @param {boolean} is_pinned - 是否置顶
|
||||
* @returns {Promise} - 更新结果
|
||||
*/
|
||||
updateThread: (threadId, title, description) =>
|
||||
updateThread: (threadId, title, is_pinned) =>
|
||||
apiPut(`/api/chat/thread/${threadId}`, {
|
||||
title,
|
||||
description
|
||||
is_pinned
|
||||
}),
|
||||
|
||||
/**
|
||||
|
||||
@ -9,12 +9,16 @@
|
||||
:agents="agents"
|
||||
:selected-agent-id="currentAgentId"
|
||||
:is-creating-new-chat="chatUIStore.creatingNewChat"
|
||||
:has-more-chats="hasMoreChats"
|
||||
:is-loading-more="isLoadingMoreChats"
|
||||
@create-chat="createNewChat"
|
||||
@select-chat="selectChat"
|
||||
@delete-chat="deleteChat"
|
||||
@rename-chat="renameChat"
|
||||
@toggle-pin="togglePinChat"
|
||||
@toggle-sidebar="toggleSidebar"
|
||||
@open-agent-modal="openAgentModal"
|
||||
@load-more-chats="loadMoreChats"
|
||||
:class="{
|
||||
'sidebar-open': chatUIStore.isSidebarOpen,
|
||||
'no-transition': localUIState.isInitialRender
|
||||
@ -108,8 +112,11 @@
|
||||
:visible="approvalState.showModal"
|
||||
:question="approvalState.question"
|
||||
:operation="approvalState.operation"
|
||||
@approve="handleApprove"
|
||||
@reject="handleReject"
|
||||
:options="approvalState.options"
|
||||
:multi-select="approvalState.multiSelect"
|
||||
:allow-other="approvalState.allowOther"
|
||||
@submit="handleQuestionSubmit"
|
||||
@cancel="handleQuestionCancel"
|
||||
/>
|
||||
|
||||
<div class="message-input-wrapper">
|
||||
@ -287,6 +294,8 @@ const chatState = reactive({
|
||||
// 组件级别的线程和消息状态
|
||||
const threads = ref([])
|
||||
const threadMessages = ref({})
|
||||
const hasMoreChats = ref(true) // 是否还有更多对话可加载
|
||||
const isLoadingMoreChats = ref(false) // 加载更多对话中
|
||||
const threadFilesMap = ref({})
|
||||
const threadAttachmentsMap = ref({})
|
||||
|
||||
@ -372,6 +381,14 @@ const hasAgentStateContent = computed(() => {
|
||||
return todoCount > 0 || fileCount > 0
|
||||
})
|
||||
|
||||
// 监听 hasAgentStateContent 从 false → true 时,自动展开面板
|
||||
watch(hasAgentStateContent, (newVal, oldVal) => {
|
||||
if (newVal && !oldVal) {
|
||||
// 从无状态变为有状态时,自动展开面板
|
||||
isAgentPanelOpen.value = true
|
||||
}
|
||||
})
|
||||
|
||||
const mentionConfig = computed(() => {
|
||||
const fileMap = new Map()
|
||||
currentThreadFiles.value
|
||||
@ -591,8 +608,10 @@ const fetchThreads = async (agentId = null) => {
|
||||
|
||||
chatUIStore.isLoadingThreads = true
|
||||
try {
|
||||
const fetchedThreads = await threadApi.getThreads(targetAgentId)
|
||||
const fetchedThreads = await threadApi.getThreads(targetAgentId, 100, 0)
|
||||
threads.value = fetchedThreads || []
|
||||
// 如果返回的数量小于limit,说明没有更多了
|
||||
hasMoreChats.value = fetchedThreads && fetchedThreads.length >= 100
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch threads:', error)
|
||||
handleChatError(error, 'fetch')
|
||||
@ -602,6 +621,35 @@ const fetchThreads = async (agentId = null) => {
|
||||
}
|
||||
}
|
||||
|
||||
// 加载更多对话
|
||||
const loadMoreChats = async () => {
|
||||
if (isLoadingMoreChats.value || !hasMoreChats.value) return
|
||||
|
||||
const targetAgentId = currentAgentId.value
|
||||
if (!targetAgentId) return
|
||||
|
||||
isLoadingMoreChats.value = true
|
||||
try {
|
||||
const offset = threads.value.length
|
||||
const fetchedThreads = await threadApi.getThreads(targetAgentId, 100, offset)
|
||||
if (fetchedThreads && fetchedThreads.length > 0) {
|
||||
// 去除重复的置顶对话(后端每次都返回所有置顶对话)
|
||||
const existingIds = new Set(threads.value.map((t) => t.id))
|
||||
const newThreads = fetchedThreads.filter((t) => !existingIds.has(t.id))
|
||||
|
||||
threads.value = [...threads.value, ...newThreads]
|
||||
hasMoreChats.value = newThreads.length >= 100
|
||||
} else {
|
||||
hasMoreChats.value = false
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load more chats:', error)
|
||||
handleChatError(error, 'fetch')
|
||||
} finally {
|
||||
isLoadingMoreChats.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 创建新线程
|
||||
const createThread = async (agentId, title = '新的对话') => {
|
||||
if (!agentId) return null
|
||||
@ -650,25 +698,43 @@ const deleteThread = async (threadId) => {
|
||||
}
|
||||
|
||||
// 更新线程标题
|
||||
const updateThread = async (threadId, title) => {
|
||||
if (!threadId || !title) return
|
||||
const updateThread = async (threadId, title, is_pinned) => {
|
||||
if (!threadId) return
|
||||
|
||||
const normalizedTitle = String(title).replace(/\s+/g, ' ').trim().slice(0, 255)
|
||||
if (!normalizedTitle) return
|
||||
if (title) {
|
||||
const normalizedTitle = String(title).replace(/\s+/g, ' ').trim().slice(0, 255)
|
||||
if (!normalizedTitle) return
|
||||
|
||||
chatState.isRenamingThread = true
|
||||
try {
|
||||
await threadApi.updateThread(threadId, normalizedTitle)
|
||||
const thread = threads.value.find((t) => t.id === threadId)
|
||||
if (thread) {
|
||||
thread.title = normalizedTitle
|
||||
chatState.isRenamingThread = true
|
||||
try {
|
||||
await threadApi.updateThread(threadId, normalizedTitle, is_pinned)
|
||||
const thread = threads.value.find((t) => t.id === threadId)
|
||||
if (thread) {
|
||||
thread.title = normalizedTitle
|
||||
if (is_pinned !== undefined) {
|
||||
thread.is_pinned = is_pinned
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to update thread:', error)
|
||||
handleChatError(error, 'update')
|
||||
throw error
|
||||
} finally {
|
||||
chatState.isRenamingThread = false
|
||||
}
|
||||
} else if (is_pinned !== undefined) {
|
||||
// 只更新置顶状态
|
||||
try {
|
||||
await threadApi.updateThread(threadId, null, is_pinned)
|
||||
const thread = threads.value.find((t) => t.id === threadId)
|
||||
if (thread) {
|
||||
thread.is_pinned = is_pinned
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to update thread pin status:', error)
|
||||
handleChatError(error, 'update')
|
||||
throw error
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to update thread:', error)
|
||||
handleChatError(error, 'update')
|
||||
throw error
|
||||
} finally {
|
||||
chatState.isRenamingThread = false
|
||||
}
|
||||
}
|
||||
|
||||
@ -1110,7 +1176,12 @@ const startRunStream = async (threadId, runId, afterSeq = '0') => {
|
||||
}
|
||||
}
|
||||
|
||||
if (event === 'finished' || event === 'error' || event === 'interrupted') {
|
||||
if (
|
||||
event === 'finished' ||
|
||||
event === 'error' ||
|
||||
event === 'interrupted' ||
|
||||
event === 'ask_user_question_required'
|
||||
) {
|
||||
flushTypingQueueForThread(threadId)
|
||||
ts.isStreaming = false
|
||||
ts.activeRunId = null
|
||||
@ -1262,15 +1333,25 @@ const sendMessage = async ({
|
||||
// 检查第一个对话是否为空
|
||||
const isFirstChatEmpty = () => {
|
||||
if (threads.value.length === 0) return false
|
||||
const firstThread = threads.value[0]
|
||||
const firstThreadMessages = threadMessages.value[firstThread.id] || []
|
||||
return firstThreadMessages.length === 0
|
||||
const chatToReuse = getFirstNonPinnedChat(threads.value)
|
||||
const messages = threadMessages.value[chatToReuse.id]
|
||||
// 只有当消息已加载且为空时才返回 true
|
||||
return messages !== undefined && messages.length === 0
|
||||
}
|
||||
|
||||
// 如果第一个对话为空,直接切换到第一个对话
|
||||
// 获取第一个非置顶的对话
|
||||
const getFirstNonPinnedChat = (chatList) => {
|
||||
if (!chatList || chatList.length === 0) return null
|
||||
return chatList.find((chat) => !chat.is_pinned) || chatList[0]
|
||||
}
|
||||
|
||||
// 如果第一个对话为空,直接切换到第一个非置顶对话
|
||||
const switchToFirstChatIfEmpty = async () => {
|
||||
if (threads.value.length > 0 && isFirstChatEmpty()) {
|
||||
await selectChat(threads.value[0].id)
|
||||
const chatToReuse = getFirstNonPinnedChat(threads.value)
|
||||
if (chatState.currentThreadId !== chatToReuse.id) {
|
||||
await selectChat(chatToReuse.id)
|
||||
}
|
||||
return true
|
||||
}
|
||||
return false
|
||||
@ -1286,10 +1367,6 @@ const createNewChat = async () => {
|
||||
// 如果第一个对话为空,直接切换到第一个对话而不是创建新对话
|
||||
if (await switchToFirstChatIfEmpty()) return
|
||||
|
||||
// 只有当当前对话是第一个对话且为空时,才阻止创建新对话
|
||||
const currentThreadIndex = threads.value.findIndex((thread) => thread.id === currentChatId.value)
|
||||
if (currentChatId.value && conversations.value.length === 0 && currentThreadIndex === 0) return
|
||||
|
||||
chatUIStore.creatingNewChat = true
|
||||
try {
|
||||
const newThread = await createThread(currentAgentId.value, '新的对话')
|
||||
@ -1369,8 +1446,8 @@ const deleteChat = async (chatId) => {
|
||||
// 如果删除的是当前对话,自动创建新对话
|
||||
await createNewChat()
|
||||
} else if (chatsList.value.length > 0) {
|
||||
// 如果删除的不是当前对话,选择第一个可用对话
|
||||
await selectChat(chatsList.value[0].id)
|
||||
// 如果删除的不是当前对话,选择第一个非置顶可用对话
|
||||
await selectChat(getFirstNonPinnedChat(chatsList.value).id)
|
||||
}
|
||||
} catch (error) {
|
||||
handleChatError(error, 'delete')
|
||||
@ -1396,6 +1473,27 @@ const renameChat = async (data) => {
|
||||
}
|
||||
}
|
||||
|
||||
const togglePinChat = async (chatId) => {
|
||||
const chat = chatsList.value.find((c) => c.id === chatId)
|
||||
if (!chat) return
|
||||
try {
|
||||
// 保存当前选中的对话ID
|
||||
const prevChatId = currentChatId.value
|
||||
|
||||
await updateThread(chatId, null, !chat.is_pinned)
|
||||
|
||||
// 刷新对话列表
|
||||
await loadChatsList()
|
||||
|
||||
// 恢复当前选中的对话
|
||||
if (prevChatId) {
|
||||
chatState.currentThreadId = prevChatId
|
||||
}
|
||||
} catch (error) {
|
||||
handleChatError(error, 'pin')
|
||||
}
|
||||
}
|
||||
|
||||
const handleSendMessage = async ({ image } = {}) => {
|
||||
const text = userInput.value.trim()
|
||||
if ((!text && !image) || !currentAgent.value || isProcessing.value) return
|
||||
@ -1516,10 +1614,10 @@ const handleSendOrStop = async (payload) => {
|
||||
}
|
||||
|
||||
// ==================== 人工审批处理 ====================
|
||||
const handleApprovalWithStream = async (approved) => {
|
||||
const handleApprovalWithStream = async (answer) => {
|
||||
const threadId = approvalState.threadId
|
||||
if (!threadId) {
|
||||
message.error('无效的审批请求')
|
||||
message.error('无效的提问请求')
|
||||
approvalState.showModal = false
|
||||
return
|
||||
}
|
||||
@ -1533,11 +1631,7 @@ const handleApprovalWithStream = async (approved) => {
|
||||
|
||||
try {
|
||||
// 使用审批 composable 处理审批
|
||||
const response = await handleApproval(
|
||||
approved,
|
||||
currentAgentId.value,
|
||||
selectedAgentConfigId.value
|
||||
)
|
||||
const response = await handleApproval(answer, currentAgentId.value, selectedAgentConfigId.value)
|
||||
|
||||
if (!response) return // 如果 handleApproval 抛出错误,这里不会执行
|
||||
|
||||
@ -1561,12 +1655,12 @@ const handleApprovalWithStream = async (approved) => {
|
||||
}
|
||||
}
|
||||
|
||||
const handleApprove = () => {
|
||||
handleApprovalWithStream(true)
|
||||
const handleQuestionSubmit = (answer) => {
|
||||
handleApprovalWithStream(answer)
|
||||
}
|
||||
|
||||
const handleReject = () => {
|
||||
handleApprovalWithStream(false)
|
||||
const handleQuestionCancel = () => {
|
||||
handleApprovalWithStream('reject')
|
||||
}
|
||||
|
||||
// 处理示例问题点击
|
||||
@ -1717,9 +1811,9 @@ const loadChatsList = async () => {
|
||||
chatState.currentThreadId = null
|
||||
}
|
||||
|
||||
// 如果有线程但没有选中任何线程,自动选择第一个
|
||||
// 如果有线程但没有选中任何线程,自动选择第一个非置顶对话
|
||||
if (threads.value.length > 0 && !chatState.currentThreadId) {
|
||||
await selectChat(threads.value[0].id)
|
||||
await selectChat(getFirstNonPinnedChat(threads.value).id)
|
||||
}
|
||||
} catch (error) {
|
||||
handleChatError(error, 'load')
|
||||
|
||||
@ -148,16 +148,40 @@
|
||||
<div v-if="getConfigOptions(value).length <= 5" class="multi-select-cards">
|
||||
<div class="multi-select-label">
|
||||
<span>已选择 {{ getSelectedCount(key) }} 项</span>
|
||||
<a-button
|
||||
type="link"
|
||||
size="small"
|
||||
class="clear-btn"
|
||||
@click="clearSelection(key)"
|
||||
v-if="getSelectedCount(key) > 0"
|
||||
>
|
||||
清空
|
||||
</a-button>
|
||||
<div class="label-actions">
|
||||
<a-button
|
||||
type="link"
|
||||
size="small"
|
||||
class="clear-btn"
|
||||
@click="clearSelection(key)"
|
||||
v-if="getSelectedCount(key) > 0"
|
||||
>
|
||||
清空
|
||||
</a-button>
|
||||
<template v-if="isToolsKind(value.template_metadata?.kind)">
|
||||
<a-divider type="vertical" />
|
||||
<a-button
|
||||
type="link"
|
||||
size="small"
|
||||
@click="refreshConfigOptions(key, value.template_metadata.kind)"
|
||||
class="action-btn"
|
||||
>
|
||||
<RotateCw :size="12" />
|
||||
刷新
|
||||
</a-button>
|
||||
<a-button
|
||||
type="link"
|
||||
size="small"
|
||||
@click="navigateToConfigPage(value.template_metadata.kind)"
|
||||
class="action-btn"
|
||||
>
|
||||
<Settings :size="12" />
|
||||
配置
|
||||
</a-button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="options-grid">
|
||||
<div
|
||||
v-for="option in getConfigOptions(value)"
|
||||
@ -322,6 +346,28 @@
|
||||
<Search :size="16" class="search-icon" />
|
||||
</template>
|
||||
</a-input>
|
||||
<template v-if="isToolsKind(currentConfigKind)">
|
||||
<a-button
|
||||
type="text"
|
||||
size="small"
|
||||
@click="refreshConfigOptions(currentConfigKey, currentConfigKind)"
|
||||
class="modal-action-btn"
|
||||
title="刷新列表"
|
||||
>
|
||||
<RotateCw :size="14" />
|
||||
刷新
|
||||
</a-button>
|
||||
<a-button
|
||||
type="text"
|
||||
size="small"
|
||||
@click="navigateToConfigPage(currentConfigKind)"
|
||||
class="modal-action-btn"
|
||||
title="跳转配置"
|
||||
>
|
||||
<Settings :size="14" />
|
||||
配置
|
||||
</a-button>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div class="selection-list">
|
||||
@ -367,7 +413,8 @@
|
||||
<script setup>
|
||||
import { ref, computed, nextTick, watch } from 'vue'
|
||||
import { message, Modal } from 'ant-design-vue'
|
||||
import { X, Trash2, Check, Plus, Search, Star } from 'lucide-vue-next'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { X, Trash2, Check, Plus, Search, Star, RotateCw, Settings } from 'lucide-vue-next'
|
||||
import ModelSelectorComponent from '@/components/ModelSelectorComponent.vue'
|
||||
import { useAgentStore } from '@/stores/agent'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
@ -395,6 +442,7 @@ const emit = defineEmits(['close'])
|
||||
const agentStore = useAgentStore()
|
||||
const userStore = useUserStore()
|
||||
const databaseStore = useDatabaseStore()
|
||||
const router = useRouter()
|
||||
|
||||
watch(
|
||||
() => props.isOpen,
|
||||
@ -476,11 +524,15 @@ const segmentedOptions = computed(() => {
|
||||
return options
|
||||
})
|
||||
|
||||
const loadLiveSkillOptions = async () => {
|
||||
const loadLiveSkillOptions = async (force = false) => {
|
||||
if (!userStore.isAdmin) {
|
||||
liveSkillOptions.value = []
|
||||
return
|
||||
}
|
||||
// 如果不是强制刷新且已有数据,则跳过
|
||||
if (!force && liveSkillOptions.value.length > 0) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
const result = await skillApi.listSkills()
|
||||
const rows = result?.data || []
|
||||
@ -494,11 +546,15 @@ const loadLiveSkillOptions = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
const loadToolOptions = async () => {
|
||||
const loadToolOptions = async (force = false) => {
|
||||
if (!userStore.isAdmin) {
|
||||
toolOptionsFromApi.value = []
|
||||
return
|
||||
}
|
||||
// 如果不是强制刷新且已有数据,则跳过
|
||||
if (!force && toolOptionsFromApi.value.length > 0) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
const result = await toolApi.getTools()
|
||||
toolOptionsFromApi.value = (result?.data || []).map((item) => ({
|
||||
@ -511,6 +567,61 @@ const loadToolOptions = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
// 判断是否为需要跳转的配置类型
|
||||
const isToolsKind = (kind) => {
|
||||
return ['knowledges', 'tools', 'mcps', 'skills'].includes(kind)
|
||||
}
|
||||
|
||||
// 强制刷新对应配置项的选项列表
|
||||
const refreshConfigOptions = async (key, kind) => {
|
||||
try {
|
||||
switch (kind) {
|
||||
case 'knowledges':
|
||||
await databaseStore.loadDatabases(true)
|
||||
message.success('知识库列表已刷新')
|
||||
break
|
||||
case 'tools':
|
||||
await loadToolOptions(true)
|
||||
message.success('工具列表已刷新')
|
||||
break
|
||||
case 'skills':
|
||||
await loadLiveSkillOptions(true)
|
||||
message.success('Skills 列表已刷新')
|
||||
break
|
||||
case 'mcps':
|
||||
// MCP 没有前端 store,提示用户刷新页面
|
||||
message.info('请在 MCP 管理页面刷新')
|
||||
break
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('刷新配置选项失败:', error)
|
||||
message.error('刷新失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 跳转到对应管理页面
|
||||
const navigateToConfigPage = (kind) => {
|
||||
// 先关闭选择弹窗
|
||||
closeSelectionModal()
|
||||
// 延迟跳转,确保弹窗先关闭
|
||||
setTimeout(() => {
|
||||
switch (kind) {
|
||||
case 'knowledges':
|
||||
router.push('/database')
|
||||
break
|
||||
case 'tools':
|
||||
router.push({ path: '/extensions', query: { tab: 'tools' } })
|
||||
break
|
||||
case 'mcps':
|
||||
router.push({ path: '/extensions', query: { tab: 'mcp' } })
|
||||
break
|
||||
case 'skills':
|
||||
router.push({ path: '/extensions', query: { tab: 'skills' } })
|
||||
break
|
||||
}
|
||||
}, 100)
|
||||
}
|
||||
|
||||
// 通用选项获取与处理
|
||||
const getConfigOptions = (value) => {
|
||||
if (value?.template_metadata?.kind === 'tools') {
|
||||
@ -557,6 +668,11 @@ const getOptionDescription = (option) => {
|
||||
return null
|
||||
}
|
||||
|
||||
const currentConfigKind = computed(() => {
|
||||
if (!currentConfigKey.value) return null
|
||||
return configurableItems.value[currentConfigKey.value]?.template_metadata?.kind
|
||||
})
|
||||
|
||||
const filteredOptions = computed(() => {
|
||||
if (!currentConfigKey.value) return []
|
||||
const key = currentConfigKey.value
|
||||
@ -1167,6 +1283,27 @@ const confirmDeleteConfig = async () => {
|
||||
margin-bottom: 12px;
|
||||
font-size: 12px;
|
||||
color: var(--gray-600);
|
||||
|
||||
.label-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
|
||||
.action-btn {
|
||||
font-size: 12px;
|
||||
color: var(--gray-600);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
padding: 2px 6px;
|
||||
height: auto;
|
||||
line-height: 1;
|
||||
|
||||
&:hover {
|
||||
color: var(--main-color);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.options-grid {
|
||||
@ -1238,8 +1375,12 @@ const confirmDeleteConfig = async () => {
|
||||
.selection-modal-content {
|
||||
.selection-search {
|
||||
margin-bottom: 16px;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
|
||||
.search-input {
|
||||
flex: 1;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--gray-300);
|
||||
height: 36px;
|
||||
@ -1265,6 +1406,19 @@ const confirmDeleteConfig = async () => {
|
||||
border-color: var(--gray-400);
|
||||
}
|
||||
}
|
||||
|
||||
.modal-action-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 13px;
|
||||
color: var(--gray-600);
|
||||
white-space: nowrap;
|
||||
|
||||
&:hover {
|
||||
color: var(--main-color);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.selection-list {
|
||||
|
||||
@ -210,9 +210,6 @@ defineExpose({
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
&.disabled {
|
||||
opacity: 0.5;
|
||||
|
||||
@ -85,7 +85,9 @@
|
||||
<!-- 文件内容 Modal -->
|
||||
<a-modal
|
||||
v-model:open="modalVisible"
|
||||
width="80%"
|
||||
width="800px"
|
||||
:style="{ maxWidth: '90vw', top: '5vh' }"
|
||||
:bodyStyle="{ maxHeight: '90vh', overflow: 'auto' }"
|
||||
:footer="null"
|
||||
:closable="false"
|
||||
@cancel="closeModal"
|
||||
@ -135,7 +137,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref, onMounted, onUpdated, nextTick } from 'vue'
|
||||
import { computed, ref, onMounted, onUpdated, nextTick, watch } from 'vue'
|
||||
import { Download, X, FolderCode } from 'lucide-vue-next'
|
||||
import {
|
||||
CheckCircleOutlined,
|
||||
@ -224,6 +226,7 @@ const checkOverflow = () => {
|
||||
|
||||
onMounted(() => {
|
||||
nextTick(checkOverflow)
|
||||
updateActiveTab()
|
||||
})
|
||||
|
||||
onUpdated(() => {
|
||||
@ -237,6 +240,22 @@ const normalizedFiles = computed(() => {
|
||||
.map((item) => ({ ...item }))
|
||||
})
|
||||
|
||||
// 自动切换 tab 逻辑:如果 files 为空且有 todos,自动切换到 todos tab
|
||||
const updateActiveTab = () => {
|
||||
const fileList = normalizedFiles.value
|
||||
const todoList = todos.value
|
||||
|
||||
// 如果当前是 files tab,但文件为空且有 todos,切换到 todos
|
||||
if (activeTab.value === 'files' && fileList.length === 0 && todoList.length > 0) {
|
||||
activeTab.value = 'todos'
|
||||
}
|
||||
}
|
||||
|
||||
// 监听 files 和 todos 变化,自动切换 tab
|
||||
watch([() => props.agentState?.files, () => props.agentState?.todos], () => {
|
||||
updateActiveTab()
|
||||
}, { deep: true })
|
||||
|
||||
const expandedKeys = ref([])
|
||||
|
||||
const buildTreeData = (filesList) => {
|
||||
|
||||
@ -152,10 +152,6 @@ const processImageUpload = async (file) => {
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
&.disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.5;
|
||||
|
||||
@ -29,57 +29,85 @@
|
||||
</button>
|
||||
</div>
|
||||
<div class="conversation-list">
|
||||
<template v-if="Object.keys(groupedChats).length > 0">
|
||||
<div v-for="(group, groupName) in groupedChats" :key="groupName" class="chat-group">
|
||||
<div class="chat-group-title">{{ groupName }}</div>
|
||||
<div
|
||||
v-for="chat in group"
|
||||
:key="chat.id"
|
||||
class="conversation-item"
|
||||
:class="{ active: currentChatId === chat.id }"
|
||||
@click="selectChat(chat)"
|
||||
>
|
||||
<div class="conversation-title">{{ chat.title || '新的对话' }}</div>
|
||||
<div class="actions-mask"></div>
|
||||
<div class="conversation-actions">
|
||||
<a-dropdown :trigger="['click']" @click.stop>
|
||||
<template #overlay>
|
||||
<a-menu>
|
||||
<a-menu-item
|
||||
key="rename"
|
||||
@click.stop="renameChat(chat.id)"
|
||||
:icon="h(EditOutlined)"
|
||||
>
|
||||
重命名
|
||||
</a-menu-item>
|
||||
<a-menu-item
|
||||
key="delete"
|
||||
@click.stop="deleteChat(chat.id)"
|
||||
:icon="h(DeleteOutlined)"
|
||||
>
|
||||
删除
|
||||
</a-menu-item>
|
||||
</a-menu>
|
||||
</template>
|
||||
<template v-if="sortedChats.length > 0">
|
||||
<div
|
||||
v-for="chat in sortedChats"
|
||||
:key="chat.id"
|
||||
class="conversation-item"
|
||||
:class="{ active: currentChatId === chat.id }"
|
||||
@click="selectChat(chat)"
|
||||
@click.middle="handleMiddleClickDelete(chat.id)"
|
||||
>
|
||||
<div class="conversation-title">
|
||||
<span>{{ chat.title || '新的对话' }}</span>
|
||||
</div>
|
||||
<div class="actions-mask"></div>
|
||||
<div class="conversation-actions">
|
||||
<a-dropdown :trigger="['click']" @click.stop>
|
||||
<template #overlay>
|
||||
<a-menu>
|
||||
<a-menu-item
|
||||
key="pin"
|
||||
@click.stop="togglePin(chat.id)"
|
||||
:icon="h(chat.is_pinned ? PinOff : Pin, { size: 14 })"
|
||||
>
|
||||
{{ chat.is_pinned ? '取消置顶' : '置顶' }}
|
||||
</a-menu-item>
|
||||
<a-menu-item
|
||||
key="rename"
|
||||
@click.stop="renameChat(chat.id)"
|
||||
:icon="h(Pencil, { size: 14 })"
|
||||
>
|
||||
重命名
|
||||
</a-menu-item>
|
||||
<a-menu-item
|
||||
key="delete"
|
||||
@click.stop="deleteChat(chat.id)"
|
||||
:icon="h(Trash2, { size: 14 })"
|
||||
>
|
||||
删除
|
||||
</a-menu-item>
|
||||
</a-menu>
|
||||
</template>
|
||||
<div class="action-btn-wrapper">
|
||||
<a-button type="text" class="more-btn" @click.stop>
|
||||
<MoreOutlined />
|
||||
<MoreVertical :size="16" />
|
||||
</a-button>
|
||||
</a-dropdown>
|
||||
</div>
|
||||
<Pin v-if="chat.is_pinned" :size="14" class="pinned-indicator" />
|
||||
</div>
|
||||
</a-dropdown>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<div v-else class="empty-list">暂无对话历史</div>
|
||||
<div v-if="hasMoreChats" class="load-more-wrapper">
|
||||
<a-button
|
||||
type="text"
|
||||
class="load-more-btn"
|
||||
:loading="isLoadingMore"
|
||||
@click="handleLoadMore"
|
||||
>
|
||||
{{ isLoadingMore ? '加载中...' : '加载更多' }}
|
||||
</a-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, h } from 'vue'
|
||||
import { DeleteOutlined, EditOutlined, MoreOutlined } from '@ant-design/icons-vue'
|
||||
import { computed, h, ref } from 'vue'
|
||||
import { message, Modal } from 'ant-design-vue'
|
||||
import { PanelLeftClose, MessageSquarePlus, LoaderCircle } from 'lucide-vue-next'
|
||||
import {
|
||||
PanelLeftClose,
|
||||
MessageSquarePlus,
|
||||
LoaderCircle,
|
||||
Pin,
|
||||
PinOff,
|
||||
Pencil,
|
||||
Trash2,
|
||||
MoreVertical
|
||||
} from 'lucide-vue-next'
|
||||
import dayjs, { parseToShanghai } from '@/utils/time'
|
||||
import { useChatUIStore } from '@/stores/chatUI'
|
||||
import { useInfoStore } from '@/stores/info'
|
||||
@ -119,6 +147,14 @@ const props = defineProps({
|
||||
selectedAgentId: {
|
||||
type: String,
|
||||
default: null
|
||||
},
|
||||
hasMoreChats: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
isLoadingMore: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
})
|
||||
|
||||
@ -127,60 +163,24 @@ const emit = defineEmits([
|
||||
'select-chat',
|
||||
'delete-chat',
|
||||
'rename-chat',
|
||||
'toggle-pin',
|
||||
'toggle-sidebar',
|
||||
'open-agent-modal'
|
||||
'open-agent-modal',
|
||||
'load-more-chats'
|
||||
])
|
||||
|
||||
const groupedChats = computed(() => {
|
||||
const groups = {
|
||||
今天: [],
|
||||
七天内: [],
|
||||
三十天内: []
|
||||
}
|
||||
|
||||
// 确保使用北京时间进行比较
|
||||
const now = dayjs().tz('Asia/Shanghai')
|
||||
const today = now.startOf('day')
|
||||
const sevenDaysAgo = now.subtract(7, 'day').startOf('day')
|
||||
const thirtyDaysAgo = now.subtract(30, 'day').startOf('day')
|
||||
|
||||
// Sort chats by creation date, newest first
|
||||
const sortedChats = [...props.chatsList].sort((a, b) => {
|
||||
// 按置顶和时间倒序排列的对话列表
|
||||
const sortedChats = computed(() => {
|
||||
return [...props.chatsList].sort((a, b) => {
|
||||
// 置顶的排在前面
|
||||
if (a.is_pinned !== b.is_pinned) {
|
||||
return a.is_pinned ? -1 : 1
|
||||
}
|
||||
const dateA = parseToShanghai(b.created_at)
|
||||
const dateB = parseToShanghai(a.created_at)
|
||||
if (!dateA || !dateB) return 0
|
||||
return dateA.diff(dateB)
|
||||
})
|
||||
|
||||
sortedChats.forEach((chat) => {
|
||||
// 将后端时间当作UTC时间处理,然后转换为北京时间
|
||||
const chatDate = parseToShanghai(chat.created_at)
|
||||
if (!chatDate) {
|
||||
return
|
||||
}
|
||||
if (chatDate.isAfter(today)) {
|
||||
groups['今天'].push(chat)
|
||||
} else if (chatDate.isAfter(sevenDaysAgo)) {
|
||||
groups['七天内'].push(chat)
|
||||
} else if (chatDate.isAfter(thirtyDaysAgo)) {
|
||||
groups['三十天内'].push(chat)
|
||||
} else {
|
||||
const monthKey = chatDate.format('YYYY-MM')
|
||||
if (!groups[monthKey]) {
|
||||
groups[monthKey] = []
|
||||
}
|
||||
groups[monthKey].push(chat)
|
||||
}
|
||||
})
|
||||
|
||||
// Remove empty groups
|
||||
for (const key in groups) {
|
||||
if (groups[key].length === 0) {
|
||||
delete groups[key]
|
||||
}
|
||||
}
|
||||
|
||||
return groups
|
||||
})
|
||||
|
||||
const createNewChat = () => {
|
||||
@ -195,6 +195,10 @@ const deleteChat = (chatId) => {
|
||||
emit('delete-chat', chatId)
|
||||
}
|
||||
|
||||
const handleMiddleClickDelete = (chatId) => {
|
||||
emit('delete-chat', chatId)
|
||||
}
|
||||
|
||||
const renameChat = async (chatId) => {
|
||||
try {
|
||||
const chat = props.chatsList.find((c) => c.id === chatId)
|
||||
@ -237,6 +241,14 @@ const renameChat = async (chatId) => {
|
||||
const toggleCollapse = () => {
|
||||
emit('toggle-sidebar')
|
||||
}
|
||||
|
||||
const handleLoadMore = () => {
|
||||
emit('load-more-chats')
|
||||
}
|
||||
|
||||
const togglePin = (chatId) => {
|
||||
emit('toggle-pin', chatId)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
@ -386,6 +398,9 @@ const toggleCollapse = () => {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
transition: color 0.2s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.actions-mask {
|
||||
@ -410,27 +425,70 @@ const toggleCollapse = () => {
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s ease;
|
||||
|
||||
.action-btn-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.pinned-indicator {
|
||||
color: var(--main-color);
|
||||
opacity: 0.8;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.more-btn {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
color: var(--gray-600);
|
||||
background-color: transparent !important;
|
||||
display: none;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 0;
|
||||
z-index: 1;
|
||||
|
||||
&:hover {
|
||||
color: var(--main-500);
|
||||
background-color: transparent !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 默认显示置顶图标
|
||||
&:has(.pinned-indicator) {
|
||||
.conversation-actions,
|
||||
.actions-mask {
|
||||
opacity: 1;
|
||||
}
|
||||
.actions-mask {
|
||||
background: linear-gradient(to right, transparent, var(--bg-sider) 30px);
|
||||
}
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background-color: var(--gray-25);
|
||||
|
||||
.actions-mask {
|
||||
background: linear-gradient(to right, transparent, var(--gray-25) 20px);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.actions-mask,
|
||||
.conversation-actions {
|
||||
opacity: 1;
|
||||
|
||||
.more-btn {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.pinned-indicator {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -441,8 +499,16 @@ const toggleCollapse = () => {
|
||||
color: var(--main-600);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.actions-mask {
|
||||
background: linear-gradient(to right, transparent, var(--gray-50) 20px);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
&:has(.pinned-indicator) {
|
||||
.actions-mask {
|
||||
background: linear-gradient(to right, transparent, var(--gray-50) 30px);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -453,6 +519,19 @@ const toggleCollapse = () => {
|
||||
color: var(--gray-500);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.load-more-wrapper {
|
||||
text-align: center;
|
||||
padding: 12px 8px;
|
||||
|
||||
.load-more-btn {
|
||||
color: var(--main-color);
|
||||
font-size: 13px;
|
||||
&:hover {
|
||||
color: var(--main-600);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -139,7 +139,6 @@ import {
|
||||
ref,
|
||||
reactive,
|
||||
computed,
|
||||
defineModel,
|
||||
onMounted,
|
||||
onActivated,
|
||||
onUnmounted,
|
||||
|
||||
@ -6,19 +6,49 @@
|
||||
<h4>{{ question }}</h4>
|
||||
</div>
|
||||
|
||||
<div class="approval-operation">
|
||||
<div v-if="operation" class="approval-operation">
|
||||
<span class="label">操作:</span>
|
||||
<span class="operation-text">{{ operation }}</span>
|
||||
</div>
|
||||
|
||||
<div class="question-options">
|
||||
<label v-for="(item, index) in options" :key="`${item.value}-${index}`" class="option-item">
|
||||
<input
|
||||
v-if="multiSelect"
|
||||
type="checkbox"
|
||||
:value="item.value"
|
||||
:checked="selectedValues.includes(item.value)"
|
||||
:disabled="isProcessing"
|
||||
@change="toggleSelect(item.value)"
|
||||
/>
|
||||
<input
|
||||
v-else
|
||||
type="radio"
|
||||
name="approval-option"
|
||||
:value="item.value"
|
||||
:checked="selectedValues[0] === item.value"
|
||||
:disabled="isProcessing"
|
||||
@change="setSingle(item.value)"
|
||||
/>
|
||||
<span :class="{ recommended: index === 0 && String(item.label).includes('(Recommended)') }">
|
||||
{{ item.label }}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div v-if="allowOther" class="other-input">
|
||||
<input
|
||||
v-model.trim="otherText"
|
||||
type="text"
|
||||
:disabled="isProcessing"
|
||||
placeholder="Other: 输入自定义答案"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="approval-actions">
|
||||
<button class="btn btn-reject" @click="handleReject" :disabled="isProcessing">
|
||||
✕ 拒绝
|
||||
</button>
|
||||
<button class="btn btn-approve" @click="handleApprove" :disabled="isProcessing">
|
||||
✓ 批准
|
||||
</button>
|
||||
<button class="btn btn-reject" @click="handleCancel" :disabled="isProcessing">取消</button>
|
||||
<button class="btn btn-approve" @click="handleSubmit" :disabled="isSubmitDisabled">提交</button>
|
||||
</div>
|
||||
|
||||
<div v-if="isProcessing" class="approval-processing">
|
||||
@ -30,47 +60,114 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
visible: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
question: {
|
||||
type: String,
|
||||
default: '是否批准此操作?'
|
||||
},
|
||||
operation: {
|
||||
type: String,
|
||||
default: ''
|
||||
}
|
||||
visible: { type: Boolean, default: false },
|
||||
question: { type: String, default: '请选择一个选项' },
|
||||
operation: { type: String, default: '' },
|
||||
options: { type: Array, default: () => [] },
|
||||
multiSelect: { type: Boolean, default: false },
|
||||
allowOther: { type: Boolean, default: true }
|
||||
})
|
||||
|
||||
const emit = defineEmits(['approve', 'reject'])
|
||||
const emit = defineEmits(['submit', 'cancel'])
|
||||
|
||||
const isProcessing = ref(false)
|
||||
const selectedValues = ref([])
|
||||
const otherText = ref('')
|
||||
|
||||
const resetForm = () => {
|
||||
isProcessing.value = false
|
||||
selectedValues.value = []
|
||||
otherText.value = ''
|
||||
}
|
||||
|
||||
// 监听弹窗关闭,重置处理状态
|
||||
watch(
|
||||
() => props.visible,
|
||||
(newVal) => {
|
||||
if (!newVal) {
|
||||
isProcessing.value = false
|
||||
resetForm()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
const handleApprove = () => {
|
||||
watch(
|
||||
() => props.options,
|
||||
(next) => {
|
||||
if (!Array.isArray(next) || next.length === 0) {
|
||||
selectedValues.value = []
|
||||
return
|
||||
}
|
||||
if (props.multiSelect) {
|
||||
selectedValues.value = selectedValues.value.filter((value) =>
|
||||
next.some((item) => item?.value === value)
|
||||
)
|
||||
return
|
||||
}
|
||||
const current = selectedValues.value[0]
|
||||
if (current && next.some((item) => item?.value === current)) return
|
||||
selectedValues.value = [next[0].value]
|
||||
},
|
||||
{ immediate: true, deep: true }
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.multiSelect,
|
||||
(isMulti) => {
|
||||
if (isMulti) return
|
||||
if (selectedValues.value.length > 1) {
|
||||
selectedValues.value = selectedValues.value.slice(0, 1)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
const toggleSelect = (value) => {
|
||||
if (isProcessing.value) return
|
||||
isProcessing.value = true
|
||||
emit('approve')
|
||||
if (selectedValues.value.includes(value)) {
|
||||
selectedValues.value = selectedValues.value.filter((item) => item !== value)
|
||||
} else {
|
||||
selectedValues.value = [...selectedValues.value, value]
|
||||
}
|
||||
}
|
||||
|
||||
const handleReject = () => {
|
||||
const setSingle = (value) => {
|
||||
if (isProcessing.value) return
|
||||
selectedValues.value = [value]
|
||||
}
|
||||
|
||||
const isSubmitDisabled = computed(() => {
|
||||
if (isProcessing.value) return true
|
||||
const hasSelected = selectedValues.value.length > 0
|
||||
const hasOther = props.allowOther && Boolean(otherText.value)
|
||||
return !hasSelected && !hasOther
|
||||
})
|
||||
|
||||
const buildAnswer = () => {
|
||||
const selected = selectedValues.value
|
||||
const other = otherText.value
|
||||
if (props.allowOther && other) {
|
||||
return {
|
||||
type: 'other',
|
||||
text: other,
|
||||
selected: selected
|
||||
}
|
||||
}
|
||||
if (props.multiSelect) {
|
||||
return selected
|
||||
}
|
||||
return selected[0]
|
||||
}
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (isSubmitDisabled.value) return
|
||||
isProcessing.value = true
|
||||
emit('reject')
|
||||
emit('submit', buildAnswer())
|
||||
}
|
||||
|
||||
const handleCancel = () => {
|
||||
if (isProcessing.value) return
|
||||
emit('cancel')
|
||||
}
|
||||
</script>
|
||||
|
||||
@ -112,6 +209,7 @@ const handleReject = () => {
|
||||
line-height: 1.5;
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.approval-operation .label {
|
||||
@ -125,6 +223,42 @@ const handleReject = () => {
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.question-options {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.option-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: var(--gray-800);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.option-item .recommended {
|
||||
color: var(--main-color);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.other-input {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.other-input input {
|
||||
width: 100%;
|
||||
border: 1px solid var(--gray-300);
|
||||
border-radius: 6px;
|
||||
padding: 8px 10px;
|
||||
font-size: 13px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.other-input input:focus {
|
||||
border-color: var(--main-color);
|
||||
}
|
||||
|
||||
.approval-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
@ -140,10 +274,6 @@ const handleReject = () => {
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
@ -167,7 +297,6 @@ const handleReject = () => {
|
||||
|
||||
.btn-approve:hover:not(:disabled) {
|
||||
background: var(--main-700);
|
||||
box-shadow: 0 2px 6px rgba(59, 130, 246, 0.25);
|
||||
}
|
||||
|
||||
.approval-processing {
|
||||
@ -197,7 +326,6 @@ const handleReject = () => {
|
||||
}
|
||||
}
|
||||
|
||||
/* 滑入滑出动画 */
|
||||
.slide-up-enter-active,
|
||||
.slide-up-leave-active {
|
||||
transition: all 0.25s ease;
|
||||
|
||||
@ -536,9 +536,12 @@ const form = reactive({
|
||||
|
||||
// 计算属性
|
||||
const filteredServers = computed(() => {
|
||||
if (!searchQuery.value) return servers.value
|
||||
const sorted = [...servers.value].sort((a, b) => {
|
||||
return new Date(a.created_at).getTime() - new Date(b.created_at).getTime()
|
||||
})
|
||||
if (!searchQuery.value) return sorted
|
||||
const q = searchQuery.value.toLowerCase()
|
||||
return servers.value.filter(
|
||||
return sorted.filter(
|
||||
(s) => s.name.toLowerCase().includes(q) || (s.description || '').toLowerCase().includes(q)
|
||||
)
|
||||
})
|
||||
@ -945,10 +948,6 @@ defineExpose({
|
||||
}
|
||||
|
||||
.list-item {
|
||||
&.disabled {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.server-icon {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
@ -769,7 +769,7 @@ defineExpose({
|
||||
|
||||
&:active {
|
||||
color: var(--main-color);
|
||||
transform: scale(0.95);
|
||||
// 移除点击缩小效果
|
||||
}
|
||||
|
||||
.anticon {
|
||||
@ -840,8 +840,8 @@ defineExpose({
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: translateY(0);
|
||||
box-shadow: 0 2px 4px var(--shadow-2);
|
||||
// 移除点击动画效果
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
|
||||
@ -10,10 +10,10 @@
|
||||
|
||||
<!-- Fixed Status Icon -->
|
||||
<span v-if="toolCall.status === 'success' || toolCall.tool_call_result">
|
||||
<CircleCheckBig size="16" class="tool-loader tool-success" />
|
||||
<component :is="toolIcon" size="16" class="tool-loader tool-success" />
|
||||
</span>
|
||||
<span v-else-if="toolCall.status === 'error'">
|
||||
<CircleCheckBig size="16" class="tool-loader tool-error" />
|
||||
<XCircle size="16" class="tool-loader tool-error" />
|
||||
</span>
|
||||
<span v-else>
|
||||
<Loader size="16" class="tool-loader rotate tool-loading" />
|
||||
@ -93,7 +93,27 @@
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
import { Loader, CircleCheckBig, ChevronsUpDown, ChevronsDownUp } from 'lucide-vue-next'
|
||||
import {
|
||||
Loader,
|
||||
CircleCheckBig,
|
||||
ChevronsUpDown,
|
||||
ChevronsDownUp,
|
||||
FileText,
|
||||
FileEdit,
|
||||
FilePen,
|
||||
FolderSearch,
|
||||
Folder,
|
||||
Database,
|
||||
BookOpen,
|
||||
Globe,
|
||||
BarChart3,
|
||||
Image,
|
||||
Calculator,
|
||||
CheckSquare,
|
||||
Wrench,
|
||||
XCircle,
|
||||
HelpCircle
|
||||
} from 'lucide-vue-next'
|
||||
import { useAgentStore } from '@/stores/agent'
|
||||
import { storeToRefs } from 'pinia'
|
||||
|
||||
@ -129,6 +149,33 @@ const toolName = computed(() => {
|
||||
return tool ? tool.name : toolId
|
||||
})
|
||||
|
||||
// Tool Icon Mapping
|
||||
const toolIcon = computed(() => {
|
||||
const name = toolName.value.toLowerCase()
|
||||
// 文件操作
|
||||
if (name.includes('read_file') || name.includes('file')) return FileText
|
||||
if (name.includes('write_file')) return FileEdit
|
||||
if (name.includes('edit_file') || name.includes('replace')) return FilePen
|
||||
if (name.includes('glob') || name.includes('search_file')) return FolderSearch
|
||||
if (name.includes('list_directory') || name.includes('ls')) return Folder
|
||||
// 数据库
|
||||
if (name.includes('mysql')) return Database
|
||||
// 知识库/搜索
|
||||
if (name.includes('kb') || name.includes('knowledge')) return BookOpen
|
||||
if (name.includes('web_search') || name.includes('tavily')) return Globe
|
||||
// 图表/图像
|
||||
if (name.includes('chart')) return BarChart3
|
||||
if (name.includes('image') || name.includes('img')) return Image
|
||||
// 计算
|
||||
if (name.includes('calc') || name.includes('math')) return Calculator
|
||||
// 任务
|
||||
if (name.includes('task') || name.includes('todo')) return CheckSquare
|
||||
// 向用户提问
|
||||
if (name.includes('ask_user_question') || name.includes('question')) return HelpCircle
|
||||
// 默认
|
||||
return Wrench
|
||||
})
|
||||
|
||||
// Args Logic
|
||||
const formattedArgs = computed(() => {
|
||||
const args = props.toolCall.args ? props.toolCall.args : props.toolCall.function?.arguments
|
||||
@ -242,7 +289,7 @@ const formatResultData = (data) => {
|
||||
}
|
||||
|
||||
.tool-loader.tool-success {
|
||||
color: var(--color-success-500);
|
||||
color: var(--main-color);
|
||||
}
|
||||
|
||||
.tool-loader.tool-error {
|
||||
|
||||
@ -62,6 +62,9 @@
|
||||
<!-- MySQL 列出表 -->
|
||||
<MysqlListTablesTool v-else-if="toolName === 'mysql_list_tables'" :tool-call="toolCall" />
|
||||
|
||||
<!-- 向用户提问 -->
|
||||
<AskUserQuestionTool v-else-if="toolName === 'ask_user_question'" :tool-call="toolCall" />
|
||||
|
||||
<!-- 默认展示 -->
|
||||
<BaseToolCall v-else :tool-call="toolCall" />
|
||||
</template>
|
||||
@ -89,6 +92,7 @@ import EditFileTool from './tools/EditFileTool.vue'
|
||||
import MysqlQueryTool from './tools/MysqlQueryTool.vue'
|
||||
import MysqlDescribeTableTool from './tools/MysqlDescribeTableTool.vue'
|
||||
import MysqlListTablesTool from './tools/MysqlListTablesTool.vue'
|
||||
import AskUserQuestionTool from './tools/AskUserQuestionTool.vue'
|
||||
|
||||
const props = defineProps({
|
||||
toolCall: {
|
||||
|
||||
@ -0,0 +1,86 @@
|
||||
<template>
|
||||
<BaseToolCall :tool-call="toolCall" hide-params>
|
||||
<template #header>
|
||||
<div class="sep-header">
|
||||
<span class="note">提问</span>
|
||||
<span class="separator">|</span>
|
||||
<span class="description">{{ shortQuestion }}</span>
|
||||
<span v-if="userAnswer" class="tag tag-answered">
|
||||
已回答: {{ displayAnswer }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
</BaseToolCall>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import BaseToolCall from '../BaseToolCall.vue'
|
||||
|
||||
const props = defineProps({
|
||||
toolCall: {
|
||||
type: Object,
|
||||
required: true
|
||||
}
|
||||
})
|
||||
|
||||
// 解析参数
|
||||
const parsedArgs = computed(() => {
|
||||
const args = props.toolCall.args || props.toolCall.function?.arguments
|
||||
if (!args) return {}
|
||||
if (typeof args === 'object') return args
|
||||
try {
|
||||
return JSON.parse(args)
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
})
|
||||
|
||||
// 解析结果
|
||||
const parsedResult = computed(() => {
|
||||
const content = props.toolCall.tool_call_result?.content
|
||||
if (!content) return null
|
||||
if (typeof content === 'object') return content
|
||||
try {
|
||||
return JSON.parse(content)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
})
|
||||
|
||||
const question = computed(() => parsedArgs.value.question || '')
|
||||
const shortQuestion = computed(() => {
|
||||
const q = question.value
|
||||
return q.length > 50 ? q.slice(0, 50) + '...' : q
|
||||
})
|
||||
|
||||
// 用户答案
|
||||
const userAnswer = computed(() => {
|
||||
const result = parsedResult.value
|
||||
if (!result) return null
|
||||
return result.user_answer || result.answer || null
|
||||
})
|
||||
|
||||
// 显示答案
|
||||
const displayAnswer = computed(() => {
|
||||
const answer = userAnswer.value
|
||||
if (!answer) return ''
|
||||
if (Array.isArray(answer)) {
|
||||
return answer.join(', ')
|
||||
}
|
||||
return String(answer)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.sep-header {
|
||||
.tag-answered {
|
||||
margin-left: 8px;
|
||||
color: var(--green-600);
|
||||
background: var(--green-50);
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@ -110,6 +110,7 @@ export function useAgentStreamHandler({
|
||||
}
|
||||
return true
|
||||
|
||||
case 'ask_user_question_required':
|
||||
case 'human_approval_required':
|
||||
console.log(`${debugPrefix}[approval_required]`, {
|
||||
threadId,
|
||||
|
||||
@ -3,21 +3,61 @@ import { message } from 'ant-design-vue'
|
||||
import { handleChatError } from '@/utils/errorHandler'
|
||||
import { agentApi } from '@/apis'
|
||||
|
||||
const normalizeOptions = (rawOptions) => {
|
||||
if (!Array.isArray(rawOptions)) return []
|
||||
return rawOptions
|
||||
.map((item) => {
|
||||
if (item && typeof item === 'object') {
|
||||
const label = String(item.label || item.value || '').trim()
|
||||
const value = String(item.value || item.label || '').trim()
|
||||
return label && value ? { label, value } : null
|
||||
}
|
||||
const text = String(item || '').trim()
|
||||
return text ? { label: text, value: text } : null
|
||||
})
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
const extractQuestionPayload = (chunk) => {
|
||||
const interruptInfo = chunk?.interrupt_info || {}
|
||||
const rawOptions = chunk?.options || interruptInfo?.options || []
|
||||
const options = normalizeOptions(rawOptions)
|
||||
const operation = chunk?.operation || interruptInfo?.operation || ''
|
||||
|
||||
const source = chunk?.source || interruptInfo?.source || 'interrupt'
|
||||
const multiSelect = Boolean(chunk?.multi_select ?? interruptInfo?.multi_select ?? false)
|
||||
const allowOther = Boolean(chunk?.allow_other ?? interruptInfo?.allow_other ?? true)
|
||||
const questionId = chunk?.question_id || interruptInfo?.question_id || ''
|
||||
const question = chunk?.question || interruptInfo?.question || '请选择一个选项'
|
||||
|
||||
return {
|
||||
questionId,
|
||||
question,
|
||||
options,
|
||||
multiSelect,
|
||||
allowOther,
|
||||
source,
|
||||
operation
|
||||
}
|
||||
}
|
||||
|
||||
export function useApproval({ getThreadState, resetOnGoingConv, fetchThreadMessages }) {
|
||||
// 审批状态
|
||||
const approvalState = reactive({
|
||||
showModal: false,
|
||||
questionId: '',
|
||||
question: '',
|
||||
operation: '',
|
||||
threadId: null,
|
||||
interruptInfo: null
|
||||
options: [],
|
||||
multiSelect: false,
|
||||
allowOther: true,
|
||||
source: '',
|
||||
threadId: null
|
||||
})
|
||||
|
||||
// 处理审批逻辑
|
||||
const handleApproval = async (approved, currentAgentId, agentConfigId = null) => {
|
||||
const handleApproval = async (answer, currentAgentId, agentConfigId = null) => {
|
||||
const threadId = approvalState.threadId
|
||||
if (!threadId) {
|
||||
message.error('无效的审批请求')
|
||||
message.error('无效的提问请求')
|
||||
approvalState.showModal = false
|
||||
return
|
||||
}
|
||||
@ -29,94 +69,82 @@ export function useApproval({ getThreadState, resetOnGoingConv, fetchThreadMessa
|
||||
return
|
||||
}
|
||||
|
||||
// 关闭弹窗
|
||||
approvalState.showModal = false
|
||||
|
||||
// 清理旧的流式控制器(如果存在)
|
||||
if (threadState.streamAbortController) {
|
||||
threadState.streamAbortController.abort()
|
||||
threadState.streamAbortController = null
|
||||
}
|
||||
|
||||
// 标记为处理中
|
||||
threadState.isStreaming = true
|
||||
resetOnGoingConv(threadId)
|
||||
threadState.streamAbortController = new AbortController()
|
||||
|
||||
console.log('🔄 [APPROVAL] Starting resume process:', { approved, threadId, currentAgentId })
|
||||
const requestBody = {
|
||||
thread_id: threadId,
|
||||
answer,
|
||||
config: agentConfigId ? { agent_config_id: agentConfigId } : {}
|
||||
}
|
||||
|
||||
try {
|
||||
// 调用恢复接口
|
||||
const response = await agentApi.resumeAgentChat(
|
||||
currentAgentId,
|
||||
{
|
||||
thread_id: threadId,
|
||||
approved: approved,
|
||||
config: agentConfigId ? { agent_config_id: agentConfigId } : {}
|
||||
},
|
||||
{
|
||||
signal: threadState.streamAbortController?.signal
|
||||
}
|
||||
)
|
||||
|
||||
console.log('🔄 [APPROVAL] Resume API response received')
|
||||
const response = await agentApi.resumeAgentChat(currentAgentId, requestBody, {
|
||||
signal: threadState.streamAbortController?.signal
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
console.error('Resume API error:', response.status, errorText)
|
||||
throw new Error(`HTTP error! status: ${response.status}, details: ${errorText}`)
|
||||
}
|
||||
|
||||
console.log('🔄 [APPROVAL] Resume API successful, returning response for stream processing')
|
||||
return response // 返回响应供调用方处理流式数据
|
||||
return response
|
||||
} catch (error) {
|
||||
console.error('❌ [APPROVAL] Resume failed:', error)
|
||||
if (error.name !== 'AbortError') {
|
||||
handleChatError(error, 'resume')
|
||||
message.error(`恢复对话失败: ${error.message || '未知错误'}`)
|
||||
}
|
||||
// 重置状态 - 只在错误时重置
|
||||
threadState.isStreaming = false
|
||||
threadState.streamAbortController = null
|
||||
throw error // 重新抛出错误让调用方处理
|
||||
throw error
|
||||
}
|
||||
// 移除 finally 块 - 让组件管理流式状态的生命周期
|
||||
}
|
||||
|
||||
// 在流式处理中处理审批请求
|
||||
const processApprovalInStream = (chunk, threadId, currentAgentId) => {
|
||||
if (chunk.status !== 'human_approval_required') {
|
||||
if (chunk.status !== 'ask_user_question_required' && chunk.status !== 'human_approval_required') {
|
||||
return false
|
||||
}
|
||||
|
||||
const { interrupt_info } = chunk
|
||||
const threadState = getThreadState(threadId)
|
||||
|
||||
if (!threadState) return false
|
||||
|
||||
// 停止显示"处理中"状态,让用户可以看到并操作审批弹窗
|
||||
const payload = extractQuestionPayload(chunk)
|
||||
|
||||
threadState.isStreaming = false
|
||||
|
||||
// 显示审批弹窗
|
||||
approvalState.showModal = true
|
||||
approvalState.question = interrupt_info?.question || '是否批准以下操作?'
|
||||
approvalState.operation = interrupt_info?.operation || '未知操作'
|
||||
approvalState.questionId = payload.questionId
|
||||
approvalState.question = payload.question
|
||||
approvalState.operation = payload.operation
|
||||
approvalState.options = payload.options
|
||||
approvalState.multiSelect = payload.multiSelect
|
||||
approvalState.allowOther = payload.allowOther
|
||||
approvalState.source = payload.source
|
||||
approvalState.threadId = chunk.thread_id || threadId
|
||||
approvalState.interruptInfo = interrupt_info
|
||||
|
||||
// 刷新消息历史显示已执行的部分
|
||||
fetchThreadMessages({ agentId: currentAgentId, threadId: threadId })
|
||||
fetchThreadMessages({ agentId: currentAgentId, threadId })
|
||||
|
||||
return true // 表示已处理审批请求,应停止流式处理
|
||||
return true
|
||||
}
|
||||
|
||||
// 重置审批状态
|
||||
const resetApprovalState = () => {
|
||||
approvalState.showModal = false
|
||||
approvalState.questionId = ''
|
||||
approvalState.question = ''
|
||||
approvalState.operation = ''
|
||||
approvalState.options = []
|
||||
approvalState.multiSelect = false
|
||||
approvalState.allowOther = true
|
||||
approvalState.source = ''
|
||||
approvalState.threadId = null
|
||||
approvalState.interruptInfo = null
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@ -95,7 +95,6 @@ export class ChatExporter {
|
||||
}
|
||||
: null
|
||||
})
|
||||
|
||||
;(messages || []).forEach((item) => {
|
||||
if (!item) return
|
||||
|
||||
|
||||
@ -42,12 +42,13 @@ const coerceDayjs = (value) => {
|
||||
}
|
||||
|
||||
// 解析 ISO 字符串(dayjs 会自动识别时区信息,如 Z 后缀表示 UTC)
|
||||
// 需要先转换为 UTC 再设置时区,否则 .tz() 只会改变显示而不会正确转换
|
||||
const parsed = dayjs(stringValue)
|
||||
if (!parsed.isValid()) {
|
||||
return null
|
||||
}
|
||||
// 转换为上海时区
|
||||
return parsed.tz(DEFAULT_TZ)
|
||||
// 先转换为 UTC(保留原始时间值),再转换到上海时区
|
||||
return parsed.utc().tz(DEFAULT_TZ)
|
||||
}
|
||||
|
||||
export const parseToShanghai = (value) => coerceDayjs(value)
|
||||
|
||||
@ -65,14 +65,27 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { ref, watch } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { Upload, RotateCw, Plus } from 'lucide-vue-next'
|
||||
import SkillsManagerComponent from '@/components/SkillsManagerComponent.vue'
|
||||
import ToolsManagerComponent from '@/components/ToolsManagerComponent.vue'
|
||||
import McpServersComponent from '@/components/McpServersComponent.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const activeTab = ref('tools')
|
||||
const skillsRef = ref(null)
|
||||
|
||||
// 监听路由 query 参数变化
|
||||
watch(
|
||||
() => route.query,
|
||||
(query) => {
|
||||
if (query.tab && ['tools', 'skills', 'mcp'].includes(query.tab)) {
|
||||
activeTab.value = query.tab
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
const toolsRef = ref(null)
|
||||
const mcpRef = ref(null)
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user