feat: 新增凭证管理与渠道状态展示功能

1. 新增渠道凭证相关API与前端展示逻辑
2. 实现凭证状态自动拉取与手动刷新功能
3. 优化聊天查询支持内部用户ID参数
4. 修复部分代码格式与异常处理逻辑
5. 新增项目代码维基文档
This commit is contained in:
Kris 2026-05-14 02:10:52 +08:00
parent f419f57f63
commit 277bf20153
11 changed files with 2082 additions and 271 deletions

892
CODE_WIKI.md Normal file
View File

@ -0,0 +1,892 @@
# Yuxi 项目 Code Wiki
> 本文档是 Yuxi语析项目的结构化代码百科涵盖项目整体架构、主要模块职责、关键类与函数说明、依赖关系以及项目运行方式等关键信息。
>
> 项目版本v0.6.2 | 最后更新2026-05-14
---
## 目录
1. [项目概述](#1-项目概述)
2. [整体架构](#2-整体架构)
3. [后端核心模块](#3-后端核心模块)
4. [前端核心模块](#4-前端核心模块)
5. [关键类与函数详解](#5-关键类与函数详解)
6. [数据流与运行链路](#6-数据流与运行链路)
7. [依赖关系](#7-依赖关系)
8. [项目运行方式](#8-项目运行方式)
9. [测试体系](#9-测试体系)
10. [附录:目录结构总览](#10-附录目录结构总览)
---
## 1. 项目概述
**Yuxi语析** 是一个基于大模型的智能知识库与知识图谱智能体开发平台,融合了 RAG 技术与知识图谱技术,基于 **LangGraph v1 + Vue.js + FastAPI + LightRAG** 架构构建。
### 核心特性
- **智能体开发**:基于 LangGraph支持子智能体、Skills、MCPs、Tools 与中间件机制
- **知识库RAG**:多格式文档解析,支持 Embedding / Rerank 配置及知识库评估
- **知识图谱**:基于 LightRAG 的图谱构建与可视化,支持属性图谱并参与智能体推理
- **平台与工程化**Vue + FastAPI 架构支持暗黑模式、Docker 与生产级部署
- **多渠道网关**:支持 Slack、Discord、Telegram、飞书、微信、QQ 等 20+ 渠道接入
### 技术栈
| 层级 | 技术 |
|------|------|
| 前端 | Vue 3 + Vite + Pinia + Ant Design Vue + Sigma.js/G6 |
| 后端 API | FastAPI + Uvicorn |
| 智能体框架 | LangGraph + LangChain |
| 任务队列 | ARQ (Redis) |
| 数据库 | PostgreSQL (业务数据) + Neo4j (图谱) + Milvus (向量) |
| 对象存储 | MinIO |
| 缓存/消息 | Redis |
| 文档解析 | MinerU + PaddleX + RapidOCR + DeepSeek OCR |
| 沙盒执行 | Docker Sandbox |
---
## 2. 整体架构
### 2.1 架构分层
```
┌─────────────────────────────────────────────────────────────┐
│ 前端层 (Frontend) │
│ Vue 3 + Vite + Pinia + Ant Design Vue + Sigma.js/G6 │
│ web/src/ │
├─────────────────────────────────────────────────────────────┤
│ API 网关层 (Gateway) │
│ FastAPI + 路由注册 + 认证中间件 + CORS + 限流 │
│ backend/server/ │
├─────────────────────────────────────────────────────────────┤
│ 业务服务层 (Services) │
│ 聊天服务、运行队列、知识库、Skills、MCP、SubAgents │
│ backend/package/yuxi/services/ │
├─────────────────────────────────────────────────────────────┤
│ 智能体层 (Agents) │
│ BaseAgent + LangGraph + 中间件 + 工具集 + 沙盒后端 │
│ backend/package/yuxi/agents/ │
├─────────────────────────────────────────────────────────────┤
│ 数据访问层 (Repositories) │
│ SQLAlchemy + 异步会话 + 业务模型 │
│ backend/package/yuxi/repositories/ │
├─────────────────────────────────────────────────────────────┤
│ 基础设施层 (Infrastructure) │
│ PostgreSQL + Neo4j + Milvus + Redis + MinIO │
│ backend/package/yuxi/storage/ │
└─────────────────────────────────────────────────────────────┘
```
### 2.2 Docker Compose 服务拓扑
| 服务 | 容器名 | 端口 | 职责 |
|------|--------|------|------|
| api-dev | api-dev | 5050 | FastAPI 主服务(热重载) |
| worker-dev | worker-dev | - | ARQ 异步任务 Worker |
| web-dev | web-dev | 5173 | Vue 前端(热重载) |
| sandbox-provisioner | sandbox-provisioner | 8002 | 沙盒环境供应器 |
| postgres | postgres | 5432 | 业务与知识库元数据 |
| redis | redis | 6379 | 运行事件、队列状态、缓存 |
| minio | minio | 9000/9001 | 对象存储 |
| milvus | milvus | 19530 | 向量检索 |
| graph | graph | 7474/7687 | Neo4j 知识图谱 |
| mineru-vllm-server | mineru-vllm-server | 30000 | 文档解析 VLLM 服务profile: all |
| mineru-api | mineru-api | 30001 | 文档解析 APIprofile: all |
| paddlex | paddlex-ocr | 8080 | PaddleX OCRprofile: all |
---
## 3. 后端核心模块
后端分为两个顶层边界:`backend/server` 是 Web 应用入口与 HTTP 适配层,`backend/package/yuxi` 是可复用业务包。
### 3.1 入口与路由层 (`backend/server/`)
#### 3.1.1 主入口 (`server/main.py`)
- 创建 FastAPI 应用,注册 lifespan 生命周期
- 挂载所有业务路由到 `/api` 前缀
- 注册中间件CORS、访问日志、登录限流、认证
- 集成 WebSocket 聊天端点和 Slack Webhook
#### 3.1.2 路由注册 (`server/routers/__init__.py`)
| 路由模块 | API 前缀 | 职责 | LITE 模式 |
|----------|----------|------|-----------|
| `system_router.py` | `/api/system/*` | 健康检查、全局配置 | ✓ |
| `auth_router.py` | `/api/auth/*` | 登录、用户信息、OIDC | ✓ |
| `chat_router.py` | `/api/chat/*` | 对话、消息流、运行态 | ✓ |
| `dashboard_router.py` | `/api/dashboard/*` | 仪表盘聚合数据 | ✓ |
| `auth_dept_router.py` | `/api/departments/*` | 部门与权限 | ✓ |
| `system_task_router.py` | `/api/tasks/*` | 后台任务管理 | ✓ |
| `mcp_router.py` | `/api/system/mcp-servers/*` | MCP 服务管理 | ✓ |
| `model_provider_router.py` | `/api/system/model-providers/*` | 模型供应商配置 | ✓ |
| `skill_router.py` | `/api/system/skills/*` | Skills 管理 | ✓ |
| `subagent_router.py` | `/api/system/subagents/*` | 子智能体管理 | ✓ |
| `tool_router.py` | `/api/system/tools/*` | 工具列表与配置 | ✓ |
| `auth_apikey_router.py` | `/api/apikey/*` | API Key 管理 | ✓ |
| `filesystem_router.py` | `/api/viewer/filesystem/*` | 工作台文件系统视图 | ✓ |
| `workspace_router.py` | `/api/workspace/*` | 用户个人工作区 | ✓ |
| `channels_router.py` | `/api/channels/*` | 多渠道网关管理 | ✓ |
| `webhook_router.py` | `/api/webhook/*` | 统一 Webhook 分发 | ✓ |
| `knowledge_router.py` | `/api/knowledge/*` | 知识库管理与检索 | ✗ |
| `knowledge_eval_router.py` | `/api/evaluation/*` | 知识库评估 | ✗ |
| `knowledge_mindmap_router.py` | `/api/mindmap/*` | 思维导图生成与查询 | ✗ |
| `graph_router.py` | `/api/graph/*` | 图谱查询与管理 | ✗ |
#### 3.1.3 Worker 入口 (`server/worker_main.py`)
- ARQ Worker 入口,导入 `WorkerSettings`
- 处理智能体运行等异步任务
### 3.2 智能体体系 (`backend/package/yuxi/agents/`)
#### 3.2.1 核心基类
| 文件 | 类/函数 | 职责 |
|------|---------|------|
| `base.py` | `BaseAgent` | 智能体基类,定义 graph 编译、消息流式处理、历史记录、checkpointer 管理 |
| `context.py` | `BaseContext` | 智能体运行配置上下文包含模型、工具、知识库、MCP、Skills、子智能体等配置 |
| `state.py` | `BaseState` | LangGraph 状态定义,包含 messages 和 artifacts |
| `state.py` | `AgentStatePayload` | 前端可消费的序列化状态结构 |
| `models.py` | `load_chat_model()` | 根据 v1/v2 spec 加载聊天模型,支持多供应商 |
#### 3.2.2 内置智能体 (`agents/buildin/`)
| 智能体 | 文件 | 职责 |
|--------|------|------|
| ChatbotAgent | `chatbot/graph.py` | 基础对话机器人支持文件上传、知识库、MCP、Skills、子智能体 |
| DeepAgent | `deep_agent/graph.py` | 深度研究智能体,用于复杂任务分解与执行 |
#### 3.2.3 中间件 (`agents/middlewares/`)
中间件负责把各种能力挂载到智能体运行时:
| 中间件 | 职责 |
|--------|------|
| `KnowledgeBaseMiddleware` | 知识库检索工具注入 |
| `SkillsMiddleware` | Skills 提示词注入、依赖展开、动态激活 |
| `RuntimeConfigMiddleware` | 运行时配置应用(模型/工具/MCP/提示词) |
| `SummaryOffloadMiddleware` | 上下文摘要优化token 阈值触发) |
| `SubAgentMiddleware` | 子智能体调度 |
| `FilesystemMiddleware` | 沙盒文件系统后端 |
| `TodoListMiddleware` | 待办事项管理 |
| `PatchToolCallsMiddleware` | 工具调用补丁 |
| `ModelRetryMiddleware` | 模型重试机制 |
#### 3.2.4 工具集 (`agents/toolkits/`)
| 模块 | 职责 |
|------|------|
| `buildin/tools.py` | 内置工具(如 tavily_search、ask_user_question |
| `kbs/tools.py` | 知识库相关工具 |
| `mysql/tools.py` | MySQL 数据库工具 |
| `registry.py` | 工具注册中心 |
#### 3.2.5 后端执行 (`agents/backends/`)
| 模块 | 职责 |
|------|------|
| `sandbox/backend.py` | 沙盒执行后端 |
| `sandbox/paths.py` | 沙盒路径管理 |
| `composite.py` | 复合后端(知识库 + Skills + 沙盒) |
| `skills_backend.py` | Skills 执行后端 |
### 3.3 服务层 (`backend/package/yuxi/services/`)
服务层是用例层,负责串联 repositories、agents、knowledge、storage 和外部系统。
| 服务文件 | 职责 | 关键函数 |
|----------|------|----------|
| `chat_service.py` | 聊天核心服务 | `agent_chat()`, `stream_agent_chat()`, `stream_agent_resume()`, `get_agent_state_view()` |
| `agent_run_service.py` | Agent 运行管理(创建/轮询/取消) | `create_agent_run_view()`, `stream_agent_run_events()`, `cancel_agent_run_view()` |
| `run_worker.py` | ARQ Worker 任务处理 | `process_agent_run()`, `RunContext`, `ChunkedEventWriter` |
| `run_queue_service.py` | 运行队列与事件流 | `get_arq_pool()`, `append_run_stream_event()`, `publish_cancel_signal()` |
| `skill_service.py` | Skills 业务逻辑 | `import_skill_zip()`, `list_skills()`, `install_builtin_skill()`, `update_builtin_skill()` |
| `mcp_service.py` | MCP 服务管理 | `get_mcp_tools()`, `get_enabled_mcp_server_config()`, `ensure_builtin_mcp_servers_in_db()` |
| `subagent_service.py` | 子智能体管理 | `get_subagents_from_names()`, `init_builtin_subagents()` |
| `langfuse_service.py` | 可观测性追踪 | `build_run_context()`, `get_trace_info()`, `flush_langfuse()` |
| `model_provider_service.py` | 模型供应商配置 | `ensure_builtin_model_providers_in_db()`, `get_all_model_providers()` |
| `model_cache.py` | 模型信息缓存 | `model_cache.rebuild()`, `is_v2_spec_format()` |
| `conversation_service.py` | 对话管理 | - |
| `filesystem_service.py` | 文件系统服务 | - |
| `knowledge_fs_service.py` | 知识库文件服务 | - |
| `task_service.py` | 后台任务调度 | `tasker.start()`, `tasker.shutdown()` |
| `upload_utils.py` | 文件上传工具 | - |
| `workspace_service.py` | 工作区服务 | - |
| `evaluation_service.py` | 知识库评估 | - |
| `feedback_service.py` | 反馈服务 | - |
| `oidc_service.py` | OIDC 认证 | - |
| `tool_service.py` | 工具元数据 | `get_tool_metadata()` |
### 3.4 知识库领域 (`backend/package/yuxi/knowledge/`)
| 文件 | 类/函数 | 职责 |
|------|---------|------|
| `base.py` | `KnowledgeBase` (ABC) | 知识库抽象基类,定义统一接口(创建/删除/查询/文件管理) |
| `manager.py` | `KnowledgeBaseManager` | 知识库管理器,统一管理多种类型知识库实例 |
| `factory.py` | `KnowledgeBaseFactory` | 知识库工厂,根据类型创建实例 |
| `implementations/dify.py` | `DifyKnowledgeBase` | Dify 知识库实现 |
| `implementations/milvus.py` | `MilvusKnowledgeBase` | Milvus 向量知识库实现 |
| `chunking/` | - | 文档分块策略RAGflow-like 语义分块) |
| `graphs/adapters/` | - | 图谱适配与上传服务 |
| `utils/kb_utils.py` | - | 知识库通用工具 |
### 3.5 数据访问层 (`backend/package/yuxi/repositories/`)
| 仓库文件 | 职责 |
|----------|------|
| `skill_repository.py` | Skills CRUD |
| `task_repository.py` | 后台任务 CRUD |
| `user_repository.py` | 用户 CRUD |
| `agent_config_repository.py` | 智能体配置 CRUD |
| `agent_run_repository.py` | 智能体运行记录 CRUD |
| `conversation_repository.py` | 对话与消息 CRUD |
| `knowledge_base_repository.py` | 知识库元数据 CRUD |
| `knowledge_file_repository.py` | 知识库文件 CRUD |
| `evaluation_repository.py` | 评估基准与结果 CRUD |
### 3.6 存储层 (`backend/package/yuxi/storage/`)
| 模块 | 职责 |
|------|------|
| `postgres/manager.py` | `PostgresManager` - 数据库连接池、会话管理、schema 迁移 |
| `postgres/models_business.py` | 业务数据模型User、Conversation、AgentRun 等) |
| `postgres/models_knowledge.py` | 知识库数据模型KnowledgeBase、KnowledgeFile 等) |
| `postgres/models_channels.py` | 渠道网关数据模型 |
| `minio/client.py` | MinIO 客户端封装 |
### 3.7 多渠道网关 (`backend/package/yuxi/channels/`)
支持 20+ 即时通讯渠道接入:
| 渠道 | 适配器位置 |
|------|-----------|
| Slack | `channels/adapters/slack/` |
| Discord | `channels/adapters/discord/` |
| Telegram | `channels/adapters/telegram/` |
| 飞书 (Feishu) | `channels/adapters/feishu/` |
| 微信 (WeChat) | `channels/adapters/wechat/` |
| QQ 机器人 | `channels/adapters/qqbot/` |
| 钉钉 | `channels/adapters/dingding/` |
| Microsoft Teams | `channels/adapters/msteams/` |
| Matrix | `channels/adapters/matrix/` |
| IRC | `channels/adapters/irc/` |
| Line | `channels/adapters/line/` |
| Twitch | `channels/adapters/twitch/` |
| Nostr | `channels/adapters/nostr/` |
| Signal | `channels/adapters/signal/` |
| WhatsApp | `channels/adapters/whatsapp/` |
| Urbit | `channels/adapters/urbit/` |
| 元宝 (Yuanbao) | `channels/adapters/yuanbao/` |
| Zalo | `channels/adapters/zalo_oa/`, `zalo_user/` |
核心文件:
- `channels/manager.py` - 渠道管理器
- `channels/base.py` - 渠道基类
- `channels/router.py` - 消息路由
- `channels/message_actions.py` - 消息动作处理
### 3.8 配置与模型 (`backend/package/yuxi/config/`, `models/`)
| 文件 | 职责 |
|------|------|
| `config/app.py` | 应用配置管理 |
| `config/builtin_providers.py` | 内置模型供应商配置 |
| `config/static/models.py` | 静态模型信息 |
| `models/chat.py` | 聊天模型适配 |
| `models/embed.py` | Embedding 模型适配 |
| `models/rerank.py` | Rerank 模型适配 |
### 3.9 文档解析 (`backend/package/yuxi/plugins/parser/`)
| 文件 | 职责 |
|------|------|
| `unified.py` | `Parser` 统一入口,封装所有解析实现 |
| `factory.py` | 解析器工厂 |
| `base.py` | 解析器抽象基类 |
| `mineru.py` | MinerU 解析实现 |
| `mineru_official.py` | MinerU 官方 API 实现 |
| `pp_structure_v3.py` | PaddleX 结构解析 |
| `rapid_ocr.py` | RapidOCR 实现 |
| `deepseek_ocr.py` | DeepSeek OCR 实现 |
---
## 4. 前端核心模块
前端是 Vue 3 + Vite 应用,业务入口集中在 `web/src/`
### 4.1 入口与路由 (`web/src/`)
| 文件 | 职责 |
|------|------|
| `main.js` | 应用挂载入口 |
| `App.vue` | 根组件 |
| `router/index.js` | 路由配置含权限守卫requiresAuth/requiresAdmin/requiresSuperAdmin |
**路由表:**
| 路径 | 页面 | 权限 |
|------|------|------|
| `/` | HomeView | 公开 |
| `/login` | LoginView | 公开 |
| `/agent` | AgentView | 登录用户 |
| `/agent/:thread_id` | AgentView带线程ID | 登录用户 |
| `/workspace` | WorkspaceView | 登录用户 |
| `/graph` | GraphView | 管理员 |
| `/database` | DataBaseView | 管理员 |
| `/database/:database_id` | DataBaseInfoView | 管理员 |
| `/dashboard` | DashboardView | 管理员 |
| `/model-config` | ModelConfigView | 管理员 |
| `/channels` | ChannelManageView | 管理员 |
| `/extensions` | ExtensionsView | 超级管理员 |
### 4.2 API 封装 (`web/src/apis/`)
所有后端接口统一封装,复用 `base.js` 的请求、鉴权和错误处理。
| API 文件 | 职责 |
|----------|------|
| `base.js` | HTTP 客户端、请求/响应拦截、权限头处理 |
| `agent_api.js` | 智能体管理、聊天接口 |
| `knowledge_api.js` | 知识库管理、文档管理、查询 |
| `graph_api.js` | 知识图谱操作 |
| `mcp_api.js` | MCP 服务器管理 |
| `skill_api.js` | Skills 管理 |
| `subagent_api.js` | 子智能体管理 |
| `system_api.js` | 系统状态、配置 |
| `auth_api.js` | 认证、用户信息 |
| `dashboard_api.js` | 仪表盘数据 |
| `department_api.js` | 部门管理 |
| `channel_api.js` | 渠道管理 |
| `tool_api.js` | 工具信息 |
| `tasker.js` | 任务管理 |
| `mindmap_api.js` | 思维导图 |
| `workspace_api.js` | 工作区 |
| `viewer_filesystem.js` | 文件系统视图 |
| `apikey_api.js` | API Key 管理 |
### 4.3 状态管理 (`web/src/stores/`)
使用 Pinia + `pinia-plugin-persistedstate` 持久化。
| Store 文件 | 职责 |
|-----------|------|
| `user.js` | 用户信息、登录状态、权限 |
| `agent.js` | 智能体配置、初始化状态 |
| `chatThreads.js` | 聊天线程列表、当前线程 |
| `database.js` | 知识库列表、当前知识库 |
| `graphStore.js` | 图谱数据、可视化状态 |
| `theme.js` | 主题(暗黑/亮色) |
| `config.js` | 系统配置 |
| `chatUI.js` | 聊天界面状态 |
| `tasker.js` | 任务状态 |
| `channel.js` | 渠道配置 |
| `info.js` | 系统信息 |
### 4.4 可组合逻辑 (`web/src/composables/`)
| 文件 | 职责 |
|------|------|
| `useAgentStreamHandler.js` | Agent 流式响应处理 |
| `useAgentRunStream.js` | Agent runs SSE 流处理 |
| `useApproval.js` | 审批逻辑处理 |
| `useMention.js` | @提及功能 |
| `useStreamSmoother.js` | 流式消息平滑调度 |
### 4.5 视图与组件 (`web/src/views/`, `components/`)
| 视图 | 职责 |
|------|------|
| `AgentView.vue` | 智能体对话主页面 |
| `WorkspaceView.vue` | 个人工作区(文件管理) |
| `DataBaseView.vue` | 知识库列表与管理 |
| `DataBaseInfoView.vue` | 知识库详情与文件管理 |
| `GraphView.vue` | 知识图谱可视化 |
| `DashboardView.vue` | 数据统计仪表盘 |
| `ModelConfigView.vue` | 模型供应商配置 |
| `ChannelManageView.vue` | 渠道网关管理 |
| `ExtensionsView.vue` | 扩展管理Skills/MCP/SubAgents |
| `LoginView.vue` | 登录页面 |
| `HomeView.vue` | 首页 |
---
## 5. 关键类与函数详解
### 5.1 智能体基类
#### `BaseAgent` (`agents/base.py`)
```python
class BaseAgent:
name = "base_agent"
description = "base_agent"
capabilities: list[str] = []
context_schema: type[BaseContext] = BaseContext
# 核心方法
async def get_graph(self, **kwargs) -> CompiledStateGraph # 子类必须实现
async def stream_messages(self, messages, input_context=None, **kwargs) # 流式消息
async def stream_messages_with_state(self, messages, input_context=None, **kwargs) # 流式消息+状态
async def invoke_messages(self, messages, input_context=None, **kwargs) # 同步调用
async def get_history(self, user_id, thread_id) -> list[dict] # 获取历史
async def get_info(self, include_configurable_items=True) # 获取元数据
def reload_graph(self) # 重置 graph 缓存
```
#### `BaseContext` (`agents/context.py`)
```python
@dataclass(kw_only=True)
class BaseContext:
thread_id: str # 对话线程ID
user_id: str # 用户ID
system_prompt: str # 系统提示词
model: str # 主模型v2 spec: provider_id:model_id
tools: list[str] # 启用工具列表
knowledges: list[str] # 启用知识库列表
mcps: list[str] # 启用 MCP 服务器列表
skills: list[str] # 启用 Skills 列表
subagents_model: str # 子智能体默认模型
subagents: list[str] # 启用子智能体列表
summary_threshold: int # 上下文摘要触发阈值KB
```
### 5.2 聊天服务
#### `chat_service.py` 核心函数
| 函数 | 职责 |
|------|------|
| `agent_chat()` | 非流式对话,返回完整响应 |
| `stream_agent_chat()` | 流式对话yield 事件块 |
| `stream_agent_resume()` | 恢复中断的对话(处理 human-in-the-loop |
| `get_agent_state_view()` | 获取 Agent 当前状态 |
| `save_messages_from_langgraph_state()` | 从 LangGraph state 持久化消息 |
| `extract_agent_state()` | 从 state 提取 todos/files/artifacts |
### 5.3 Agent Run 服务
#### `agent_run_service.py` 核心函数
| 函数 | 职责 |
|------|------|
| `create_agent_run_view()` | 创建后台运行任务,入队 ARQ |
| `stream_agent_run_events()` | SSE 流式推送运行事件 |
| `cancel_agent_run_view()` | 取消运行任务 |
| `get_active_run_by_thread()` | 获取线程的活跃运行 |
### 5.4 Worker 任务处理
#### `run_worker.py` 核心类/函数
| 类/函数 | 职责 |
|---------|------|
| `process_agent_run()` | ARQ 任务主函数,消费流并写入 Redis |
| `RunContext` | 运行上下文,管理取消信号监听 |
| `ChunkedEventWriter` | 事件块缓冲写入器 |
| `WorkerSettings` | ARQ Worker 配置max_tries=2, job_timeout=900s |
### 5.5 知识库管理
#### `KnowledgeBaseManager` (`knowledge/manager.py`)
```python
class KnowledgeBaseManager:
async def create_database(name, description, kb_type="lightrag", ...) # 创建知识库
async def delete_database(db_id) # 删除知识库
async def add_file_record(db_id, item, params) # 添加文件记录
async def parse_file(db_id, file_id) # 解析文件为 Markdown
async def index_file(db_id, file_id) # 索引文件到向量库
async def aquery(query_text, db_id, **kwargs) # 异步查询
def get_retrievers() -> dict[str, dict] # 获取所有检索器
```
### 5.6 MCP 服务
#### `mcp_service.py` 核心函数
| 函数 | 职责 |
|------|------|
| `get_mcp_tools()` | 获取指定服务器的工具(带缓存) |
| `get_enabled_mcp_tools()` | Agent 统一入口(自动过滤 disabled_tools |
| `get_tools_from_all_servers()` | 获取所有启用服务器的工具 |
| `ensure_builtin_mcp_servers_in_db()` | 同步内置 MCP 配置到数据库 |
| `create_mcp_server()` / `update_mcp_server()` / `delete_mcp_server()` | CRUD |
### 5.7 Skills 服务
#### `skill_service.py` 核心函数
| 函数 | 职责 |
|------|------|
| `import_skill_zip()` | 从 ZIP 导入 Skill |
| `list_skills()` | 列出所有 Skills |
| `install_builtin_skill()` | 安装内置 Skill |
| `update_builtin_skill()` | 更新内置 Skill带覆盖确认 |
| `sync_thread_visible_skills()` | 同步线程可见 Skills 到沙盒 |
### 5.8 数据库管理
#### `PostgresManager` (`storage/postgres/manager.py`)
```python
class PostgresManager(metaclass=SingletonMeta):
def initialize() # 初始化连接池
async def create_business_tables() # 创建业务表
async def ensure_business_schema() # 确保业务 schema增量迁移
async def ensure_knowledge_schema() # 确保知识库 schema
async def get_async_session_context() # 异步会话上下文管理器
```
---
## 6. 数据流与运行链路
### 6.1 典型智能体对话流程
```
1. AgentView 收集输入、附件、配置
2. web/src/apis/agent_api.js 调用 /api/chat/*
3. server/routers/chat_router.py → chat_service / agent_run_service
4. 服务层读取 conversation、agent_config、tools、skills、knowledge
5a. 同步模式: 直接调用 stream_agent_chat() → 返回 SSE 流
5b. 异步模式: create_agent_run_view() → ARQ 入队 → worker 执行
6. worker 执行 LangGraph 智能体
- 中间件挂载知识库、工具、Skills、MCP、附件、沙盒
- 运行事件写入 Redis
7. 最终状态和业务记录写入 Postgres
文件和产物落到 saves/、MinIO 或沙盒映射目录
8. 前端通过 SSE/轮询消费运行事件
渲染消息、工具调用、引用来源、产物卡片、文件预览
```
### 6.2 Agent Run 异步流程
```
用户请求
create_agent_run_view()
- 创建 AgentRun 记录status=pending
- ARQ enqueue_job("process_agent_run", run.id)
worker process_agent_run()
- 加载用户和配置
- mark_run_running()
- stream_agent_chat() 消费流
- ChunkedEventWriter 缓冲写入 Redis
- 处理取消信号
- mark_run_terminal(status=completed/failed/cancelled)
前端 SSE 连接 /api/chat/runs/{run_id}/events
- 轮询 Redis 事件
- 心跳保活
- 终端状态返回 close 事件
```
### 6.3 知识库文档处理流程
```
上传文件
add_file_record() → status=UPLOADED
parse_file() → 调用 Parser.aparse()
- status=PARSING
- 解析为 Markdown
- 保存到 MinIO
- status=PARSED / ERROR_PARSING
index_file() → 向量化和图谱构建
- status=INDEXING
- 分块 → Embedding → 向量库
- status=INDEXED / ERROR_INDEXING
aquery() → 检索 → Rerank → 返回结果
```
---
## 7. 依赖关系
### 7.1 后端核心依赖
```
fastapi>=0.121 # Web 框架
uvicorn[standard]>=0.34 # ASGI 服务器
arq>=0.26.3 # 异步任务队列
langgraph>=1.0.1 # 智能体编排
langchain>=1.2.0 # LLM 框架
langchain-openai>=1.0 # OpenAI 兼容层
langchain-mcp-adapters # MCP 适配
lightrag-hku>=1.4.6 # 知识图谱
neo4j>=5.28 # 图数据库
pymilvus>=2.5 # 向量数据库
asyncpg>=0.30 # PostgreSQL 异步驱动
psycopg[binary,pool] # PostgreSQL 连接池
redis>=5.2 # 缓存/消息
minio>=7.2 # 对象存储
sqlalchemy[asyncio]>=2 # ORM
langfuse>=4.0 # 可观测性
pydantic # 数据验证
pyjwt>=2.8 # JWT 认证
```
### 7.2 前端核心依赖
```
vue@^3.5 # 框架
vue-router@^4.6 # 路由
pinia@^3.0 # 状态管理
pinia-plugin-persistedstate # 状态持久化
ant-design-vue@^4.2 # UI 组件库
lucide-vue-next # 图标库
@antv/g6@^5.0 # 图谱可视化
sigma@^3.0 # 图谱渲染
@vueuse/core # 组合式工具
vite@^7.3 # 构建工具
less@^4.5 # CSS 预处理器
marked@^16 # Markdown 渲染
highlight.js # 代码高亮
```
### 7.3 模块依赖图
```
server/main.py
├── server/routers/*
│ └── yuxi.services.*
│ ├── yuxi.agents.* (BaseAgent, agent_manager)
│ ├── yuxi.knowledge.* (KnowledgeBaseManager)
│ ├── yuxi.repositories.* (SQLAlchemy CRUD)
│ ├── yuxi.storage.* (Postgres, MinIO)
│ └── yuxi.channels.* (多渠道网关)
└── server/utils/* (lifespan, auth, middleware)
yuxi.agents.base.BaseAgent
├── yuxi.agents.context.BaseContext
├── yuxi.agents.models.load_chat_model
└── yuxi.storage.postgres.manager.pg_manager
yuxi.services.chat_service
├── yuxi.agents.buildin.agent_manager
├── yuxi.repositories.conversation_repository
├── yuxi.services.langfuse_service
└── yuxi.plugins.guard.content_guard
```
---
## 8. 项目运行方式
### 8.1 环境要求
- Docker & Docker Compose
- Python 3.12+(后端开发)
- Node.js 20+ + pnpm前端开发
- Git
### 8.2 快速启动
```bash
# 1. 克隆项目
git clone --branch v0.6.2 --depth 1 https://github.com/xerrors/Yuxi.git
cd Yuxi
# 2. 初始化(创建 .env 等)
# Linux/macOS
./scripts/init.sh
# Windows PowerShell
.\scripts\init.ps1
# 3. 启动全部服务
docker compose up -d
# 或使用 Makefile
make up
# 4. 访问前端
open http://localhost:5173
```
### 8.3 LITE 模式(轻量启动)
跳过知识库、图谱、评估等重依赖:
```bash
make up-lite
# 等效于:
# LITE_MODE=true VITE_USE_RUNS_API=false docker compose up -d postgres redis minio api web
```
### 8.4 开发常用命令
```bash
# 查看日志
make logs
docker logs api-dev --tail 100
docker logs worker-dev --tail 100
# 代码格式化
make format
# 等效于:
cd backend && uv run ruff format package
cd backend && uv run ruff check package --fix
cd web && pnpm run format
cd web && pnpm run lint
# 运行测试
cd backend && uv run --group test pytest
# 停止服务
make down
```
### 8.5 开发环境特性
- **热重载**`api-dev` 和 `web-dev` 服务均配置热重载,本地修改代码后无需重启容器
- **代码挂载**`backend/server`、`backend/package`、`web/src` 等目录挂载到容器
- **调试**:使用 `docker logs` 查看实时日志
---
## 9. 测试体系
测试代码放在 `backend/test/`,按三层组织:
| 层级 | 目录 | 职责 |
|------|------|------|
| 单元测试 | `test/unit/` | 不依赖外部服务的纯逻辑测试 |
| 集成测试 | `test/integration/` | API 路由测试(需运行中的服务) |
| 端到端测试 | `test/e2e/` | 完整业务流程测试 |
### 9.1 运行测试
```bash
cd backend
uv run --group test pytest
# 带覆盖率
uv run --group test pytest --cov=yuxi --cov-report=html
# 仅运行单元测试
uv run --group test pytest -m unit
# 仅运行集成测试
uv run --group test pytest -m integration
# 仅运行 e2e 测试
uv run --group test pytest -m e2e
```
### 9.2 测试配置
- `pytest.ini` 配置在 `backend/pyproject.toml`
- 全局 fixtures 在 `test/conftest.py`
- 各层独立 `conftest.py` 管理层级 fixtures
---
## 10. 附录:目录结构总览
```
ForcePilot/
├── .github/ # GitHub 配置Issue 模板、工作流)
│ ├── ISSUE_TEMPLATE/
│ └── workflows/
├── backend/ # 后端代码
│ ├── package/ # 可复用业务包
│ │ └── yuxi/ # 核心包
│ │ ├── agents/ # 智能体体系
│ │ │ ├── backends/ # 执行后端沙盒、Skills
│ │ │ ├── buildin/ # 内置智能体
│ │ │ ├── middlewares/# 中间件
│ │ │ ├── toolkits/ # 工具注册与实现
│ │ │ ├── base.py # BaseAgent
│ │ │ ├── context.py # BaseContext
│ │ │ ├── models.py # 模型加载
│ │ │ └── state.py # 状态定义
│ │ ├── channels/ # 多渠道网关
│ │ │ └── adapters/ # 各渠道适配器
│ │ ├── config/ # 应用配置
│ │ ├── gateway/ # 网关协议
│ │ ├── knowledge/ # 知识库领域
│ │ │ ├── chunking/ # 分块策略
│ │ │ ├── graphs/ # 图谱适配
│ │ │ └── implementations/ # 具体实现
│ │ ├── models/ # 模型适配chat/embed/rerank
│ │ ├── plugins/ # 插件
│ │ │ └── parser/ # 文档解析
│ │ ├── repositories/ # 数据访问层
│ │ ├── services/ # 业务服务层
│ │ ├── storage/ # 存储基础设施
│ │ │ ├── minio/ # 对象存储
│ │ │ └── postgres/ # PostgreSQL
│ │ └── utils/ # 通用工具
│ ├── server/ # Web 应用入口
│ │ ├── routers/ # HTTP 路由
│ │ ├── utils/ # Web 层工具
│ │ ├── main.py # FastAPI 入口
│ │ └── worker_main.py # Worker 入口
│ ├── test/ # 测试代码
│ │ ├── unit/ # 单元测试
│ │ ├── integration/ # 集成测试
│ │ └── e2e/ # 端到端测试
│ └── pyproject.toml # 后端项目配置
├── web/ # 前端代码
│ ├── src/
│ │ ├── apis/ # API 封装
│ │ ├── components/ # 可复用组件
│ │ ├── composables/ # 可组合逻辑
│ │ ├── layouts/ # 布局组件
│ │ ├── router/ # 路由配置
│ │ ├── stores/ # Pinia 状态
│ │ ├── utils/ # 前端工具
│ │ ├── views/ # 页面级视图
│ │ ├── App.vue # 根组件
│ │ └── main.js # 入口
│ ├── public/
│ ├── index.html
│ ├── package.json
│ └── vite.config.js
├── docker/ # Docker 配置
│ ├── api.Dockerfile
│ ├── web.Dockerfile
│ ├── sandbox_provisioner/ # 沙盒供应器
│ └── volumes/ # 数据卷挂载点
├── docs/ # 项目文档
│ ├── .vitepress/ # VitePress 配置
│ ├── agents/ # Agent 开发文档
│ ├── develop-guides/ # 开发指南
│ └── vibe/ # 开发者笔记
├── docker-compose.yml # Docker Compose 配置
├── Makefile # 快捷命令
├── README.md # 项目说明
├── ARCHITECTURE.md # 架构文档
├── AGENTS.md # 开发准则
└── .env.template # 环境变量模板
```
---
## 参考文档
- [ARCHITECTURE.md](ARCHITECTURE.md) - 架构代码地图
- [AGENTS.md](AGENTS.md) - 开发准则与行为规范
- [README.md](README.md) - 项目快速开始
- [docker-compose.yml](docker-compose.yml) - 服务拓扑与配置
- [backend/package/pyproject.toml](backend/package/pyproject.toml) - Python 依赖
- [web/package.json](web/package.json) - Node.js 依赖

View File

@ -95,9 +95,7 @@ class ChannelMessageRecordRepository:
cutoff = utc_now_naive() - timedelta(seconds=timeout_seconds)
return [r for r in records if r.created_at and r.created_at < cutoff]
async def get_recent_records(
self, channel_id: str, chat_id: str, limit: int = 10
) -> list[ChannelMsgRecord]:
async def get_recent_records(self, channel_id: str, chat_id: str, limit: int = 10) -> list[ChannelMsgRecord]:
result = await self.db.execute(
select(ChannelMsgRecord)
.where(
@ -109,12 +107,12 @@ class ChannelMessageRecordRepository:
)
return list(result.scalars().all())
async def get_chat_message_count(
self, channel_id: str, chat_id: str, since_hours: int = 24
) -> int:
async def get_chat_message_count(self, channel_id: str, chat_id: str, since_hours: int = 24) -> int:
cutoff = utc_now_naive() - timedelta(hours=since_hours)
result = await self.db.execute(
select(func.count()).select_from(ChannelMsgRecord).where(
select(func.count())
.select_from(ChannelMsgRecord)
.where(
ChannelMsgRecord.channel_id == channel_id,
ChannelMsgRecord.chat_id == chat_id,
ChannelMsgRecord.created_at >= cutoff,

View File

@ -4,6 +4,7 @@ from typing import Any
from sqlalchemy import (
BigInteger,
Boolean,
Column,
DateTime,
Index,
@ -17,6 +18,31 @@ from yuxi.storage.postgres.models_business import Base
from yuxi.utils.datetime_utils import format_utc_datetime, utc_now_naive
class ChannelConfig(Base):
__tablename__ = "channels_configs"
id = Column(BigInteger, primary_key=True, autoincrement=True)
channel_id = Column(String(32), unique=True, nullable=False, index=True)
config_json = Column(JSONB, nullable=False, default=dict)
enabled = Column(Boolean, nullable=False, default=True)
registered_by = Column(String(64), nullable=True)
created_at = Column(DateTime, nullable=False, default=utc_now_naive)
updated_at = Column(DateTime, nullable=False, default=utc_now_naive, onupdate=utc_now_naive)
__table_args__ = (Index("idx_channels_configs_channel_id", "channel_id"),)
def to_dict(self) -> dict[str, Any]:
return {
"id": self.id,
"channel_id": self.channel_id,
"config_json": self.config_json or {},
"enabled": self.enabled,
"registered_by": self.registered_by,
"created_at": format_utc_datetime(self.created_at),
"updated_at": format_utc_datetime(self.updated_at),
}
class ChannelUserMapping(Base):
"""渠道用户映射表 - 渠道用户与内部用户的绑定关系"""
@ -64,6 +90,18 @@ class ChannelThreadMapping(Base):
"internal_user_id",
name="uq_channel_thread",
),
Index(
"idx_thread_mapping_user_recent",
"channel_id",
"internal_user_id",
text("last_active_at DESC"),
),
Index(
"idx_thread_mapping_chat_lookup",
"channel_id",
"channel_chat_id",
text("last_active_at DESC"),
),
)
def to_dict(self) -> dict[str, Any]:
@ -115,6 +153,12 @@ class ChannelMsgRecord(Base):
postgresql_where=(status == "processing"),
),
Index("idx_msg_records_chat", "channel_id", "chat_id"),
Index(
"idx_msg_records_channel_rt",
"channel_id",
"response_time_ms",
postgresql_where=(response_time_ms.isnot(None)),
),
)
def to_dict(self) -> dict[str, Any]:
@ -148,7 +192,7 @@ class ChannelPolicyConfig(Base):
id = Column(BigInteger, primary_key=True, autoincrement=True)
channel_id = Column(String(32), nullable=False, unique=True)
group_chat_mode = Column(String(16), nullable=False, default="all")
group_chat_mode = Column(String(16), nullable=False, default="mention_only")
whitelist_ids = Column(JSONB, nullable=False, default=list)
welcome_message = Column(String(500), nullable=True)
work_hours_start = Column(String(5), nullable=True, default="09:00")
@ -183,7 +227,6 @@ class ChannelRoutingRule(Base):
channel_id = Column(String(32), nullable=False)
command = Column(String(64), nullable=False)
agent_config_id = Column(String(64), nullable=False)
priority = Column(Integer, nullable=False, default=0)
created_at = Column(DateTime, nullable=False, default=utc_now_naive)
updated_at = Column(DateTime, nullable=False, default=utc_now_naive, onupdate=utc_now_naive)
@ -198,7 +241,6 @@ class ChannelRoutingRule(Base):
"channel_id": self.channel_id,
"command": self.command,
"agent_config_id": self.agent_config_id,
"priority": self.priority,
"created_at": format_utc_datetime(self.created_at),
"updated_at": format_utc_datetime(self.updated_at),
}

View File

@ -23,7 +23,7 @@ from starlette.middleware.base import BaseHTTPMiddleware
from server.routers import router
from server.routers.ws_chat_router import ws_chat
from yuxi.channels.adapters.slack.http_handler import slack_webhook
from yuxi.channels.adapters.nostr.profile_api import profile_router
from yuxi.channels.message_actions import ChannelNotFoundError
from server.utils.lifespan import lifespan
from server.utils.auth_middleware import is_public_path
from server.utils.common_utils import setup_logging
@ -47,8 +47,15 @@ app.include_router(router, prefix="/api")
app.include_router(ws_chat)
# Slack HTTP Webhook (单独注册以处理 URL verification challenge)
app.include_router(slack_webhook)
# Nostr Profile API
app.include_router(profile_router)
@app.exception_handler(ChannelNotFoundError)
async def channel_not_found_handler(request: Request, exc: ChannelNotFoundError):
return JSONResponse(
status_code=status.HTTP_404_NOT_FOUND,
content={"code": -1, "data": None, "message": str(exc)},
)
# CORS 设置
app.add_middleware(

File diff suppressed because it is too large Load Diff

View File

@ -89,11 +89,13 @@ async def websocket_chat(ws: WebSocket, client_id: str, token: str = Query(None)
return
await ws.accept()
await ws.send_json({
"type": "connected",
"payload": {"client_id": client_id, "user_id": user_id},
"timestamp": _now_iso(),
})
await ws.send_json(
{
"type": "connected",
"payload": {"client_id": client_id, "user_id": user_id},
"timestamp": _now_iso(),
}
)
last_msg_time = time.monotonic()
rate_bucket: list[float] = []
@ -139,11 +141,13 @@ async def websocket_chat(ws: WebSocket, client_id: str, token: str = Query(None)
now = time.monotonic()
rate_bucket = [t for t in rate_bucket if now - t < 1.0]
if len(rate_bucket) >= MAX_MSG_PER_SEC:
await ws.send_json({
"type": "error",
"payload": {"code": 429, "message": "Rate limit exceeded"},
"timestamp": _now_iso(),
})
await ws.send_json(
{
"type": "error",
"payload": {"code": 429, "message": "Rate limit exceeded"},
"timestamp": _now_iso(),
}
)
continue
rate_bucket.append(now)
@ -173,31 +177,37 @@ async def websocket_chat(ws: WebSocket, client_id: str, token: str = Query(None)
)
request_id = identity.channel_message_id
await ws.send_json({
"type": "typing",
"payload": {"status": "agent", "request_id": request_id},
"timestamp": _now_iso(),
})
await ws.send_json(
{
"type": "typing",
"payload": {"status": "agent", "request_id": request_id},
"timestamp": _now_iso(),
}
)
try:
await get_channel_manager().dispatch_inbound(message)
except Exception as e:
logger.error(f"WS message processing error: {e}")
await ws.send_json({
"type": "error",
"payload": {
"request_id": request_id,
"code": 500,
"message": f"处理出错: {str(e)}",
},
"timestamp": _now_iso(),
})
await ws.send_json(
{
"type": "error",
"payload": {
"request_id": request_id,
"code": 500,
"message": f"处理出错: {str(e)}",
},
"timestamp": _now_iso(),
}
)
await ws.send_json({
"type": "done",
"payload": {"request_id": request_id},
"timestamp": _now_iso(),
})
await ws.send_json(
{
"type": "done",
"payload": {"request_id": request_id},
"timestamp": _now_iso(),
}
)
except WebSocketDisconnect:
logger.info(f"WS client {client_id} disconnected")

View File

@ -185,7 +185,14 @@ export const channelApi = {
getUserMapping: (channelId, userId) => apiGet(`/api/channels/${channelId}/users/${userId}`),
getChatMapping: (channelId, chatId) => apiGet(`/api/channels/${channelId}/chats/${chatId}`),
getChatMapping: (channelId, chatId, internalUserId) => {
const params = internalUserId ? { internal_user_id: internalUserId } : {}
return apiGet(`/api/channels/${channelId}/chats/${chatId}`, { params })
},
getCredentialStatus: (channelId) => apiGet(`/api/channels/${channelId}/credential-status`),
refreshCredential: (channelId) => apiAdminPost(`/api/channels/${channelId}/refresh-credential`),
getDashboardChannelStats: () => apiGet('/api/channels/status')
}

View File

@ -15,6 +15,7 @@ const userResult = ref(null)
const userLoading = ref(false)
const chatSearchId = ref('')
const chatInternalUserId = ref('')
const chatResult = ref(null)
const chatLoading = ref(false)
@ -50,7 +51,7 @@ async function searchChat() {
chatLoading.value = true
chatResult.value = null
try {
const res = await channelApi.getChatMapping(props.channelId, cid)
const res = await channelApi.getChatMapping(props.channelId, cid, chatInternalUserId.value || undefined)
if (seq !== searchChatSeq) return
chatResult.value = res.data
} catch (e) {
@ -137,6 +138,15 @@ async function searchChat() {
搜索
</button>
</div>
<div class="search-bar">
<input
v-model="chatInternalUserId"
type="text"
class="search-input"
placeholder="内部用户 ID可选用于精确匹配..."
@keydown.enter="searchChat"
/>
</div>
<div v-if="chatLoading" class="search-loading">搜索中...</div>
<div v-else-if="chatResult" class="result-card">
<div class="result-row">

View File

@ -1,6 +1,6 @@
<script setup>
import { computed, ref } from 'vue'
import { RotateCcw, Plug, MessageCircle } from 'lucide-vue-next'
import { RotateCcw, Plug, MessageCircle, Shield, RefreshCw } from 'lucide-vue-next'
import ChannelStatusBadge from './ChannelStatusBadge.vue'
import { channelIcons } from '@/constants/channelIcons'
@ -8,10 +8,12 @@ const props = defineProps({
channel: { type: Object, required: true },
toggling: { type: Boolean, default: false },
batchMode: { type: Boolean, default: false },
selected: { type: Boolean, default: false }
selected: { type: Boolean, default: false },
credentialStatus: { type: Object, default: null },
credentialLoading: { type: Boolean, default: false }
})
const emit = defineEmits(['manage', 'toggle', 'restart', 'test', 'select'])
const emit = defineEmits(['manage', 'toggle', 'restart', 'test', 'select', 'refreshCredential'])
const imgError = ref(false)
@ -41,6 +43,47 @@ const healthLabel = computed(() => {
const map = { healthy: '正常', degraded: '降级', unhealthy: '异常' }
return map[healthClass.value]
})
const credentialClass = computed(() => {
const cs = props.credentialStatus
if (!cs || !cs.has_credential) return 'cred-none'
if (cs.is_expired) return 'cred-expired'
if (cs.expires_at && cs.source === 'db') {
const remaining = new Date(cs.expires_at).getTime() - Date.now()
if (remaining <= 3600000) return 'cred-soon'
return 'cred-ok'
}
if (cs.source === 'memory') return 'cred-memory'
if (cs.source === 'config' || cs.source === 'env') return 'cred-static'
if (cs.source === 'file') return 'cred-file'
return 'cred-ok'
})
const credentialLabel = computed(() => {
const map = {
'cred-ok': '凭证正常',
'cred-soon': '即将过期',
'cred-expired': '已过期',
'cred-none': '无凭证',
'cred-memory': '内存存储',
'cred-static': '静态凭证',
'cred-file': '文件存储'
}
return map[credentialClass.value] || '凭证正常'
})
const credentialSourceLabel = computed(() => {
const map = { db: '数据库', config: '配置文件', env: '环境变量', file: '文件', memory: '内存', none: '无' }
const cs = props.credentialStatus
return cs ? (map[cs.source] || cs.source) : ''
})
const showRefreshBtn = computed(() => {
const cs = props.credentialStatus
if (!cs) return false
return cs.source === 'db' || cs.source === 'memory'
})
</script>
</script>
<template>
@ -81,6 +124,31 @@ const healthLabel = computed(() => {
</div>
</div>
<div v-if="credentialStatus" class="card-credential">
<div class="credential-header">
<div class="credential-status-badge" :class="credentialClass">
<Shield :size="12" />
<span>{{ credentialLabel }}</span>
</div>
<span class="credential-source">来源: {{ credentialSourceLabel }}</span>
<button
v-if="showRefreshBtn"
class="btn-refresh-cred"
:disabled="credentialLoading"
title="刷新凭证"
@click="emit('refreshCredential', channel.channel_id)"
>
<RefreshCw :size="12" :class="{ spinning: credentialLoading }" />
</button>
</div>
<div v-if="credentialStatus.last_updated" class="credential-detail">
<span>更新于 {{ credentialStatus.last_updated }}</span>
</div>
<div v-if="credentialStatus.expires_at" class="credential-detail">
<span>过期时间 {{ credentialStatus.expires_at }}</span>
</div>
</div>
<div v-if="channel.health && channel.enabled" class="card-health">
<span class="health-item">
<span class="health-dot" :class="healthClass"></span>
@ -305,6 +373,70 @@ const healthLabel = computed(() => {
}
}
.card-credential {
display: flex;
flex-direction: column;
gap: 4px;
padding: 8px 10px;
border-radius: 6px;
background: var(--gray-10);
border: 1px solid var(--gray-100);
.credential-header {
display: flex;
align-items: center;
gap: 8px;
}
.credential-status-badge {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 8px;
border-radius: 10px;
font-size: 12px;
font-weight: 500;
&.cred-ok { background: var(--color-success-100); color: var(--color-success-700); }
&.cred-soon { background: var(--color-warning-100); color: var(--color-warning-700); }
&.cred-expired { background: var(--color-error-100); color: var(--color-error-700); }
&.cred-none { background: var(--gray-100); color: var(--gray-500); }
&.cred-memory { background: var(--color-warning-100); color: var(--color-warning-700); }
&.cred-static { background: var(--gray-100); color: var(--gray-600); }
&.cred-file { background: var(--gray-100); color: var(--gray-600); }
}
.credential-source {
font-size: 12px;
color: var(--gray-500);
}
.btn-refresh-cred {
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border: 1px solid var(--gray-200);
border-radius: 4px;
background: transparent;
color: var(--gray-500);
cursor: pointer;
margin-left: auto;
transition: border-color 0.2s, color 0.2s;
&:hover { border-color: var(--main-200); color: var(--main-color); }
&:disabled { opacity: 0.5; cursor: not-allowed; }
.spinning { animation: spin 1s linear infinite; }
}
.credential-detail {
font-size: 11px;
color: var(--gray-500);
}
}
.card-footer {
display: flex;
align-items: center;
@ -360,4 +492,9 @@ const healthLabel = computed(() => {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
</style>

View File

@ -31,6 +31,23 @@ export const useChannelStore = defineStore('channel', () => {
try {
const res = await channelApi.getStatusList()
channels.value = res.data?.channels || {}
const channelIds = Object.keys(channels.value)
if (channelIds.length > 0) {
const credResults = await Promise.allSettled(
channelIds.map((id) =>
channelApi.getCredentialStatus(id).then((r) => ({ id, data: r.data }))
)
)
for (const result of credResults) {
if (result.status === 'fulfilled' && result.value?.data) {
channels.value[result.value.id] = {
...channels.value[result.value.id],
credential_status: result.value.data
}
}
}
}
} catch (e) {
loadError.value = e.message
} finally {
@ -166,6 +183,35 @@ export const useChannelStore = defineStore('channel', () => {
return res
}
async function fetchCredentialStatus(channelId) {
try {
const res = await channelApi.getCredentialStatus(channelId)
if (res.data) {
channels.value[channelId] = {
...channels.value[channelId],
credential_status: res.data
}
}
return res.data
} catch (e) {
console.warn('获取凭证状态失败:', e)
return null
}
}
async function refreshCredential(channelId) {
try {
const res = await channelApi.refreshCredential(channelId)
if (res.data) {
await fetchCredentialStatus(channelId)
}
return res
} catch (e) {
console.warn('刷新凭证失败:', e)
throw e
}
}
function sendMessage(content, opts) {
return wsClient.value?.send(content, opts)
}
@ -255,6 +301,8 @@ export const useChannelStore = defineStore('channel', () => {
updatePolicy,
registerChannel,
unregisterChannel,
fetchCredentialStatus,
refreshCredential,
sendMessage,
initWS,
disconnectWS,

View File

@ -4,7 +4,7 @@ import { useRoute, useRouter } from 'vue-router'
import { message, Modal } from 'ant-design-vue'
import {
ArrowLeft, RefreshCw, Play, Square, RotateCcw, Plug, CheckSquare, X,
Plus, MessageCircle, Settings
Plus, MessageCircle, Settings, Shield
} from 'lucide-vue-next'
import { useChannelStore } from '@/stores/channel'
import { useAgentStore } from '@/stores/agent'
@ -41,6 +41,7 @@ const showTestDialog = ref(false)
const editableConfig = ref({})
const editablePolicy = ref({})
const actionLoading = reactive({})
const credRefreshLoading = ref(false)
const policySaving = ref(false)
const refreshing = ref(false)
@ -268,7 +269,8 @@ async function loadChannelDetail(channelId) {
await Promise.all([
channelStore.fetchChannelDetail(channelId),
channelStore.fetchChannelPolicy(channelId),
channelStore.fetchRouting(channelId)
channelStore.fetchRouting(channelId),
channelStore.fetchCredentialStatus(channelId)
])
} finally {
detailLoading.value = false
@ -466,6 +468,48 @@ function handleCardTest(channelId) {
router.push({ name: 'ChannelDetailComp', params: { channelId }, query: { tab: 'test' } })
}
function credentialSourceLabel(source) {
const map = { db: '数据库', config: '配置文件', env: '环境变量', file: '文件', memory: '内存', none: '无' }
return map[source] || source || '-'
}
function credentialStatusClass(cs) {
if (!cs || !cs.has_credential) return 'cred-none'
if (cs.is_expired) return 'cred-expired'
if (cs.expires_at && cs.source === 'db') {
const remaining = new Date(cs.expires_at).getTime() - Date.now()
if (remaining <= 3600000) return 'cred-soon'
return 'cred-ok'
}
if (cs.source === 'memory') return 'cred-memory'
return 'cred-ok'
}
function credentialStatusLabel(cs) {
const map = {
'cred-ok': '凭证正常',
'cred-soon': '即将过期',
'cred-expired': '已过期',
'cred-none': '无凭证',
'cred-memory': '内存存储'
}
return map[credentialStatusClass(cs)] || '凭证正常'
}
async function handleRefreshCredential(channelId) {
if (credRefreshLoading.value) return
credRefreshLoading.value = true
try {
await channelStore.refreshCredential(channelId)
message.success('凭证刷新成功')
await loadChannelDetail(channelId)
} catch (e) {
message.error(e.message || '刷新凭证失败')
} finally {
credRefreshLoading.value = false
}
}
onMounted(async () => {
if (!props.channelId) {
await channelStore.fetchAllStatus()
@ -700,6 +744,54 @@ onUnmounted(() => {
</div>
</div>
<div v-if="channelDetail.credential_status" class="credential-info-section">
<h4 class="subsection-title">
<Shield :size="14" />
凭证状态
<a-button
size="small"
type="link"
:loading="credRefreshLoading"
@click="handleRefreshCredential(detailChannelId)"
>
<RefreshCw :size="12" />
刷新凭证
</a-button>
</h4>
<div class="credential-info-grid">
<div class="info-item">
<span class="info-label">凭证来源</span>
<span class="info-value">
<span class="cred-source-badge" :class="`cred-source-${channelDetail.credential_status.source}`">
{{ credentialSourceLabel(channelDetail.credential_status.source) }}
</span>
</span>
</div>
<div class="info-item">
<span class="info-label">凭证类型</span>
<span class="info-value">{{ channelDetail.credential_status.credential_type || '-' }}</span>
</div>
<div class="info-item">
<span class="info-label">状态</span>
<span class="info-value">
<span class="cred-status-badge" :class="credentialStatusClass(channelDetail.credential_status)">
{{ credentialStatusLabel(channelDetail.credential_status) }}
</span>
</span>
</div>
<div class="info-item">
<span class="info-label">最后更新</span>
<span class="info-value">{{ channelDetail.credential_status.last_updated || '-' }}</span>
</div>
<div class="info-item">
<span class="info-label">过期时间</span>
<span class="info-value">
{{ channelDetail.credential_status.expires_at || '无过期时间' }}
</span>
</div>
</div>
</div>
<div class="channel-actions">
<a-button
type="primary"
@ -1471,4 +1563,59 @@ onUnmounted(() => {
cursor: not-allowed;
}
}
.credential-info-section {
margin-top: 16px;
padding-top: 12px;
border-top: 1px solid var(--gray-150);
.subsection-title {
display: flex;
align-items: center;
gap: 6px;
font-size: 14px;
font-weight: 600;
color: var(--gray-900);
margin: 0 0 10px;
}
}
.credential-info-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
@media (max-width: 767px) {
grid-template-columns: 1fr;
}
}
.cred-source-badge {
display: inline-block;
padding: 2px 8px;
border-radius: 4px;
font-size: 12px;
font-weight: 500;
&.cred-source-db { background: var(--color-success-50); color: var(--color-success-700); }
&.cred-source-config { background: var(--gray-100); color: var(--gray-600); }
&.cred-source-env { background: var(--gray-100); color: var(--gray-600); }
&.cred-source-file { background: var(--gray-100); color: var(--gray-600); }
&.cred-source-memory { background: var(--color-warning-50); color: var(--color-warning-700); }
&.cred-source-none { background: var(--gray-100); color: var(--gray-500); }
}
.cred-status-badge {
display: inline-block;
padding: 2px 8px;
border-radius: 4px;
font-size: 12px;
font-weight: 500;
&.cred-ok { background: var(--color-success-50); color: var(--color-success-700); }
&.cred-soon { background: var(--color-warning-50); color: var(--color-warning-700); }
&.cred-expired { background: var(--color-error-50); color: var(--color-error-700); }
&.cred-none { background: var(--gray-100); color: var(--gray-500); }
&.cred-memory { background: var(--color-warning-50); color: var(--color-warning-700); }
}
</style>