Merge branch 'feat/enterprise-client-server' into main
Deploy to Production / deploy (push) Failing after 7s
Details
Deploy to Production / deploy (push) Failing after 7s
Details
企业级客户端-服务端架构 + 代码审查修复 - JWT 认证 + RBAC 权限矩阵 - 终端六层安全防御 - 远程 LLM 网关(401 重试) - Tauri 客户端配置同步 - 代码审查 P0/P1/P2 修复
This commit is contained in:
commit
aeb82ad7a0
|
|
@ -39,3 +39,6 @@ src/agentkit/server/static/
|
|||
|
||||
# Env
|
||||
.env
|
||||
|
||||
# Runtime data (auth DB, conversation DB, etc.)
|
||||
data/
|
||||
|
|
|
|||
17
AGENTS.md
17
AGENTS.md
|
|
@ -112,20 +112,23 @@ On failure: fallback to single-agent mode (lead or first active expert).
|
|||
| Layer | Modules | Purpose |
|
||||
|-------|---------|---------|
|
||||
| API | `server/`, `cli/` | FastAPI routes + Typer CLI |
|
||||
| Auth | `server/auth/` | JWT + RBAC + terminal security (6-layer whitelist) |
|
||||
| Service | `core/`, `chat/`, `skills/`, `experts/` | Agent engine, routing, skills, expert teams |
|
||||
| Data | `memory/`, `session/`, `bus/` | Persistence, sessions, messaging |
|
||||
| Utility | `llm/`, `tools/`, `evolution/`, `quality/`, `mcp/` | LLM gateway, tools, self-evolution, quality, MCP |
|
||||
| Client | `client/` | ConfigSync, RemoteLLMProvider integration |
|
||||
|
||||
### Key Subsystems
|
||||
|
||||
- **LLM Gateway** (`llm/`): 6 providers (OpenAI/Anthropic/Gemini/Doubao/Wenxin/Yuanbao), fallback, semantic cache, usage tracking
|
||||
- **LLM Gateway** (`llm/`): 6 providers (OpenAI/Anthropic/Gemini/Doubao/Wenxin/Yuanbao), fallback, semantic cache, usage tracking, RemoteLLMProvider (client→server proxy with 401 refresh retry)
|
||||
- **Memory** (`memory/`): 4-layer (SOUL/USER/MEMORY/DAILY), WorkingMemory (Redis), EpisodicMemory (PG+pgvector), SemanticMemory (HTTP RAG)
|
||||
- **Evolution** (`evolution/`): Reflector, PromptOptimizer (genetic), PitfallDetector, ABTester
|
||||
- **Tools** (`tools/`): 21 built-in + MCP extension, composition (SequentialChain/ParallelFanOut/DynamicSelector)
|
||||
- **Pipeline** (`orchestrator/`): PipelineEngine, SagaOrchestrator, DynamicPipeline, HandoffManager
|
||||
- **Bus** (`bus/`): MemoryBus (in-process), RedisBus (distributed)
|
||||
- **Auth** (`server/auth/`): JWT (access 15min + refresh 7d, HS256), API Key (constant-time compare), 3-level RBAC (member/operator/admin + permission bits), 6-layer terminal security (blocklist→shell-ops→builtin→global→user→session→danger), bcrypt password hashing (rounds=12)
|
||||
|
||||
### Server Routes (17 modules)
|
||||
### Server Routes (22 modules)
|
||||
|
||||
| Prefix | Module | Purpose |
|
||||
|--------|--------|---------|
|
||||
|
|
@ -135,6 +138,7 @@ On failure: fallback to single-agent mode (lead or first active expert).
|
|||
| `/api/v1/chat` | chat.py | Chat REST + WebSocket |
|
||||
| `/api/v1/ws` | ws.py | WebSocket channel |
|
||||
| `/api/v1/llm` | llm.py | LLM usage |
|
||||
| `/api/v1/llm/chat` | llm_gateway.py | LLM gateway proxy (JWT auth, SSE streaming) |
|
||||
| `/api/v1/health` | health.py | Health check |
|
||||
| `/api/v1/metrics` | metrics.py | Metrics |
|
||||
| `/api/v1/evolution` | evolution.py + evolution_dashboard.py | Self-evolution API |
|
||||
|
|
@ -143,8 +147,13 @@ On failure: fallback to single-agent mode (lead or first active expert).
|
|||
| `/api/v1/kb` | kb_management.py | Knowledge base |
|
||||
| `/api/v1/skill-mgmt` | skill_management.py | Skill management |
|
||||
| `/api/v1/workflows` | workflows.py | Workflows |
|
||||
| `/api/v1/terminal` | terminal.py | Terminal |
|
||||
| `/api/v1/terminal` | terminal.py | Local terminal (client sidecar PTY) |
|
||||
| `/api/v1/terminal/server` | terminal_server.py | Server terminal (server PTY + admin approval) |
|
||||
| `/api/v1/terminal` | terminal_whitelist.py | Whitelist/blocklist/audit-log management |
|
||||
| `/api/v1/settings` | settings.py | Settings |
|
||||
| `/api/v1/auth` | auth.py | Login/refresh/logout/me |
|
||||
| `/api/v1/system` | system.py | System resources (SYSTEM_CONFIG permission) |
|
||||
| `/api/v1/config` | config_sync.py | Config version + sync (polling) |
|
||||
|
||||
### WebSocket Chat Protocol
|
||||
|
||||
|
|
@ -158,6 +167,8 @@ Expert Team events: `team_formed`, `expert_step`, `expert_result`, `plan_update`
|
|||
- `/agent/code` — Code/workflow
|
||||
- `/agent/monitor` — Evolution dashboard
|
||||
- `/computer-use` — Desktop control
|
||||
- `/login` — Login page (JWT auth)
|
||||
- Terminal panel — Local + server terminal with whitelist manager
|
||||
|
||||
### Configuration Priority
|
||||
|
||||
|
|
|
|||
126
README.md
126
README.md
|
|
@ -14,6 +14,7 @@ AgentKit 解决的核心问题:**从写 150 行 Agent 代码降为 10-20 行 Y
|
|||
- **生产就绪** -- 内置质量门禁、模型降级、用量统计、级联检测、状态持久化
|
||||
- **四种使用** -- Python 库引用、CLI 聊天、Web GUI、桌面客户端
|
||||
- **专家团队** -- Expert Team Mode,多专家协作执行复杂任务,前端以多角色对话流呈现
|
||||
- **企业架构** -- 客户端-服务端分离,JWT 认证 + 三级 RBAC + LLM 网关代理 + 终端六层安全防护
|
||||
- **记忆持久化** -- SOUL/USER/MEMORY/DAILY 四层记忆,写入即生效
|
||||
- **自进化** -- 反思驱动 Soul 更新,经验积累与陷阱检测
|
||||
- **工具丰富** -- 内置 Shell、搜索、爬虫、记忆、桌面操控等工具,支持 MCP 扩展
|
||||
|
|
@ -211,6 +212,87 @@ result = await orchestrator.execute_plan(plan)
|
|||
|
||||
用户也可在聊天中通过 `@team:researcher,writer,reviewer 任务描述` 前缀触发团队模式。
|
||||
|
||||
### 16. 企业级客户端-服务端架构
|
||||
|
||||
将 AgentKit 从纯本地运行架构演进为企业级客户端+服务端架构。客户端(Tauri 桌面端)作为 AI 工作台本地执行 Agent/终端/文件操作,服务端作为企业平台提供 LLM 网关(统一 Key 管理)、用户权限、审计日志、知识库共享能力。
|
||||
|
||||
**核心能力**:
|
||||
|
||||
| 能力 | 说明 |
|
||||
|------|------|
|
||||
| JWT 认证 | Access Token (15min) + Refresh Token (7d),HS256 签名 |
|
||||
| API Key 认证 | 服务间调用,常量时间比较(`hmac.compare_digest`) |
|
||||
| 三级 RBAC | member / operator / admin + 独立权限位 |
|
||||
| LLM 网关代理 | 客户端通过服务端间接调用 LLM,不在本地存储 API Key |
|
||||
| 双模式终端 | 本地终端(客户端 sidecar PTY)+ 服务端终端(服务端 PTY + 管理员审批) |
|
||||
| 六层终端安全 | 黑名单 → 内置安全 → 全局白名单 → 用户白名单 → 会话白名单 → 危险检测 |
|
||||
| 终端审计日志 | 所有命令(执行/审批/拒绝/阻止)均记录,支持按用户/会话/模式过滤 |
|
||||
| 配置同步 | 启动全量拉取 + 5 分钟轮询版本号 + 手动刷新 |
|
||||
| Shell 操作符防护 | `&&`、`;`、`|`、`$()` 等操作符始终触发危险检测,绕过白名单前缀匹配 |
|
||||
|
||||
**权限矩阵**:
|
||||
|
||||
| 权限 | member | operator | admin |
|
||||
|------|--------|----------|-------|
|
||||
| CHAT | ✓ | ✓ | ✓ |
|
||||
| KB_QUERY | ✓ | ✓ | ✓ |
|
||||
| KB_WRITE | | ✓ | ✓ |
|
||||
| WORKFLOW_EXECUTE | ✓ | ✓ | ✓ |
|
||||
| TERMINAL_LOCAL_USE | | ✓ | ✓ |
|
||||
| TERMINAL_SERVER_USE | | | ✓ |
|
||||
| TERMINAL_WHITELIST_MANAGE | | ✓ | ✓ |
|
||||
| USER_MANAGE | | | ✓ |
|
||||
| SYSTEM_CONFIG | | | ✓ |
|
||||
|
||||
**认证流程**:
|
||||
|
||||
```
|
||||
客户端登录 → /api/v1/auth/login (username + password)
|
||||
← { access_token, refresh_token, user }
|
||||
|
||||
Token 过期 → RemoteLLMProvider 收到 401
|
||||
→ 调用 refresh_callback → /api/v1/auth/refresh
|
||||
← 新 access_token (refresh_token 不轮换)
|
||||
→ 重试原始请求
|
||||
```
|
||||
|
||||
**终端安全决策流**:
|
||||
|
||||
```
|
||||
命令输入
|
||||
↓
|
||||
1. 黑名单检查 → BLOCKED
|
||||
↓
|
||||
2. Shell 操作符检测 (&&/;/|/$()/`) → 标记为危险
|
||||
↓
|
||||
3. 内置安全白名单 (ls/cat/git status...) → SAFE
|
||||
↓
|
||||
4. 全局白名单 (管理员配置) → SAFE
|
||||
↓
|
||||
5. 用户白名单 (DB 持久化) → SAFE
|
||||
↓
|
||||
6. 会话白名单 (本次会话临时) → SAFE
|
||||
↓
|
||||
7. 危险检测 (_is_dangerous) → 需确认/审批
|
||||
↓
|
||||
本地终端: 用户确认 → 加入会话白名单 → 执行
|
||||
服务端终端: 管理员审批 (5分钟超时) → 执行
|
||||
```
|
||||
|
||||
**使用方式**:
|
||||
|
||||
```python
|
||||
from agentkit.llm import RemoteLLMProvider
|
||||
|
||||
# 客户端通过服务端 LLM 网关调用(不在本地存储 API Key)
|
||||
provider = RemoteLLMProvider(
|
||||
server_url="https://api.example.com",
|
||||
auth_token_provider=lambda: get_jwt_token(),
|
||||
refresh_callback=refresh_tokens, # 401 时自动刷新
|
||||
)
|
||||
response = await provider.chat(request)
|
||||
```
|
||||
|
||||
## 架构图
|
||||
|
||||
```
|
||||
|
|
@ -223,12 +305,19 @@ result = await orchestrator.execute_plan(plan)
|
|||
│ 前端 (Vue 3 + Ant Design Vue) │
|
||||
│ ChatView · ExpertTeamView · ExpertMessage · PlanViz │
|
||||
│ EvolutionView · WorkflowView · TerminalView · ComputerUse │
|
||||
│ LoginView · SystemMonitorPanel · WhitelistManager │
|
||||
└──────────────────────────┼───────────────────────────────────┘
|
||||
│ WebSocket / SSE / HTTP
|
||||
│ WebSocket / SSE / HTTP (JWT)
|
||||
┌──────────────────────────┼───────────────────────────────────┐
|
||||
│ 服务端 (FastAPI + Uvicorn) │
|
||||
│ portal.py · chat.py · evolution.py · workflows.py · ... │
|
||||
│ 17个路由模块 · Agent Pool · Expert Team · Memory Store │
|
||||
│ ┌─────────────────────────────────────────────────────┐ │
|
||||
│ │ 认证层: AuthMiddleware (JWT + API Key 双轨) │ │
|
||||
│ │ RBAC: require_permission / require_terminal_auth │ │
|
||||
│ └─────────────────────────────────────────────────────┘ │
|
||||
│ portal.py · chat.py · evolution.py · workflows.py │
|
||||
│ auth.py · terminal_server.py · terminal_whitelist.py │
|
||||
│ llm_gateway.py · config_sync.py · system.py · ... │
|
||||
│ 22个路由模块 · Agent Pool · Expert Team · Memory Store │
|
||||
└──────────────────────────┼───────────────────────────────────┘
|
||||
│
|
||||
┌──────────────┼──────────────┐
|
||||
|
|
@ -261,6 +350,13 @@ result = await orchestrator.execute_plan(plan)
|
|||
┌─────────┐ ┌────────┐ ┌──────────┐
|
||||
│DashScope│ │ OpenAI │ │ DeepSeek │ ...
|
||||
└─────────┘ └────────┘ └──────────┘
|
||||
|
||||
┌──────────────────────────────────────────────────────────────┐
|
||||
│ 终端安全六层防护 │
|
||||
│ 黑名单 → Shell操作符检测 → 内置白名单 → 全局白名单 │
|
||||
│ → 用户白名单 → 会话白名单 → 危险检测 → 审批/确认 │
|
||||
│ 本地终端: 用户确认 | 服务端终端: 管理员审批 (5min超时) │
|
||||
└──────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 模块分层
|
||||
|
|
@ -1233,6 +1329,24 @@ v2 增强:接受 SkillConfig 时自动创建 Skill 并启用 ReAct 模式,Qu
|
|||
| `/api/v1/skills` | GET | 列出所有 Skill |
|
||||
| `/api/v1/llm/usage` | GET | 查询 LLM 用量 |
|
||||
| `/api/v1/health` | GET | 健康检查 |
|
||||
| `/api/v1/auth/login` | POST | 用户登录(用户名+密码 → JWT) |
|
||||
| `/api/v1/auth/refresh` | POST | 刷新 access_token |
|
||||
| `/api/v1/auth/logout` | POST | 登出(吊销 refresh_token) |
|
||||
| `/api/v1/auth/me` | GET | 获取当前用户信息 |
|
||||
| `/api/v1/llm/chat` | POST | LLM 网关代理(JWT 认证) |
|
||||
| `/api/v1/llm/chat/stream` | POST | LLM 网关流式代理(SSE) |
|
||||
| `/api/v1/terminal/ws` | WS | 本地终端 WebSocket(JWT via ?token=) |
|
||||
| `/api/v1/terminal/server/ws` | WS | 服务端终端 WebSocket(管理员审批) |
|
||||
| `/api/v1/terminal/approvals` | GET | 列出待审批命令(管理员) |
|
||||
| `/api/v1/terminal/approvals/{id}/approve` | POST | 审批通过(管理员) |
|
||||
| `/api/v1/terminal/approvals/{id}/reject` | POST | 审批拒绝(管理员) |
|
||||
| `/api/v1/terminal/whitelist/global` | GET/POST/DELETE | 全局白名单管理(管理员) |
|
||||
| `/api/v1/terminal/whitelist/user` | GET/POST/DELETE | 用户白名单管理 |
|
||||
| `/api/v1/terminal/blocklist` | GET/POST/DELETE | 黑名单管理(管理员) |
|
||||
| `/api/v1/terminal/audit-logs` | GET | 审计日志查询(管理员,支持 terminal_mode 过滤) |
|
||||
| `/api/v1/system/resources` | GET | 系统资源监控(需 SYSTEM_CONFIG 权限) |
|
||||
| `/api/v1/config/version` | GET | 配置版本号(内容哈希) |
|
||||
| `/api/v1/config/all` | GET | 全量配置(技能+工作流+Agent) |
|
||||
|
||||
### Web GUI
|
||||
|
||||
|
|
@ -1416,11 +1530,12 @@ fischer-agentkit/
|
|||
│ ├── bus/ # 消息总线(MemoryBus + RedisBus)
|
||||
│ ├── chat/ # 聊天路由(CostAwareRouter + ExecutionMode)
|
||||
│ ├── cli/ # CLI 命令(Typer)
|
||||
│ ├── client/ # 客户端 SDK(ConfigSync + RemoteLLMProvider 集成)
|
||||
│ ├── core/ # 核心引擎(ReAct/Reflexion/ReWOO/ConfigDriven + HandoffTransport)
|
||||
│ ├── evaluation/ # 评估系统(RAGAS)
|
||||
│ ├── evolution/ # 自进化(反思/优化/陷阱检测/A/B测试)
|
||||
│ ├── experts/ # 专家团队(Expert/Team/Orchestrator/Plan/Router/Config/Registry)
|
||||
│ ├── llm/ # LLM 网关(6 Provider + 缓存 + 用量追踪)
|
||||
│ ├── llm/ # LLM 网关(6 Provider + 缓存 + 用量追踪 + RemoteLLMProvider)
|
||||
│ ├── marketplace/ # 多Agent市场(拍卖/财富)
|
||||
│ ├── mcp/ # MCP 协议
|
||||
│ ├── memory/ # 记忆系统(SOUL/USER/MEMORY/DAILY + RAG)
|
||||
|
|
@ -1430,6 +1545,9 @@ fischer-agentkit/
|
|||
│ ├── quality/ # 质量保障(对齐/级联检测/门控)
|
||||
│ ├── router/ # 意图路由
|
||||
│ ├── server/ # FastAPI 服务端 + Vue 3 前端
|
||||
│ │ ├── auth/ # 认证子系统(JWT + RBAC + 终端安全六层防护)
|
||||
│ │ ├── routes/ # 22 个路由模块(含 auth/terminal_server/llm_gateway/config_sync)
|
||||
│ │ └── frontend/ # Vue 3 SPA(含 LoginView/SystemMonitorPanel/WhitelistManager)
|
||||
│ ├── session/ # 会话管理
|
||||
│ ├── skills/ # 技能系统
|
||||
│ ├── telemetry/ # 遥测追踪
|
||||
|
|
|
|||
|
|
@ -0,0 +1,440 @@
|
|||
---
|
||||
title: "Fischer AgentKit 专家团演进路线图 v2 — 竞品差距分析与下一步方向"
|
||||
type: strategy
|
||||
status: active
|
||||
created: 2026-06-17
|
||||
updated: 2026-06-17
|
||||
origin: 竞品深度调研 + 代码库全量扫描
|
||||
version: 2
|
||||
---
|
||||
|
||||
# Fischer AgentKit 专家团演进路线图 v2
|
||||
|
||||
## Summary
|
||||
|
||||
Fischer AgentKit 定位为**通用 Agent 平台**,以 `@board`(群聊决策讨论)和 `@team`(流水线任务交付)构成双模式协作体系。基于对 10 个竞品的深度调研和代码库全量扫描,识别出当前核心问题是 `@team` 代码完整但完全未集成(死代码),且 `CollaborationPlan` 已被移除导致无法支持流水线编排。
|
||||
|
||||
v2 路线图相比 v1 的关键调整:专家模板从"50+"收缩为"少而精 5-8 个编程专家"(对标 Qoder/Cursor/Claude Code),隔离机制从"git worktree + 沙盒"降级为"上下文隔离"(对标 Trae Solo/Claude Code Subagent),编排模式从"hub-and-spoke"升级为"流水线模式"(对标 Qoder Team Lead + 阶段依赖)。新增 R0 修复死代码和前后端不一致。
|
||||
|
||||
`@board`(群聊决策讨论,保留名人专家)是唯一无竞品的能力,应保持和放大;`@team`(流水线任务交付,新增编程专家)是对标 Qoder 的核心补齐方向。两者构成"决策→执行"的完整闭环。
|
||||
|
||||
---
|
||||
|
||||
## Problem Frame
|
||||
|
||||
### 当前状态(代码库全量扫描结果)
|
||||
|
||||
Fischer AgentKit 专家团系统存在严重的"代码完整但未集成"问题:
|
||||
|
||||
| 模式 | 容器 | 编排器 | 路由器 | WebSocket 集成 | 实际状态 |
|
||||
|------|------|--------|--------|---------------|----------|
|
||||
| 群聊讨论 | `BoardTeam` | `BoardOrchestrator` | `BoardRouter` (`@board`) | ✅ 已集成 | 可用 |
|
||||
| 任务分解 | `ExpertTeam` | `TeamOrchestrator` | `ExpertTeamRouter` (`@team`) | ❌ 未集成 | **死代码** |
|
||||
|
||||
**核心问题清单**:
|
||||
|
||||
1. **`TeamOrchestrator` 从未被实例化** — `grep` 在 `src/agentkit/server` 中搜索 `TeamOrchestrator(` 返回零匹配,整个代码库中仅在 `__init__.py` 导出但无调用方
|
||||
2. **`CollaborationPlan` 已被移除** — 替换为简化的 `TeamPlan`(仅 `subtasks` 列表,无阶段依赖图),VOTE/FUSION 合并策略已删除仅保留 BEST
|
||||
3. **前后端数据模型不一致** — 前端 `team.ts` 仍用 `plan_phases` 概念,后端 `TeamPlan` 已改为 `subtasks`,一旦集成 `@team` 会立即暴露
|
||||
4. **集成测试已损坏** — `tests/integration/test_expert_team.py` 导入已移除的 `CollaborationPlan, ParallelType, PhaseStatus, PlanPhase`,会 ImportError
|
||||
5. **多模型路由未消费** — `ExpertConfig.llm` 字段存在但两个 Orchestrator 均硬编码 `model="default"`
|
||||
6. **AGENTS.md 文档过时** — 描述的"CostAwareRouter 三层路由"、"serial/parallel/competitive + merge"已与实际简化后的代码不符
|
||||
|
||||
### 竞品差距矩阵(10 个竞品深度调研)
|
||||
|
||||
| 能力维度 | Fischer AgentKit | Qoder | WorkBuddy | Trae Solo | Cursor 2.0 | Devin | Claude Code | Codex | 差距大小 |
|
||||
|----------|-----------------|-------|-----------|-----------|------------|-------|-------------|-------|---------|
|
||||
| 群聊讨论 | ✅ `@board` 独有 | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | **领先** |
|
||||
| 任务分解执行 | ⚠️ 死代码 | ✅ 流水线 | ✅ 多窗口 | ✅ 主子 | ✅ 8并行 | ✅ 舰队 | ✅ 委派 | ✅ 沙箱 | **大** |
|
||||
| 编排模式 | hub-and-spoke(未集成) | 流水线 | 多窗口并行 | 主子协同 | 纯并行 | 递归管理 | 委派+并行 | 计划→沙箱 | **大** |
|
||||
| 隔离机制 | ❌ 零实现 | 多Workspace | 沙箱 | 上下文隔离 | git worktree | 独立VM | 上下文+worktree | 沙箱 | **大** |
|
||||
| 专家模板数 | 9(名人) | 5类(编程) | 100+(办公) | 用户自定义 | 8并行 | 无上限 | 5内置+自定义 | 6角色插件 | **需定位** |
|
||||
| 多模型路由 | 配置存在未消费 | 4模式 | 多模型 | 按需选择 | 按阶段 | 单一 | 单一 | GPT-5.5 | **中** |
|
||||
| 仓库级理解 | ❌ | 10万+文件 | — | ✅ | ✅ | ✅ Wiki | ✅ | ✅ | **大** |
|
||||
| 自进化系统 | ✅ 遗传+AB | ✅ Expert+Team Skill | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | **缩小** |
|
||||
| 记忆系统 | ✅ 4层+pgvector | ✅ 记忆感知 | — | ✅ 智能压缩 | — | ✅ Wiki | ✅ Session | ✅ Memory | **缩小** |
|
||||
| MCP 支持 | ✅ 完整 | ❌ | ✅ | — | ✅ | — | ✅ 标准 | ✅ | **持平** |
|
||||
| IDE 集成 | Web+Tauri | IDE+JetBrains+VS Code | 桌面 | IDE | VS Code fork | Web+Desktop | CLI+VS Code | Web+CLI+IDE | **中** |
|
||||
|
||||
### 三个独特优势(应保持和放大)
|
||||
|
||||
1. **私董会群聊讨论** — 10 个竞品中唯一支持多专家群聊式决策讨论的能力。`@board` 前缀触发,多轮自主循环,主持人小结,用户可干预。无任何竞品实现此模式。
|
||||
2. **自进化系统(但优势在缩小)** — 遗传算法优化 prompt + A/B 测试 + 坑点检测 + 路径优化。Qoder 已跟进 Expert Skill + Team Skill 双向进化,需重新定位差异化(见 R4)。
|
||||
3. **4 层记忆系统(但优势在缩小)** — SOUL/USER/MEMORY/DAILY + pgvector 情节记忆 + 语义检索。竞品(Qoder 记忆感知、Claude Session Memory、Codex Memory)正在跟进,需深化场景化记忆。
|
||||
|
||||
### 三个关键差距(需优先补齐)
|
||||
|
||||
1. **`@team` 死代码 + 编排模式不足** — 代码完整但从未调用,且现有 hub-and-spoke 不支持流水线编排。是投入产出比最高的改进。
|
||||
2. **无隔离机制** — 缺乏任何形式的隔离(worktree/容器/上下文),多 Agent 并行会互相干扰。选择上下文隔离作为最轻量方案。
|
||||
3. **编程专家模板缺失** — 当前 9 个均为名人专家(用于 @board),无编程领域专家(用于 @team 流水线)。
|
||||
|
||||
### 行业趋势(影响演进方向)
|
||||
|
||||
1. **从 Agentic Coding 到 Agentic Engineering** — 行业从"AI 帮你写代码"转向"AI 帮你交付软件",Qoder 1.0、Devin、Trae SOLO 都体现这一范式切换
|
||||
2. **Human on the Loop 成共识** — 从 Human in the Loop(人在循环中)转向 Human on the Loop(人在循环上)— 人负责定义、驾驭、判断,Agent 负责执行
|
||||
3. **MCP 成事实标准** — Anthropic 的 Model Context Protocol 获得 OpenAI、Google、Meta 联合支持,Fischer 已支持,不再是差异化优势
|
||||
4. **记忆与进化能力成标配** — 竞品普遍跟进,Fischer 需从"有"升级为"深"
|
||||
|
||||
---
|
||||
|
||||
## Requirements
|
||||
|
||||
### R0: 修复死代码与前后端不一致
|
||||
|
||||
**目标**:在集成 `@team` 之前,修复代码库中已存在的死代码、损坏测试和前后端不一致问题,确保集成工作建立在干净的基线上。
|
||||
|
||||
**需求**:
|
||||
- 修复 `tests/integration/test_expert_team.py` 中对已移除类的导入(`CollaborationPlan, ParallelType, PhaseStatus, PlanPhase`)
|
||||
- 统一前后端数据模型:前端 `team.ts` 和 `ExpertTeamView.vue` 从 `plan_phases` 迁移到 `subtasks`(或后端恢复阶段概念,取决于 R1 决策)
|
||||
- 更新 `AGENTS.md` 中过时的描述:CostAwareRouter 三层路由 → RequestPreprocessor、serial/parallel/competitive → hub-and-spoke
|
||||
- 清理 `TeamOrchestrator` 的死代码状态:要么集成(R1),要么标记为实验性
|
||||
|
||||
**验收标准**:
|
||||
- `pytest tests/integration/test_expert_team.py` 不再 ImportError
|
||||
- 前后端数据模型一致
|
||||
- `AGENTS.md` 与代码实际状态一致
|
||||
|
||||
**关联文件**:
|
||||
- `tests/integration/test_expert_team.py` — 修复导入
|
||||
- `src/agentkit/server/frontend/src/stores/team.ts` — 数据模型迁移
|
||||
- `src/agentkit/server/frontend/src/components/chat/ExpertTeamView.vue` — 数据模型迁移
|
||||
- `AGENTS.md` — 文档更新
|
||||
|
||||
---
|
||||
|
||||
### R1: `@team` 流水线模式集成到 WebSocket
|
||||
|
||||
**目标**:将 `ExpertTeamRouter` + 重新实现的流水线 `TeamOrchestrator` + `ExpertTeam` 集成到 `chat.py` WebSocket 流程,支持阶段依赖的串行/并行混合编排,对标 Qoder 的 Team Lead + 流水线协作。
|
||||
|
||||
**需求**:
|
||||
|
||||
**R1.1 流水线编排引擎**:
|
||||
- 恢复阶段依赖图(`CollaborationPlan` 或等效设计),支持:
|
||||
- 串行阶段:规划 → 前端 → 后端 → QA → 评审(有依赖关系)
|
||||
- 并行阶段:同一阶段内无依赖的子任务并行执行
|
||||
- 阶段间数据传递:前一阶段输出作为后一阶段输入
|
||||
- Lead Expert 负责任务分解和阶段规划
|
||||
- 保留 BEST 合并策略用于并行子任务结果汇总
|
||||
|
||||
**R1.2 上下文隔离**:
|
||||
- 每个子任务在独立上下文窗口运行,不继承主对话历史
|
||||
- 子任务间通过 `SharedWorkspace` 共享中间产物(代码片段、文档、测试结果)
|
||||
- 子任务完成后,结果汇总到 Lead Expert 的上下文
|
||||
|
||||
**R1.3 WebSocket 集成**:
|
||||
- 用户输入 `@team 任务描述` 时,通过 `ExpertTeamRouter.resolve()` 解析专家列表和任务
|
||||
- 创建 `ExpertTeam`,注册 handoff handler,广播 `team_formed` 事件
|
||||
- 流水线执行驱动:Lead 分解 → 阶段串行 → 阶段内并行 → 结果汇总
|
||||
- WebSocket 事件流:`team_formed` → `plan_update`(阶段计划)→ `expert_step`(每个子任务)→ `expert_result` → `phase_started`/`phase_completed` → `team_synthesis` → `team_dissolved`
|
||||
- 失败时 fallback 到单 Agent REACT 模式(已有 `_fallback_to_single_agent`)
|
||||
|
||||
**R1.4 路由支持**:
|
||||
- 支持 `@team 任务描述` → 使用默认编程团队
|
||||
- 支持 `@team:frontend_engineer,backend_engineer 任务` → 显式指定专家
|
||||
- 支持 `@team:dev_team 任务` → 调用整组开发团队
|
||||
|
||||
**验收标准**:
|
||||
- `@team 开发用户登录功能` 能触发流水线:规划 → 前端 → 后端 → QA → 评审
|
||||
- 前端 `ExpertTeamView.vue` 正确显示阶段计划、当前阶段、专家步骤
|
||||
- `TEAM_COLLAB` 执行模式不再 fallback 到 REACT
|
||||
- 子任务在独立上下文执行,互不污染
|
||||
- 与 `@board` 互不干扰,用户可自由切换
|
||||
|
||||
**关联文件**:
|
||||
- `src/agentkit/server/routes/chat.py` — 新增 `_execute_team_collab` 函数(参照 `_execute_board_meeting` 模式)
|
||||
- `src/agentkit/experts/router.py` — `ExpertTeamRouter`(已实现,需集成)
|
||||
- `src/agentkit/experts/orchestrator.py` — `TeamOrchestrator`(需重写为流水线模式)
|
||||
- `src/agentkit/experts/plan.py` — `TeamPlan`(需恢复阶段依赖图)
|
||||
- `src/agentkit/experts/team.py` — `ExpertTeam`(已实现,需适配流水线)
|
||||
- `src/agentkit/server/frontend/src/components/chat/ExpertTeamView.vue` — 前端组件(需适配阶段展示)
|
||||
|
||||
---
|
||||
|
||||
### R2: 新增 5-8 个编程领域专家模板
|
||||
|
||||
**目标**:为 `@team` 流水线模式新增 5-8 个编程领域核心专家,对标 Qoder 的 5 类专家(Team Lead + 前端/后端/QA/评审),与 `@board` 的名人专家形成区分。
|
||||
|
||||
**需求**:
|
||||
- 新增编程专家模板(YAML 格式,存放 `configs/experts/`):
|
||||
1. **tech_lead** — 技术负责人:任务分解、架构决策、代码审查、阶段协调
|
||||
2. **frontend_engineer** — 前端工程师:UI 实现、组件开发、样式编写、前端测试
|
||||
3. **backend_engineer** — 后端工程师:API 设计、服务逻辑、数据库操作、后端测试
|
||||
4. **qa_engineer** — QA 工程师:测试用例设计、自动化测试、缺陷验证、质量报告
|
||||
5. **code_reviewer** — 代码审查员:代码规范、安全漏洞、性能问题、最佳实践
|
||||
6. **devops_engineer** — DevOps 工程师:CI/CD、部署配置、基础设施、监控(可选)
|
||||
7. **security_expert** — 安全专家:安全审计、漏洞扫描、加密设计(可选)
|
||||
8. **database_expert** — 数据库专家:Schema 设计、查询优化、数据迁移(可选)
|
||||
- 每个专家配置:`persona`、`thinking_style`、`speaking_style`、`bound_skills`(绑定工具)、`avatar`、`color`
|
||||
- 新增 `dev_team.yaml` 组合模板,包含默认编程团队成员列表
|
||||
- 专家模板可通过 API 动态注册(已有 `ExpertTemplateRegistry.register()`)
|
||||
|
||||
**验收标准**:
|
||||
- `configs/experts/` 目录包含 5-8 个编程专家 YAML + 1 个 `dev_team.yaml`
|
||||
- `@team:frontend_engineer,backend_engineer 开发用户登录功能` 能调用对应专家
|
||||
- `@team:dev_team 任务` 能调用默认编程团队
|
||||
- 前端 `MentionDropdown.vue` 支持按领域筛选专家(名人专家 vs 编程专家)
|
||||
|
||||
**关联文件**:
|
||||
- `configs/experts/` — 新增专家模板 YAML
|
||||
- `src/agentkit/experts/registry.py` — `ExpertTemplateRegistry`(已实现,无需修改)
|
||||
- `src/agentkit/server/frontend/src/components/chat/MentionDropdown.vue` — 专家筛选(需适配)
|
||||
|
||||
---
|
||||
|
||||
### R3: 上下文隔离机制
|
||||
|
||||
**目标**:为 `@team` 流水线子任务提供上下文级隔离,对标 Trae Solo 的独立上下文环境和 Claude Code Subagent 的上下文隔离。子任务在独立上下文窗口运行,不继承主对话历史,避免上下文污染。
|
||||
|
||||
**需求**:
|
||||
- **上下文隔离**:
|
||||
- 每个子任务创建独立的 `ConfigDrivenAgent` 实例,不共享对话历史
|
||||
- 子任务的 LLM 调用使用独立上下文窗口,仅包含:任务描述 + 专家 persona + 前置阶段输出
|
||||
- 子任务间通过 `SharedWorkspace` 传递中间产物(代码片段、文档、测试结果)
|
||||
- **SharedWorkspace 强化**:
|
||||
- 复用现有 `SharedWorkspace`(Redis 后端 + 内存回退)
|
||||
- 新增约定 key:`{task_id}/phase/{phase_id}/output` 存储阶段输出
|
||||
- 新增约定 key:`{task_id}/artifacts/{artifact_name}` 存储代码片段、文档等
|
||||
- Lead Expert 可读取所有阶段的输出用于汇总
|
||||
- **配置化**:
|
||||
- `agentkit.yaml` 中配置隔离模式:`context`(默认)/ `none`(调试用)
|
||||
- 上下文窗口大小可配置(默认遵循 LLM Provider 限制)
|
||||
|
||||
**验收标准**:
|
||||
- `@team` 子任务在独立上下文执行,主对话历史不泄漏到子任务
|
||||
- 阶段间数据通过 `SharedWorkspace` 正确传递
|
||||
- Lead Expert 能读取所有阶段输出进行汇总
|
||||
- 子任务失败不影响其他子任务的上下文
|
||||
|
||||
**关联文件**:
|
||||
- `src/agentkit/experts/orchestrator.py` — 子任务上下文创建
|
||||
- `src/agentkit/core/shared_workspace.py` — `SharedWorkspace`(已实现,需强化约定 key)
|
||||
- `src/agentkit/core/config_driven.py` — `ConfigDrivenAgent`(已实现,需确保独立实例)
|
||||
|
||||
---
|
||||
|
||||
### R4: 多模型智能路由
|
||||
|
||||
**目标**:按任务类型和专家角色自动选择最优 LLM 模型,对标 Qoder 的 4 模式和 Trae 的多模型选择。当前 `ExpertConfig.llm` 字段已预留但 Orchestrator 硬编码 `model="default"`,需消费该字段。
|
||||
|
||||
**需求**:
|
||||
- **专家级模型配置**:
|
||||
- 每个 `ExpertConfig` 可配置 `llm.provider` 和 `llm.model`
|
||||
- Orchestrator 读取 `ExpertConfig.llm` 而非硬编码 `model="default"`
|
||||
- 示例:`tech_lead` 用 Claude Opus(推理强)、`frontend_engineer` 用 DeepSeek-Coder(代码强)、`qa_engineer` 用 GPT-4o(均衡)
|
||||
- **任务类型路由**:
|
||||
- 编程任务 → 代码能力强的模型(如 Claude Sonnet/Opus、DeepSeek-Coder)
|
||||
- 对话任务 → 响应快的模型(如 GPT-4o-mini、Doubao-Lite)
|
||||
- 推理任务 → 推理能力强的模型(如 Claude Opus、o4-mini)
|
||||
- 中文任务 → 中文优化模型(如 Doubao、Wenxin、Qwen)
|
||||
- **路由策略**:
|
||||
- 基于 `ExpertConfig.llm` + 任务关键词 + 用户配置偏好
|
||||
- 支持用户在 `agentkit.yaml` 中自定义路由规则
|
||||
- 支持 `@team:expert --model=claude-opus 任务` 显式指定模型
|
||||
- **成本控制**:
|
||||
- 每次路由决策记录模型选择原因
|
||||
- 月度成本报告按任务类型/模型维度统计
|
||||
- 超预算时自动降级到更便宜的模型
|
||||
- **Fallback 链**:
|
||||
- 首选模型不可用时自动 fallback(已有 `_get_models_to_try`)
|
||||
- Fallback 决策记录到 usage tracking
|
||||
|
||||
**验收标准**:
|
||||
- 编程专家自动使用代码能力强的模型
|
||||
- 用户可自定义路由规则
|
||||
- 成本报告展示模型选择分布
|
||||
- Fallback 链正常工作
|
||||
- `board_orchestrator.py` 和 `orchestrator.py` 不再硬编码 `model="default"`
|
||||
|
||||
**关联文件**:
|
||||
- `src/agentkit/experts/orchestrator.py` — 读取 `ExpertConfig.llm`(移除硬编码)
|
||||
- `src/agentkit/experts/board_orchestrator.py` — 读取 `ExpertConfig.llm`(移除硬编码)
|
||||
- `src/agentkit/llm/gateway.py` — `LLMGateway`(已实现,无需修改)
|
||||
- `configs/experts/*.yaml` — 专家模板新增 `llm` 配置
|
||||
|
||||
---
|
||||
|
||||
### R5: 仓库级代码理解
|
||||
|
||||
**目标**:支持 10 万+ 文件级上下文检索和跨模块语义理解,对标 Qoder 的 Repo Wiki 和 Devin 的 Wiki。为 `@team` 编程任务提供代码上下文。
|
||||
|
||||
**需求**:
|
||||
- **代码索引**:
|
||||
- 对项目代码库建立向量索引(复用 pgvector)
|
||||
- 支持函数级、类级、文件级粒度索引
|
||||
- 增量索引:文件变更时自动更新索引
|
||||
- **语义检索**:
|
||||
- Agent 可通过 `code_search("用户认证逻辑")` 检索相关代码
|
||||
- 返回代码片段 + 文件路径 + 上下文
|
||||
- 支持自然语言查询和代码片段查询
|
||||
- **架构图谱**:
|
||||
- 自动生成项目架构图(模块依赖、调用关系)
|
||||
- 前端可视化展示(复用 `WorkflowView.vue` 的图编辑器)
|
||||
- **Repo Wiki**:
|
||||
- 自动生成项目文档(README、API 文档、架构说明)
|
||||
- 代码变更时自动更新文档
|
||||
|
||||
**验收标准**:
|
||||
- `@team 重构用户认证模块` 能自动检索相关代码
|
||||
- 架构图正确展示模块依赖关系
|
||||
- Repo Wiki 自动生成并可浏览
|
||||
|
||||
**关联文件**:
|
||||
- `src/agentkit/memory/episodic.py` — `EpisodicMemory`(pgvector 可复用)
|
||||
- `src/agentkit/tools/` — 新增 `code_search` 工具
|
||||
- `src/agentkit/server/frontend/src/components/` — 架构图可视化
|
||||
|
||||
---
|
||||
|
||||
### R6: IDE 插件(VS Code 扩展)
|
||||
|
||||
**目标**:将 Fischer AgentKit 能力嵌入 VS Code 编辑器,对标 Cursor 的原生 IDE 体验和 Qoder 的 JetBrains 插件。
|
||||
|
||||
**需求**:
|
||||
- **VS Code 扩展**:
|
||||
- 侧边栏聊天面板(复用 Web GUI 的 Vue 组件)
|
||||
- 内联代码补全和建议
|
||||
- 选中代码 → 右键 → "发送给专家团"
|
||||
- `@team` 和 `@board` 前缀路由在 IDE 内可用
|
||||
- **上下文感知**:
|
||||
- 自动获取当前打开的文件、光标位置、选中文本作为上下文
|
||||
- 诊断信息(lint 错误、类型错误)自动传递给 Agent
|
||||
- 工作区符号搜索
|
||||
- **操作集成**:
|
||||
- Agent 可直接编辑文件(通过 VS Code WorkspaceEdit API)
|
||||
- 终端命令执行(通过 VS Code Terminal API)
|
||||
- Git 操作(通过 VS Code Git API)
|
||||
- **Tauri 桌面端保留**:
|
||||
- 现有 Tauri 桌面应用继续维护
|
||||
- VS Code 扩展作为轻量级替代方案
|
||||
|
||||
**验收标准**:
|
||||
- VS Code 扩展可安装和使用
|
||||
- `@team` 和 `@board` 在 IDE 内正常工作
|
||||
- Agent 可编辑文件和执行终端命令
|
||||
- 选中文本可发送给专家团
|
||||
|
||||
---
|
||||
|
||||
## Scope Boundaries
|
||||
|
||||
### 本次范围内
|
||||
|
||||
- R0: 修复死代码与前后端不一致 — 最高优先级(基线修复)
|
||||
- R1: `@team` 流水线集成 — 最高优先级(核心功能补齐)
|
||||
- R2: 5-8 个编程专家模板 — 高优先级(场景覆盖)
|
||||
- R3: 上下文隔离 — 中优先级(轻量隔离)
|
||||
- R4: 多模型路由 — 中优先级(成本优化)
|
||||
- R5: 仓库级理解 — 低优先级(长期)
|
||||
- R6: IDE 插件 — 低优先级(长期)
|
||||
|
||||
### 延后到后续迭代
|
||||
|
||||
- **git worktree 隔离**:v1 路线图中的 worktree 方案延后,v2 选择上下文隔离作为更轻量的方案。若未来需要文件系统级隔离再评估
|
||||
- **沙盒/容器隔离**:延后,需 Docker 环境,当前上下文隔离已满足需求
|
||||
- **实时协作**:多用户共享 Agent 会话(对标 Google Docs 协作模式)
|
||||
- **Agent 市场**:用户分享和下载专家模板/Skill 配置
|
||||
- **语音交互**:语音输入和输出(对标 ChatGPT Voice、Cursor Voice Mode)
|
||||
- **工作流模板库**:预置行业工作流模板(如"电商运营全流程")
|
||||
- **企业级权限**:RBAC、审计日志、SSO 集成
|
||||
|
||||
### 不在产品身份内
|
||||
|
||||
- **纯代码补全**:不做 GitHub Copilot 式的行内补全,聚焦 Agent 级任务
|
||||
- **模型训练/微调**:不训练自有模型,保持模型无关性
|
||||
- **CI/CD 流水线**:不做持续集成/部署系统,与现有 DevOps 工具集成
|
||||
- **100+ 专家模板**:不走 WorkBuddy 的多而广路线,聚焦少而精
|
||||
|
||||
---
|
||||
|
||||
## Dependencies / Assumptions
|
||||
|
||||
1. **R0 依赖**:无外部依赖,纯代码修复
|
||||
2. **R1 依赖**:R0 完成(基线干净);`ExpertTeamRouter` 代码已完整;需重新实现流水线 `TeamOrchestrator` 和阶段依赖图
|
||||
3. **R2 依赖**:`ExpertTemplateRegistry` 已支持动态注册,扩展工作主要是 YAML 编写
|
||||
4. **R3 依赖**:`SharedWorkspace` 已实现(Redis 后端 + 内存回退);`ConfigDrivenAgent` 支持独立实例
|
||||
5. **R4 依赖**:`LLMGateway` 已支持 6 个 Provider 和 fallback 链;`ExpertConfig.llm` 字段已预留
|
||||
6. **R5 依赖**:pgvector 已用于情节记忆,可复用;需要新增代码解析和索引模块
|
||||
7. **R6 依赖**:VS Code 扩展 API + 现有 Web GUI 组件可复用
|
||||
|
||||
**假设**:
|
||||
- 用户有可用的 LLM API 密钥(至少一个 Provider)
|
||||
- 项目代码库规模在 10 万文件以内(R5 的索引能力上限)
|
||||
- Redis 可用于 `SharedWorkspace`(R3 的上下文共享)
|
||||
|
||||
---
|
||||
|
||||
## Success Criteria
|
||||
|
||||
### R0 成功标准
|
||||
- `pytest tests/integration/test_expert_team.py` 不再 ImportError
|
||||
- 前后端数据模型一致(`subtasks` 或恢复 `plan_phases`,取决于 R1 决策)
|
||||
- `AGENTS.md` 与代码实际状态一致
|
||||
|
||||
### R1 成功标准
|
||||
- `@team` 流水线在 WebSocket 中端到端可用
|
||||
- 前端正确显示阶段计划、当前阶段、专家步骤
|
||||
- 与 `@board` 互不干扰,用户可自由切换
|
||||
- 子任务在独立上下文执行,互不污染
|
||||
|
||||
### 整体成功标准
|
||||
- 编程专家模板数 5-8 个(R2)
|
||||
- `@team` 子任务在上下文隔离环境中执行(R3)
|
||||
- 编程任务自动选择代码模型,成本降低 30%+(R4)
|
||||
- 10 万文件项目代码检索延迟 < 500ms(R5)
|
||||
- VS Code 扩展日活用户 > 100(R6)
|
||||
|
||||
### 差距缩小目标
|
||||
|
||||
| 能力维度 | 当前 | 目标 | 对标 |
|
||||
|----------|------|------|------|
|
||||
| 任务分解执行 | ⚠️ 死代码 | ✅ 流水线可用 | Qoder |
|
||||
| 编排模式 | hub-and-spoke(未集成) | ✅ 流水线+并行 | Qoder |
|
||||
| 隔离机制 | ❌ | ✅ 上下文隔离 | Trae Solo/Claude Code |
|
||||
| 编程专家模板 | 0 | 5-8 个 | Qoder 5类 |
|
||||
| 模型智能路由 | ❌ 硬编码 | ✅ 按专家路由 | Qoder/Trae |
|
||||
| 仓库级理解 | ❌ | ✅ 10万文件 | Qoder/Devin |
|
||||
| IDE 集成 | Web+Tauri | +VS Code 扩展 | Cursor/Qoder |
|
||||
| 群聊讨论 | ✅ 独有 | ✅ 保持领先 | 无竞品 |
|
||||
| 自进化 | ✅ 遗传+AB | ✅ 深化场景化 | Qoder Expert+Team Skill |
|
||||
| 记忆系统 | ✅ 4层 | ✅ 场景化深化 | Qoder/Claude/Codex |
|
||||
|
||||
---
|
||||
|
||||
## Implementation Priority
|
||||
|
||||
| 优先级 | 需求 | 预期收益 | 复杂度 | 变化(vs v1) |
|
||||
|--------|------|---------|--------|--------------|
|
||||
| P0 | R0: 修复死代码+不一致 | 干净基线 | 低 | **新增** |
|
||||
| P0 | R1: `@team` 流水线集成 | 核心功能可用 | 高 | 范围扩大(需重写编排器) |
|
||||
| P1 | R2: 5-8 个编程专家 | 场景覆盖 | 中 | 从 50+ 收缩为少而精 |
|
||||
| P1 | R3: 上下文隔离 | 子任务隔离 | 中 | 从 worktree+沙盒 降级 |
|
||||
| P2 | R4: 多模型路由 | 成本优化 30%+ | 中 | 保持 |
|
||||
| P2 | R5: 仓库级理解 | 代码任务质量 | 高 | 保持(长期) |
|
||||
| P3 | R6: IDE 插件 | 用户入口扩展 | 高 | 保持(长期) |
|
||||
|
||||
---
|
||||
|
||||
## Competitive Positioning
|
||||
|
||||
### Fischer AgentKit 的差异化策略
|
||||
|
||||
**核心差异化(无竞品)**:
|
||||
- `@board` 群聊决策讨论 — 10 个竞品中唯一支持多专家群聊式决策讨论
|
||||
|
||||
**对标差异化(有竞品但独特组合)**:
|
||||
- `@board`(决策)+ `@team`(执行)双模式闭环 — 竞品要么只有执行(Qoder/Cursor/Devin),要么只有单模式
|
||||
- 自进化系统(遗传算法+AB测试)— 比 Qoder 的 Expert Skill + Team Skill 更深层,但需深化场景化
|
||||
- 4 层记忆系统(SOUL/USER/MEMORY/DAILY+pgvector)— 比竞品的记忆能力更结构化,但需场景化
|
||||
|
||||
**非差异化(持平)**:
|
||||
- MCP 支持 — 已成事实标准
|
||||
- 多 Provider LLM Gateway — 竞品普遍支持
|
||||
- 工具系统 — 竞品普遍支持
|
||||
|
||||
**需补齐(落后)**:
|
||||
- `@team` 任务分解执行 — 死代码,需集成
|
||||
- 流水线编排模式 — 需实现
|
||||
- 上下文隔离 — 需实现
|
||||
- 编程专家模板 — 需新增
|
||||
- 多模型路由 — 需消费已有字段
|
||||
- 仓库级理解 — 需实现(长期)
|
||||
|
|
@ -0,0 +1,392 @@
|
|||
# Fischer AgentKit — 聊天主区 VI 重梳与新功能适配 (Requirements)
|
||||
|
||||
**Date:** 2026-06-18
|
||||
**Status:** Draft
|
||||
**Scope:** 聊天主区布局 + 右屏可收起 Tab 面板 + @team / @board 消息适配
|
||||
**Out of scope:** 侧边栏 (ChatSidebar)、顶部 (TopNav)、抽屉 (右抽屉)、移动端布局
|
||||
|
||||
---
|
||||
|
||||
## 1. Context & Problem
|
||||
|
||||
### 1.1 现状
|
||||
|
||||
- 主色 `#6366f1` (indigo) —— Notion 风格暖灰基底,但品牌感弱,与「Fischer」德系工业严谨感的名字不匹配
|
||||
- 聊天主区是简单的气泡 + 流式步骤,缺少对**复杂 AI 响应**(@team 计划、@board 多轮讨论、工具调用链、错误恢复)的精细化表达
|
||||
- 新功能(@board 私董会、@team 专家团)已有后端事件流(`board_started` / `expert_speech` / `round_summary` / `team_formed` / `plan_update` / `phase_started` ...),但前端在 `ChatMessage.vue` 中只是把它们当成普通消息流式渲染,**没有专属视觉表达**
|
||||
- 私董会当前色 `#8E44AD` 偏沉,与整体调性割裂
|
||||
|
||||
### 1.2 目标
|
||||
|
||||
- 形成 **「克制中性 + 强调色做语义信号」** 的 VI 体系 —— 文本是主体,色彩是信号
|
||||
- 重新设计聊天主区布局:常规气泡 + 复杂事件拆为 card(Cursor / Trae 范式)
|
||||
- 为 @team / @board / 工具调用 / 错误四类复杂事件提供专属 card 视觉语言
|
||||
- 保留右屏可收起 Tab 面板(监控 / 技能 / 系统 / 知识库),但样式与新 VI 一致
|
||||
- 产出一份**静态高保真 mockup**(独立 preview 路由),供 review 后再进入实施
|
||||
|
||||
### 1.3 设计哲学
|
||||
|
||||
> **Notion 的留白 + Cursor 的复杂事件 card + Trae 的可收起右屏 + 我们自己的中性克制**
|
||||
|
||||
- **Notion**:暖灰基底 (`#fbfbfa` / `#ededec`),柔和阴影 (low-opacity, more spread),暖色代码高亮 (Catppuccin Mocha)
|
||||
- **Cursor**:复杂事件(agent 计划、工具调用、错误)拆为独立 card,不挤在普通消息流中
|
||||
- **Trae**:右屏 Tab 面板可收起,主区最大化
|
||||
- **我们自己的**:**主色由 indigo 改为近黑 + 中性灰**,强调色 (accent) 仅用于语义信号
|
||||
|
||||
---
|
||||
|
||||
## 2. Design Language Specification
|
||||
|
||||
### 2.1 Color System (Light Theme)
|
||||
|
||||
| Token | Value | 用途 |
|
||||
|---|---|---|
|
||||
| `--bg-primary` | `#ffffff` | 主背景 (消息区、card) |
|
||||
| `--bg-secondary` | `#fbfbfa` | 次背景 (整体 app shell、输入框) |
|
||||
| `--bg-tertiary` | `#f7f7f5` | 三级背景 (hover、嵌套 card) |
|
||||
| `--bg-code` | `#1e1e2e` | 代码块背景 (Catppuccin Mocha) |
|
||||
| `--text-primary` | `#1a1a1a` | 主文字 (95% 文本) |
|
||||
| `--text-secondary` | `#4a4a4a` | 次文字 (元数据、辅助) |
|
||||
| `--text-tertiary` | `#6b6b6a` | 三级文字 (时间戳、状态) |
|
||||
| `--text-placeholder` | `#9b9b9a` | 占位符 |
|
||||
| `--border-color` | `#ededec` | 默认边框 |
|
||||
| `--border-color-hover` | `#dfdfde` | hover 边框 |
|
||||
|
||||
**强调色 (新增):**
|
||||
|
||||
| Token | Value | 语义 | 使用场景 |
|
||||
|---|---|---|---|
|
||||
| `--accent-team` | `#3b82f6` | 专家团协作 (中性蓝) | @team plan card、expert avatar、team 状态条 |
|
||||
| `--accent-board` | `#a855f7` | 私董会讨论 (琥珀/紫) | @board banner、moderator 头像、board round 进度 |
|
||||
| `--accent-team-soft` | `#dbeafe` | 蓝轻量背景 | @team card 顶条、chip |
|
||||
| `--accent-board-soft` | `#f3e8ff` | 紫轻量背景 | @board card 顶条、moderator chip |
|
||||
|
||||
**语义色 (保留):**
|
||||
|
||||
| Token | Value | 语义 |
|
||||
|---|---|---|
|
||||
| `--color-success` | `#22c55e` | 工具调用成功、阶段 completed |
|
||||
| `--color-warning` | `#f59e0b` | 主持人小结 (round summary) |
|
||||
| `--color-error` | `#ef4444` | 错误、阶段 failed |
|
||||
| `--color-info` | `#3b82f6` | 普通信息 |
|
||||
|
||||
> 关键变化:**移除 `--color-primary` (indigo) 作为通用主色**,改用 `--accent-team` / `--accent-board` 作为功能语义色。Ant Design 的 `colorPrimary` token 改为 `#1a1a1a` (近黑),让通用 UI (按钮、菜单选中) 都是中性。
|
||||
|
||||
### 2.2 Color System (Dark Theme)
|
||||
|
||||
| Token | Value |
|
||||
|---|---|
|
||||
| `--bg-primary` | `#1a1a1a` |
|
||||
| `--bg-secondary` | `#1f1f1f` |
|
||||
| `--bg-tertiary` | `#2a2a2a` |
|
||||
| `--text-primary` | `#fbfbfa` |
|
||||
| `--text-secondary` | `#cececd` |
|
||||
| `--accent-team` | `#60a5fa` (亮蓝) |
|
||||
| `--accent-team-soft` | `rgba(59, 130, 246, 0.15)` |
|
||||
| `--accent-board` | `#c084fc` (亮紫) |
|
||||
| `--accent-board-soft` | `rgba(168, 85, 247, 0.15)` |
|
||||
|
||||
### 2.3 Typography
|
||||
|
||||
- 主字体:现有 `font-family` 不变(系统字体栈)
|
||||
- 字体大小:保留现有 token (`--font-xs` ~ `--font-xl`)
|
||||
- **新增**:`--font-mono` (代码字体, SF Mono / Monaco / Consolas)
|
||||
- **新增**:`--leading-message: 1.65` —— 消息正文行高(比现有 `--leading-normal: 1.5` 更松,提升长文阅读体验)
|
||||
|
||||
### 2.4 Spacing & Layout
|
||||
|
||||
- 间距:保留现有 4/8/12/16/20/24/32 scale
|
||||
- 圆角:保留现有 4/6/10/14 scale
|
||||
- **新增**:`--radius-card: 12px` (复杂 card 圆角,比 chat bubble 的 `--radius-lg: 10px` 略大)
|
||||
- **新增**:`--space-message-gap: 24px` (消息之间间距)
|
||||
|
||||
### 2.5 Shadow
|
||||
|
||||
- 保留现有 4 档 shadow (sm/md/lg/xl)
|
||||
- **新增**:`--shadow-card: 0 1px 3px rgba(0,0,0,0.04), 0 0 0 1px rgba(0,0,0,0.04)` (复杂 card 边框+微阴影组合,Notion 风格)
|
||||
|
||||
---
|
||||
|
||||
## 3. Chat Main Area — Layout & Message Architecture
|
||||
|
||||
### 3.1 整体布局
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ TopNav (48px) │
|
||||
├──────────┬──────────────────────────────────┬───────────┤
|
||||
│ │ │ │
|
||||
│ Sidebar │ Chat Main Area │ Right │
|
||||
│ 280px │ (resizable) │ Panel │
|
||||
│ │ │ (320px) │
|
||||
│ │ ┌────────────────────────────┐ │ Tab: │
|
||||
│ │ │ BoardStatusView (顶部状态) │ │ - 监控 │
|
||||
│ │ ├────────────────────────────┤ │ - 技能 │
|
||||
│ │ │ │ │ - 系统 │
|
||||
│ │ │ Message List │ │ - 知识库 │
|
||||
│ │ │ (user / assistant 气泡) │ │ │
|
||||
│ │ │ + TeamPlanCard │ │ 可收起 → │
|
||||
│ │ │ + BoardRoundCard │ │ │
|
||||
│ │ │ + ToolCallCard │ │ │
|
||||
│ │ │ + ThinkingBlock │ │ │
|
||||
│ │ │ │ │ │
|
||||
│ │ ├────────────────────────────┤ │ │
|
||||
│ │ │ ChatInput (输入框) │ │ │
|
||||
│ │ └────────────────────────────┘ │ │
|
||||
└──────────┴──────────────────────────────────┴───────────┘
|
||||
```
|
||||
|
||||
- 主区最大宽度 `max-width: 860px` 居中(防止超宽屏阅读疲劳),左右侧贴边留白用 `--space-4`
|
||||
- 右屏默认展开 320px,可收起至 0(折叠按钮在主区右上角,IconNav 同步)
|
||||
- 消息垂直节奏:消息之间 `--space-message-gap: 24px`;气泡内部 padding `--space-3 var(--space-4)`
|
||||
|
||||
### 3.2 Message Types & Visual Treatment
|
||||
|
||||
| Type | Trigger | 视觉范式 | 颜色信号 | 关键元素 |
|
||||
|---|---|---|---|---|
|
||||
| **user** | `role: 'user'` | 60% 宽右对齐气泡 | 中性灰背景 `--bg-tertiary` | 文本 + 附件 pill + hover 显示「编辑/复制」 |
|
||||
| **assistant (text)** | `role: 'assistant'`, `message_type: 'chat'` | 90% 宽左对齐 flat (无气泡) | 无 | Markdown 渲染 + 代码高亮 + 引用块 |
|
||||
| **assistant (thinking)** | `thinking` 字段非空 | 折叠块 (Cursor 风格) | 灰色 `--text-tertiary` | 「已思考 N 秒」可点击展开 |
|
||||
| **tool call** | `tool_calls[]` 非空 | 嵌套 card (边框 + 阴影) | 中性 + 成功绿 / 错误红 | 工具名 + 状态点 (●) + 折叠参数/结果 |
|
||||
| **team plan** | `team_formed` / `plan_update` | 跨整行大 card | **蓝色顶条** (`--accent-team`) | Lead expert 头像 + 阶段时间线 + 状态图标 |
|
||||
| **team expert step** | `expert_step` | 内嵌 plan card 的子项 | 蓝 | expert 头像 + 当前步骤描述 |
|
||||
| **team expert result** | `expert_result` | 内嵌 plan card 的子项 | 蓝 | 折叠结果块 (类似 tool call) |
|
||||
| **team synthesis** | `team_synthesis` | 跨整行结论 card | 蓝 | 渐变顶条 + 总结文本 |
|
||||
| **board started** | `board_started` | 跨整行 banner card | **紫色顶条** (`--accent-board`) | 主题 + 专家 chips + 主持人标签 |
|
||||
| **board expert speech** | `expert_speech` | 90% 宽左对齐,带 expert 头像 chip | 紫 | 头像 + 第 N 轮 + 角色标签 (主持/专家) |
|
||||
| **board round summary** | `round_summary` | 主持人头像 + 总结文本 | 紫轻背景 `--accent-board-soft` | 折叠块 (默认收起避免噪声) |
|
||||
| **board conclusion** | `board_concluded` + `final_answer` | 跨整行结论 card | 紫渐变顶条 | 共识 + 分歧 + 决策建议 |
|
||||
| **error** | `error` | 跨整行 alert 样式 | 红色 `--color-error` | 错误信息 + 「重试」按钮 |
|
||||
|
||||
### 3.3 关键交互细节
|
||||
|
||||
**气泡 (常规):**
|
||||
- user 气泡 hover:显示「编辑」「复制」图标
|
||||
- assistant 文本 hover:底部浮现操作条「复制」「重新生成」「反馈 👍👎」
|
||||
- 流式光标:文本末尾 2px 宽闪烁竖线 (Cursor 风格) 或 8x8 圆点 (Claude 风格) —— **选竖线,更克制**
|
||||
- Markdown 实时渲染:流式过程中不渲染重型 syntax highlight,避免闪烁
|
||||
|
||||
**Thinking Block (新增):**
|
||||
- 默认收起,显示「已思考 N 秒」+ chevron
|
||||
- 展开后显示 thinking 文本 (灰色)
|
||||
- 动画:展开时 height transition 200ms
|
||||
|
||||
**Tool Call Card:**
|
||||
- 默认收起 (只显示工具名 + 状态点)
|
||||
- 点击展开:参数 (折叠) + 结果 (折叠)
|
||||
- 错误:状态点变红 + 行内错误信息
|
||||
- 成功:状态点变绿
|
||||
- 嵌套调用:缩进 16px,最多嵌套 3 层
|
||||
|
||||
**Team Plan Card (新增):**
|
||||
```
|
||||
┌──────────────────────────────────────────────────────┐
|
||||
│ ▌ Lead: Sarah Chen (技术负责人) [In Progress] │ ← 蓝色顶条 4px
|
||||
├──────────────────────────────────────────────────────┤
|
||||
│ 阶段 1: 数据模型设计 ● running │
|
||||
│ ├─ 张三 (后端) 正在分析 schema... │
|
||||
│ └─ 完成时间: ~2 min │
|
||||
│ │
|
||||
│ 阶段 2: API 实现 ○ pending (依赖 阶段1) │
|
||||
│ 阶段 3: 测试 ○ pending (依赖 阶段2) │
|
||||
├──────────────────────────────────────────────────────┤
|
||||
│ 进度: ████████░░░░░░ 45% 当前: 阶段 1 / 3 │
|
||||
└──────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Board Banner (新增):**
|
||||
```
|
||||
┌──────────────────────────────────────────────────────┐
|
||||
│ ▌ 🏛️ 私董会 — 主题:「是否应投入资源做这个功能」 │ ← 紫色顶条 4px
|
||||
├──────────────────────────────────────────────────────┤
|
||||
│ 专家: 🚀 Elon 🛒 Jeff 🧠 Charlie 💻 Paul │
|
||||
│ [主持人] Elion Musk │
|
||||
│ 轮次: 第 2 / 5 [████████░░░░] 40% │
|
||||
└──────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Board Round Card (单轮发言):**
|
||||
```
|
||||
┌──────────────────────────────────────────────────────┐
|
||||
│ 🚀 Elon Musk · 第 2 轮 · 主持人 │ ← 紫色边 1px 左侧
|
||||
│ │
|
||||
│ 我认为第一性原理是... │
|
||||
│ [长文本...] │
|
||||
│ 09:32 │
|
||||
└──────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Right Panel (Tab Panel)
|
||||
|
||||
### 4.1 行为
|
||||
|
||||
- 位置:主区右侧,初始 320px 宽
|
||||
- Tab:监控 / 技能 / 系统 / 知识库 (4 个,对应附件图)
|
||||
- 折叠按钮:主区右上角 (ChatView 顶部) 一个 chevron 按钮,折叠后宽度 0,折叠按钮变为「展开」chevron
|
||||
- 折叠状态记忆:localStorage 持久化
|
||||
- 拖拽:可选 (后续迭代,本期不做)
|
||||
|
||||
### 4.2 样式
|
||||
|
||||
- 背景:`--bg-secondary` (与主区背景区分但不抢戏)
|
||||
- 顶部 Tab bar:48px 高,左对齐文字 Tab,激活 Tab 下方 2px 蓝色 (或当前 Tab 主题色) 指示条
|
||||
- 内边距:`var(--space-3) var(--space-4)`
|
||||
- 卡片:与主区 card 一致 (12px 圆角 + 1px 边框)
|
||||
|
||||
### 4.3 Tab 内容 (沿用现有数据)
|
||||
|
||||
- **监控**:4 个 metric card (服务可用性 / 平均响应时间 / 请求总数 / 错误率) + 资源使用列表 + 服务状态列表 + 错误趋势图
|
||||
- **技能**:技能列表 (来自 `useSkillsStore`),点击展开详情
|
||||
- **系统**:系统信息 (CPU / 内存 / 磁盘)
|
||||
- **知识库**:知识库源列表 + 检索测试入口
|
||||
|
||||
---
|
||||
|
||||
## 5. Input Area
|
||||
|
||||
### 5.1 现状
|
||||
|
||||
- 输入框 + 发送按钮 + 模型选择 + (新增) 私董会按钮
|
||||
|
||||
### 5.2 改造
|
||||
|
||||
- **私董会按钮 → 重命名为「专家团」** 按钮,紫色 `--accent-board` 高亮
|
||||
- 新增「**@team** 团队」按钮 (蓝色 `--accent-team`),与私董会按钮并列
|
||||
- 两个按钮都点击打开对应 Modal (已有的 `BoardMeetingModal.vue` + 新建 `TeamMeetingModal.vue`)
|
||||
- 拖拽附件:拖入文件到输入框显示「拖放以附加」浮层
|
||||
- @mention 自动补全:现有 `MentionDropdown` 保留
|
||||
|
||||
### 5.3 入口设计
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────┐
|
||||
│ [📎 附件] [👥 专家团] [🏛️ 私董会] │
|
||||
├──────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ 输入消息... │
|
||||
│ │
|
||||
├──────────────────────────────────────────────────┤
|
||||
│ [模型: Claude 4 Sonnet ▾] [清空] [发送 →] │
|
||||
└──────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Components to Build / Modify
|
||||
|
||||
### 6.1 修改现有
|
||||
|
||||
| 文件 | 改动 |
|
||||
|---|---|
|
||||
| `src/agentkit/server/frontend/src/styles/tokens.css` | 新增 `--accent-team*` / `--accent-board*` / `--shadow-card` 等 token;移除 `--color-primary` 作为主色 (保留作为渐变背景) |
|
||||
| `src/agentkit/server/frontend/src/styles/theme.ts` | `colorPrimary` 改为近黑;新增 accent 主题组件配置 |
|
||||
| `src/agentkit/server/frontend/src/views/ChatView.vue` | 主区 max-width 居中 + 右屏折叠按钮 + 整体留白节奏调整 |
|
||||
| `src/agentkit/server/frontend/src/components/chat/ChatMessage.vue` | 重构分发:按 `message_type` 路由到对应子组件 |
|
||||
| `src/agentkit/server/frontend/src/components/chat/ExpertMessage.vue` | 适配新 VI |
|
||||
| `src/agentkit/server/frontend/src/components/chat/ToolCallCard.vue` | 已有,确认样式对齐新 token |
|
||||
| `src/agentkit/server/frontend/src/components/chat/ThinkingBlock.vue` | 已有,新增折叠动画 |
|
||||
| `src/agentkit/server/frontend/src/components/chat/BoardStatusView.vue` | 用 `--accent-board` 替换 `#8E44AD` |
|
||||
| `src/agentkit/server/frontend/src/components/chat/ExpertTeamView.vue` | 用 `--accent-team` 替换默认色 |
|
||||
| `src/agentkit/server/frontend/src/components/chat/ChatInput.vue` | 新增「专家团」按钮 (蓝色) + 重命名「私董会」按钮为紫色 |
|
||||
| `src/agentkit/server/frontend/src/components/chat/BoardMeetingModal.vue` | 适配新 VI |
|
||||
|
||||
### 6.2 新增组件
|
||||
|
||||
| 文件 | 用途 |
|
||||
|---|---|
|
||||
| `src/agentkit/server/frontend/src/components/chat/TeamPlanCard.vue` | @team 计划可视化 (时间线 + 阶段 + 进度) |
|
||||
| `src/agentkit/server/frontend/src/components/chat/BoardRoundCard.vue` | @board 单轮发言 (专家头像 + 轮次 + 文本) |
|
||||
| `src/agentkit/server/frontend/src/components/chat/BoardConclusionCard.vue` | @board 最终结论 (共识 + 分歧 + 建议) |
|
||||
| `src/agentkit/server/frontend/src/components/chat/ErrorCard.vue` | 错误提示 card (含重试按钮) |
|
||||
| `src/agentkit/server/frontend/src/components/chat/UserBubble.vue` | user 消息气泡 (提取自 ChatMessage) |
|
||||
| `src/agentkit/server/frontend/src/components/chat/AssistantBubble.vue` | assistant 消息 flat 块 (提取自 ChatMessage) |
|
||||
| `src/agentkit/server/frontend/src/components/chat/TeamMeetingModal.vue` | @team 专家团选择 Modal (对标 BoardMeetingModal) |
|
||||
| `src/agentkit/server/frontend/src/components/layout/RightPanel.vue` | 可折叠右屏 Tab 面板 |
|
||||
| `src/agentkit/server/frontend/src/views/ChatPreviewView.vue` | 静态高保真 mockup 页面 (供 review) |
|
||||
|
||||
### 6.3 Mockup 页面 (Preview View)
|
||||
|
||||
6 个静态场景,**全部用假数据**:
|
||||
|
||||
1. **欢迎页** —— 居中 logo + 4 个能力提示 + 输入框预填「@team:tech_lead,frontend_engineer,backend_engineer 设计一个用户认证系统」
|
||||
2. **User 消息 + Assistant 文本 + 代码块** —— user 提问,assistant 流式响应含 markdown + 代码
|
||||
3. **@team 计划 + 阶段进行中** —— TeamPlanCard 显示 3 阶段,第 1 阶段 running,专家子项展开
|
||||
4. **@board 多轮讨论** —— BoardBanner + 3 轮不同专家发言 + 1 个主持人小结 + 1 个最终结论
|
||||
5. **工具调用链** —— 嵌套 2 层 tool call,状态点变化
|
||||
6. **错误态** —— ErrorCard + 「重试」按钮
|
||||
|
||||
---
|
||||
|
||||
## 7. Dependencies & Assumptions
|
||||
|
||||
### 7.1 Assumptions
|
||||
|
||||
- A1: 用户已确认主色方向为「近黑 + 强调色」
|
||||
- A2: 用户已确认中等改造范围 (聊天主区 + 右屏),不碰侧边栏/顶部/抽屉
|
||||
- A3: 用户已确认混合消息范式 (气泡 + card)
|
||||
- A4: 现有后端事件 (team_formed / expert_step / board_started / expert_speech / round_summary / board_concluded) 的 payload 字段不变
|
||||
- A5: 现有 WebSocket 协议不变,只改前端渲染
|
||||
- A6: 暗色模式 token 一并提供 (本需求重点是浅色,深色保持 token 同步)
|
||||
- A7: 不实现 message editing / branch conversation (Trae 独有)
|
||||
- A8: 不实现图片/文件 inline 渲染优化 (现有 `FilePreview.vue` 够用)
|
||||
|
||||
### 7.2 Risks
|
||||
|
||||
- **R1 (高)**: ChatMessage.vue 重构可能影响现有 unit test —— 需检查测试覆盖
|
||||
- **R2 (中)**: 大量组件修改可能引入回归 —— 建议每组件单独 PR
|
||||
- **R3 (中)**: mockup 评审 → 实施可能有偏差 —— mockup 应在 PR 之前完成
|
||||
|
||||
### 7.3 Out of Scope (Explicit)
|
||||
|
||||
- 侧边栏 (ChatSidebar.vue) 重构
|
||||
- 顶部导航 (TopNav.vue) 重构
|
||||
- 移动端响应式优化
|
||||
- 实际 WebSocket 数据流对接 (mockup 阶段)
|
||||
- 可访问性 (a11y) 审计
|
||||
- 性能优化 (虚拟滚动、消息懒加载) —— 独立 PR
|
||||
|
||||
---
|
||||
|
||||
## 8. Success Criteria
|
||||
|
||||
### 8.1 完成定义
|
||||
|
||||
- [ ] 静态 mockup 页面 (`/agent/preview`) 6 个场景全部可看可交互
|
||||
- [ ] `tokens.css` + `theme.ts` 全部新 token 已就位且通过 lint
|
||||
- [ ] 所有改动组件 `vue-tsc --noEmit` 通过
|
||||
- [ ] 所有改动组件 `ruff check` 通过 (Python 端)
|
||||
- [ ] 现有 unit test 不回归 (`pytest tests/unit/ -x -q`)
|
||||
- [ ] 暗色模式 token 同步更新
|
||||
- [ ] 视觉 review 通过 (用户签字)
|
||||
|
||||
### 8.2 验收检查点
|
||||
|
||||
1. **Mockup review** (核心验收):用户看 `/agent/preview`,对 6 个场景逐一 review,决定是否进入实施
|
||||
2. **Token review**:对照本规范 §2 确认 token 命名和值
|
||||
3. **组件 review**:每个新组件单独 PR 走 code review
|
||||
|
||||
---
|
||||
|
||||
## 9. Open Questions
|
||||
|
||||
- Q1: @team 蓝 (`#3b82f6`) vs @board 紫 (`#a855f7`) 的分配是否调换?当前:蓝=协作,紫=深度
|
||||
- Q2: 流式光标样式:竖线 vs 圆点?提议:竖线 (更克制)
|
||||
- Q3: 私董会按钮文字:是「私董会」还是「@board 讨论」?提议:「私董会」(短)
|
||||
- Q4: 专家团按钮文字:是「专家团」「@team」「召唤专家」?提议:「专家团」
|
||||
- Q5: 是否需要「消息引用」功能 (Trae 的 <#file> 引用)?提议:本期不做
|
||||
|
||||
---
|
||||
|
||||
## 10. Reference
|
||||
|
||||
- 参考设计语言:Notion (留白 / 暖灰 / 柔和阴影), Cursor (复杂事件 card), Trae (可收起右屏 + Side Chat)
|
||||
- 行业共识 (setproduct 2026-05-29):
|
||||
- Full-width is current best practice for serious AI chat. Bubbles signal "messenger" and undermine the tool framing.
|
||||
- Use subtle background shading or alignment to separate user and assistant messages instead.
|
||||
- Streaming cursor must exist; dropping it is a common mistake.
|
||||
- Markdown rendering during stream: don't render heavy syntax highlight during streaming to avoid flickering.
|
||||
- 来源:[Designing AI chat interfaces: Anatomy, patterns, pitfalls](https://www.setproduct.com/blog/ai-chat-interface-ui-design)
|
||||
- 来源:[Trae CN 2026 完全指南](https://blog.csdn.net/the_finals/article/details/161893557)
|
||||
- 来源:[Claude Code vs Cursor 2026](https://www.aidesigner.ai/blog/claude-code-vs-cursor)
|
||||
|
|
@ -0,0 +1,469 @@
|
|||
---
|
||||
status: active
|
||||
date: 2026-06-19
|
||||
type: feat
|
||||
origin: docs/brainstorms/2026-06-18-chat-area-vi-redesign-requirements.md
|
||||
---
|
||||
|
||||
# feat: 聊天主区 VI 重梳与新功能适配 — 实施计划(执行中 / 复审版)
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
本计划承接 [聊天主区 VI 重梳需求文档](docs/brainstorms/2026-06-18-chat-area-vi-redesign-requirements.md),目标是把需求中的设计令牌、消息视觉范式、右屏 Tab 面板、@team / @board 消息适配以及 Preview 静态样例全部落地。
|
||||
|
||||
**当前进展(截至复审时点):**
|
||||
|
||||
- `U1. 补齐设计令牌与主题映射` 已完成:`tokens.css` 与 `theme.ts` 新增 `--radius-card`、`--font-mono`、`--accent-team*`、`--accent-board*`、`--shadow-card` 等;关键组件已使用语义 token。
|
||||
- `U2. 消息模型与分发器对齐后端事件` 已完成:`IChatMessage` 扩展了 `plan_phases`、`error_detail`、`board_conclusion` 等字段;`useMessageRenderer.ts` 已新增 `team_plan`、`board_banner`、`board_conclusion`、`error` 等路由;`chat.ts` 已调整 `board_started` / `board_concluded` 事件推送。
|
||||
- `U3. TeamPlanCard 视觉升级` 已完成:蓝色顶条、阶段时间线、进度条、状态图标就位。
|
||||
- `U4. Board 复杂消息组件群` 已完成:`BoardBannerCard.vue`、`BoardRoundCard.vue`、`BoardConclusionCard.vue` 已新建/升级;`Scene4BoardDiscussion.vue` 已加入 banner、多轮发言、小结、结论的完整示例。
|
||||
- `U5. ErrorCard 重试与渲染集成` 已完成:ErrorCard 支持重试事件,`useMessageRenderer.ts` 已路由 error 类型。
|
||||
- `U6. ChatInput 交互补全` 已完成:拖拽上传浮层、清空按钮、专家团/私董会语义色按钮就位。
|
||||
- `U7. 右屏 Tab 面板内容补全` **部分完成**:Tab bar 与「监控」内容已就位,但「技能 / 系统 / 知识库」三个 Tab 的内容尚未根据 `activeTab` 切换渲染。
|
||||
|
||||
**剩余工作:**
|
||||
|
||||
- `U7` 完成:实现 `SkillsTab` / `SystemTab` / `KnowledgeTab` 三个子组件,并在 `SystemMonitorPanel.vue` 中按 `activeTab` 切换。
|
||||
- `U7` 结对代码审查。
|
||||
- `U8` Preview 样例场景精修。
|
||||
- `U8` 结对代码审查。
|
||||
- `U9` BoardMeetingModal VI 适配收尾。
|
||||
- `U9` 结对代码审查。
|
||||
- `U10` 质量门与后端回归测试。
|
||||
|
||||
**执行纪律:** 每个开发单元完成后,启动一个子 agent 进行结对代码审查;审查通过后再进入下一单元。全部完成后统一跑 `vue-tsc --noEmit` / `npm run build` / `pytest` 并修复问题。
|
||||
|
||||
---
|
||||
|
||||
## Problem Frame
|
||||
|
||||
聊天主区既要承载普通对话(user / assistant 文本),也要承载越来越复杂的 AI 运行态:专家团计划、私董会多轮讨论、工具调用链、错误恢复。现有实现已经把这些类型拆成独立组件,但:
|
||||
|
||||
1. 视觉语言与需求文档仍有偏差(TeamPlanCard 缺少进度条、BoardRoundCard 缺少紫色边框与角色标签、ErrorCard 缺少重试);
|
||||
2. 消息分发器没有为 `error`、`board_started`、`board_conclusion`、`plan_update` 等类型预留专属渲染路径;
|
||||
3. 右屏 `SystemMonitorPanel` 只有「监控」有内容,「技能 / 系统 / 知识库」尚未接入;
|
||||
4. 部分设计令牌(`--radius-card`、`--font-mono`)和交互细节(拖拽上传、清空输入、按钮语义色)尚未补齐。
|
||||
|
||||
---
|
||||
|
||||
## Requirements Traceability
|
||||
|
||||
| 需求来源 | 本计划覆盖范围 |
|
||||
|---|---|
|
||||
| §2 Color System | 补齐 `--radius-card`、`--font-mono`,深色模式同步,移除硬编码旧色 |
|
||||
| §3.1 整体布局 | ChatView max-width 已居中;右屏折叠由 `RightPanel` 负责,本计划补 Tab 内容 |
|
||||
| §3.2 消息类型表 | 为 `team_plan`、`board_banner`、`board_speech`、`board_summary`、`board_conclusion`、`error` 补齐/升级组件与分发 |
|
||||
| §3.3 关键交互 | ChatInput 拖拽浮层、清空按钮、按钮语义色;ErrorCard 重试;ToolCallCard 默认收起已具备 |
|
||||
| §4 Right Panel | SystemMonitorPanel 增加「技能 / 系统 / 知识库」可切换内容 |
|
||||
| §5 Input Area | 专家团/私董会按钮着色、拖拽上传、清空 |
|
||||
| §6.3 Mockup 6 场景 | 调整 Scene1/4/5,使预览更接近需求图 |
|
||||
|
||||
---
|
||||
|
||||
## Key Technical Decisions
|
||||
|
||||
1. **继续沿用 `MessageShell` + `useMessageRenderer` 分发架构。** 复杂消息统一走 `ChatMessage` 分发,不在 `ChatView` 里写大量 `v-if`。
|
||||
2. **扩展 `IChatMessage` 模型承载复杂消息字段。** 新增 `plan_phases`、`error_detail`、`board_conclusion`,让后端事件可以无损透传到组件层,而不是把所有状态塞进 `content`。
|
||||
3. **Board 顶部 Banner 用消息流中的 `milestone` 卡片表达。** `board_started` 事件进入聊天列表时渲染为 `BoardBannerCard`,关闭原来的纯文本 `milestone` fallback;`board_concluded` 渲染为 `BoardConclusionCard`。
|
||||
4. **右屏 Tab 内容复用现有 store 与组件,但做紧凑版。** 避免把完整 `SkillsView` / `KnowledgeBaseView` 的页面级 padding 和 header 搬进侧边栏;新建 `SkillsPanel` / `KnowledgePanel` / `SystemPanel` 只保留核心列表/指标。
|
||||
5. **所有新增/修改组件必须先过 `vue-tsc --noEmit`。** 由于前端没有单元测试,类型检查 + 构建即主要质量门。
|
||||
6. **每个单元完成后启动结对审查子 agent。** 审查范围:类型安全、Vue 范式、设计令牌使用、边界条件处理;审查通过后再进入下一单元。
|
||||
|
||||
---
|
||||
|
||||
## Implementation Units
|
||||
|
||||
### U1. 补齐设计令牌与主题映射
|
||||
|
||||
**Goal:** 让需求文档 §2 中新增的 `--radius-card`、`--font-mono` 等令牌就位,并清理组件中残留的硬编码色值。
|
||||
|
||||
**Requirements:** §2.1 / §2.2 / §2.4 / §2.5
|
||||
|
||||
**Dependencies:** 无
|
||||
|
||||
**Files:**
|
||||
- `src/agentkit/server/frontend/src/styles/tokens.css`
|
||||
- `src/agentkit/server/frontend/src/styles/theme.ts`
|
||||
- `src/agentkit/server/frontend/src/components/chat/BoardMeetingModal.vue`
|
||||
- `src/agentkit/server/frontend/src/components/layout/SystemMonitorPanel.vue`
|
||||
|
||||
**Approach:**
|
||||
- 在 `:root` 与 `[data-theme="dark"]` 中新增 `--radius-card: 12px` 和 `--font-mono: 'SF Mono', 'Fira Code', ...`。
|
||||
- `theme.ts` 的 `Card` / `Modal` 组件 token 优先读取 `--radius-card`。
|
||||
- 将 `BoardMeetingModal.vue` 中硬编码的 `rgba(142, 68, 173, ...)` 与 `rgba(99, 102, 241, ...)` 替换为 `--accent-board-soft` / `--accent-board-bg`。
|
||||
- 将 `SystemMonitorPanel.vue` 中硬编码的健康色 `#22c55e`、错误色 `#ef4444` 替换为语义令牌。
|
||||
|
||||
**Test scenarios:**
|
||||
- `vue-tsc --noEmit` 通过。
|
||||
- 在浏览器开发者工具中检查 `:root` 包含 `--radius-card` 与 `--font-mono`。
|
||||
- 深色模式下 `--accent-board-soft` 为带透明度的紫色。
|
||||
|
||||
**Verification:** 类型检查通过;浏览器 computed styles 可见新增令牌;BoardMeetingModal 选中态背景不再出现旧紫色。
|
||||
|
||||
**Pair review:** 由子 agent 检查 token 命名一致性、深色模式同步、是否仍有遗漏硬编码色值。
|
||||
|
||||
---
|
||||
|
||||
### U2. 消息模型与分发器对齐后端事件
|
||||
|
||||
**Goal:** 让 `useMessageRenderer` 能正确识别 `error`、`board_started`、`board_conclusion`、`plan_update` 等消息类型,并给组件传递结构化数据。
|
||||
|
||||
**Requirements:** §3.2 / §6.1
|
||||
|
||||
**Dependencies:** U1
|
||||
|
||||
**Files:**
|
||||
- `src/agentkit/server/frontend/src/api/types.ts`
|
||||
- `src/agentkit/server/frontend/src/components/chat/helpers/useMessageRenderer.ts`
|
||||
- `src/agentkit/server/frontend/src/components/chat/messages/index.ts`
|
||||
- `src/agentkit/server/frontend/src/components/chat/ChatMessage.vue`
|
||||
- `src/agentkit/server/frontend/src/stores/chat.ts`
|
||||
- `src/agentkit/server/frontend/src/components/chat/messages/BoardBannerCard.vue`(已新建)
|
||||
- `src/agentkit/server/frontend/src/components/chat/messages/BoardConclusionCard.vue`(已新建)
|
||||
|
||||
**Approach:**
|
||||
- 在 `IChatMessage` 中新增可选字段:`plan_phases?: ITeamPlanPhase[]`、`error_detail?: string`、`board_conclusion?: IBoardConcludedData`。
|
||||
- 扩展 `MessageViewType` 为 `'user' | 'assistant' | 'team_plan' | 'board_banner' | 'board_speech' | 'board_summary' | 'board_conclusion' | 'milestone' | 'error'`。
|
||||
- `resolveMessageType` 按 `message_type` 返回 `team_plan`(`plan_update`)、`board_banner`(新增 `board_started`)、`board_conclusion`、`error`(新增 `error`)。
|
||||
- `team_plan` 分支从 `message.plan_phases` 取阶段,而不是从 `tool_calls` 映射(当前实现是错误映射)。
|
||||
- `chat.ts` 中 `board_started` 不再推送纯文本 `milestone`,而是推送 `message_type: 'board_started'` 的消息;`board_concluded` 推送 `message_type: 'board_conclusion'` 并带上 `board_conclusion` 数据。
|
||||
- `error` 分支对应后端 `error` 事件或本地把 `status === 'error'` 的消息路由到 `ErrorCard`。
|
||||
|
||||
**Test scenarios:**
|
||||
- Happy path: `plan_update` 消息渲染 `TeamPlanCard` 且阶段名称正确。
|
||||
- Happy path: `board_started` 渲染 `BoardBannerCard`,显示主题、专家 chips、轮次。
|
||||
- Happy path: `board_conclusion` 渲染 `BoardConclusionCard`,展示共识 / 分歧 / 建议。
|
||||
- Error path: 后端 `error` 事件渲染 `ErrorCard` 并带重试按钮。
|
||||
- Edge case: `message.plan_phases` 为空时 `TeamPlanCard` 不崩溃。
|
||||
|
||||
**Verification:** 在 Preview Scene3/4/6 中可见正确渲染;`vue-tsc` 无新增类型错误。
|
||||
|
||||
**Pair review:** 由子 agent 检查类型定义完整性、分发器分支覆盖、`chat.ts` 事件转换是否遗漏字段。
|
||||
|
||||
---
|
||||
|
||||
### U3. TeamPlanCard 视觉升级
|
||||
|
||||
**Goal:** 让专家团计划 card 符合需求图:蓝色顶条、Lead 头像、阶段时间线、进度条、状态图标。
|
||||
|
||||
**Requirements:** §3.2 Team Plan Card 图示
|
||||
|
||||
**Dependencies:** U2
|
||||
|
||||
**Files:**
|
||||
- `src/agentkit/server/frontend/src/components/chat/messages/TeamPlanCard.vue`
|
||||
- `src/agentkit/server/frontend/src/components/preview/scenes/Scene3TeamPlan.vue`
|
||||
|
||||
**Approach:**
|
||||
- 顶部增加 4px 高的 `--accent-team` 顶条,左侧显示 Lead 头像 + 「专家团计划」标签 + 完成计数。
|
||||
- 阶段列表使用相对时间线:每个阶段左侧圆点/状态图标(● 执行中、○ 待执行、✓ 完成、✕ 失败),下方可展开专家子步骤(预留 `result` / `milestone` 字段)。
|
||||
- 底部增加进度条:`completed / total` 百分比,用 `--accent-team` 填充。
|
||||
- 颜色统一使用 `--accent-team*` 系列,不再使用默认 Ant Design 紫色。
|
||||
|
||||
**Test scenarios:**
|
||||
- Happy path: 3 个阶段中 1 个运行、2 个待执行,进度条显示 33%。
|
||||
- Edge case: 所有阶段失败,进度条为 0%,状态图标全为错误色。
|
||||
- Edge case: 阶段名超长时显示 `text-overflow: ellipsis`。
|
||||
|
||||
**Verification:** Preview Scene3 与需求图一致;无类型错误。
|
||||
|
||||
**Pair review:** 由子 agent 检查样式 token 使用、进度计算正确性、状态图标映射。
|
||||
|
||||
---
|
||||
|
||||
### U4. Board 复杂消息组件群
|
||||
|
||||
**Goal:** 补齐私董会所需的 `BoardBannerCard`、`BoardRoundCard` 紫色边框样式、`BoardConclusionCard`。
|
||||
|
||||
**Requirements:** §3.2 Board Banner / Board Round Card / board_conclusion
|
||||
|
||||
**Dependencies:** U2
|
||||
|
||||
**Files:**
|
||||
- `src/agentkit/server/frontend/src/components/chat/messages/BoardBannerCard.vue`(已新建)
|
||||
- `src/agentkit/server/frontend/src/components/chat/messages/BoardRoundCard.vue`
|
||||
- `src/agentkit/server/frontend/src/components/chat/messages/BoardConclusionCard.vue`(已新建)
|
||||
- `src/agentkit/server/frontend/src/components/chat/messages/index.ts`
|
||||
- `src/agentkit/server/frontend/src/components/preview/scenes/Scene4BoardDiscussion.vue`
|
||||
|
||||
**Approach:**
|
||||
- `BoardBannerCard`:跨整行 card,顶部 4px `--accent-board` 条,显示 🏛️ 私董会主题、专家 emoji chips、主持人标签、当前轮次 / 最大轮次进度条。
|
||||
- `BoardRoundCard`:外层包裹增加左侧 3px `--accent-board` 边框、`--accent-board-soft` 背景;header 显示专家头像 chip、第 N 轮、角色标签(主持/专家)。
|
||||
- `BoardConclusionCard`:跨整行 card,紫色渐变顶条,内部展示「共识」「分歧」「决策建议」三个区块;默认折叠分歧与建议,点击展开。
|
||||
- 导出到 `messages/index.ts`;更新 Preview Scene4 包含 banner + 3 轮发言 + 小结 + 结论。
|
||||
|
||||
**Test scenarios:**
|
||||
- Happy path: banner 显示主题、专家、主持人、轮次进度。
|
||||
- Happy path: 专家发言 card 左侧紫边,主持人 meta 带「主持」标签。
|
||||
- Happy path: 结论 card 默认展开共识,折叠分歧/建议,点击可展开。
|
||||
- Edge case: 结论数据为空时隐藏对应区块。
|
||||
|
||||
**Verification:** Preview Scene4 完整呈现 banner、发言、小结、结论;`vue-tsc` 通过。
|
||||
|
||||
**Pair review:** 由子 agent 检查紫色 token 一致性、BoardRoundCard 结构与需求图差异、折叠状态实现。
|
||||
|
||||
---
|
||||
|
||||
### U5. ErrorCard 重试与渲染集成
|
||||
|
||||
**Goal:** 错误 card 支持重试操作,并能在消息流中正确触发。
|
||||
|
||||
**Requirements:** §3.2 error / §3.3 错误重试
|
||||
|
||||
**Dependencies:** U2
|
||||
|
||||
**Files:**
|
||||
- `src/agentkit/server/frontend/src/components/chat/messages/ErrorCard.vue`
|
||||
- `src/agentkit/server/frontend/src/components/chat/helpers/useMessageRenderer.ts`
|
||||
- `src/agentkit/server/frontend/src/components/chat/ChatMessage.vue`
|
||||
- `src/agentkit/server/frontend/src/components/preview/scenes/Scene6Error.vue`
|
||||
- `src/agentkit/server/frontend/src/stores/chat.ts`
|
||||
|
||||
**Approach:**
|
||||
- `ErrorCard` 增加 `onRetry` prop / `emit('retry')`,在 card 底部显示「重试」按钮。
|
||||
- `ChatMessage` 监听 retry 事件,调用 `chatStore.resendLastUserMessage()` 或类似 action;若不存在则新增该 action:取当前对话最后一条 user 消息重新发送。
|
||||
- `useMessageRenderer` 对 `message_type === 'error'` 或 `status === 'error'` 的消息渲染 `ErrorCard`。
|
||||
- Preview Scene6 的 ErrorCard 带重试按钮,点击后触发重发模拟。
|
||||
|
||||
**Test scenarios:**
|
||||
- Happy path: 点击 ErrorCard「重试」重新发送最后一条 user 消息。
|
||||
- Error path: 对话中没有 user 消息时重试按钮禁用或不显示。
|
||||
- Edge case: 连续多次重试不追加重复占位消息。
|
||||
|
||||
**Verification:** Scene6 可交互;类型检查通过。
|
||||
|
||||
**Pair review:** 由子 agent 检查重试逻辑幂等性、空消息边界、事件冒泡处理。
|
||||
|
||||
---
|
||||
|
||||
### U6. ChatInput 交互补全
|
||||
|
||||
**Goal:** 完成输入区的语义色按钮、拖拽上传浮层、清空按钮。
|
||||
|
||||
**Requirements:** §5.2 / §5.3
|
||||
|
||||
**Dependencies:** U1
|
||||
|
||||
**Files:**
|
||||
- `src/agentkit/server/frontend/src/components/chat/ChatInput.vue`
|
||||
- `src/agentkit/server/frontend/src/components/preview/scenes/Scene1Welcome.vue`
|
||||
|
||||
**Approach:**
|
||||
- 「专家团」按钮默认/悬停使用 `--accent-team`;「私董会」按钮默认/悬停使用 `--accent-board`;图标尺寸保持 14px。
|
||||
- 在 textarea 区域监听 `dragenter` / `dragover` / `dragleave` / `drop`,放下文件时显示「拖放以附加」半透明浮层并调用现有 `handleFileChange` 逻辑。
|
||||
- 在 footer 增加「清空」按钮,点击后 `inputText = ''` 并清空已选附件 pills。
|
||||
- Preview Scene1 的输入框预填文本改为 `@team:tech_lead,frontend_engineer,backend_engineer 设计一个用户认证系统`。
|
||||
|
||||
**Test scenarios:**
|
||||
- Happy path: 拖拽文件到输入区出现浮层,松开后文件被引用到输入框。
|
||||
- Happy path: 点击清空后输入框与附件 pills 清空。
|
||||
- Edge case: 非文件拖拽(如文本)不触发上传浮层。
|
||||
|
||||
**Verification:** 浏览器手动验证;`vue-tsc` 通过。
|
||||
|
||||
**Pair review:** 由子 agent 检查拖拽事件处理是否健壮、清空是否同时清理附件、按钮语义色是否正确。
|
||||
|
||||
---
|
||||
|
||||
### U7. 右屏 Tab 面板内容补全
|
||||
|
||||
**Goal:** SystemMonitorPanel 的「技能 / 系统 / 知识库」三个 Tab 有真实内容。
|
||||
|
||||
**Requirements:** §4.1 / §4.3
|
||||
|
||||
**Dependencies:** 无
|
||||
|
||||
**Files:**
|
||||
- `src/agentkit/server/frontend/src/components/layout/SystemMonitorPanel.vue`
|
||||
- `src/agentkit/server/frontend/src/stores/skills.ts`
|
||||
- `src/agentkit/server/frontend/src/stores/knowledge.ts`
|
||||
- `src/agentkit/server/frontend/src/components/skills/SkillCard.vue`
|
||||
- `src/agentkit/server/frontend/src/components/kb/SearchTest.vue`
|
||||
- `src/agentkit/server/frontend/src/api/system.ts`(已新建)
|
||||
|
||||
**Approach:**
|
||||
- 保持 Tab bar 与切换逻辑不变,把当前监控内容拆到 `MonitorTab` 子组件。
|
||||
- 新增 `SkillsTab`:使用 `useSkillsStore` 拉取技能列表,以紧凑列表展示技能名 + 描述 + 状态,点击展开 `SkillDetail`;复用 `SkillCard` 但减少 padding。
|
||||
- 新增 `SystemTab`:展示当前 `resources`(CPU / 内存 / 磁盘)与服务健康检查,避免重复监控 Tab 的 4 个 metric card。
|
||||
- 新增 `KnowledgeTab`:使用 `useKnowledgeStore` 拉取 sources,展示知识库源列表,并在底部嵌入 `SearchTest` 做检索测试。
|
||||
- 激活 Tab 的下划线颜色改为 `--accent-team`。
|
||||
|
||||
**Test scenarios:**
|
||||
- Happy path: 切换到「技能」Tab 可见技能列表,点击技能弹出详情。
|
||||
- Happy path: 切换到「系统」Tab 可见资源使用率与服务状态。
|
||||
- Happy path: 切换到「知识库」Tab 可见源列表与检索测试入口。
|
||||
- Error path: API 失败时显示友好错误提示。
|
||||
|
||||
**Verification:** 在 `/agent/chat` 打开右屏切换 4 个 Tab;`vue-tsc` / `npm run build` 通过。
|
||||
|
||||
**Pair review:** 由子 agent 检查 store 使用是否引入重复请求、Tab 切换状态、API 错误处理。
|
||||
|
||||
---
|
||||
|
||||
### U8. Preview 样例场景精修
|
||||
|
||||
**Goal:** 让 6 个 Preview 场景与需求文档 §6.3 一一对应,便于视觉 review。
|
||||
|
||||
**Requirements:** §6.3
|
||||
|
||||
**Dependencies:** U2 / U3 / U4 / U5 / U6
|
||||
|
||||
**Files:**
|
||||
- `src/agentkit/server/frontend/src/components/preview/ChatPreviewShell.vue`
|
||||
- `src/agentkit/server/frontend/src/components/preview/scenes/Scene1Welcome.vue`
|
||||
- `src/agentkit/server/frontend/src/components/preview/scenes/Scene3TeamPlan.vue`
|
||||
- `src/agentkit/server/frontend/src/components/preview/scenes/Scene4BoardDiscussion.vue`
|
||||
- `src/agentkit/server/frontend/src/components/preview/scenes/Scene5ToolCall.vue`
|
||||
|
||||
**Approach:**
|
||||
- Scene1:顶部 hero + 4 能力提示 + 预填 `@team:...` 的输入框。
|
||||
- Scene3:使用升级后的 `TeamPlanCard`,第 1 阶段 running、专家子项展开。
|
||||
- Scene4:包含 `BoardBannerCard`、3 轮不同专家发言、1 个小结、1 个 `BoardConclusionCard`。
|
||||
- Scene5:展示嵌套 2 层 tool call,状态点变化(running → completed / error)。
|
||||
- ChatPreviewShell:场景切换按钮增加键盘左右箭头监听,方便演示。
|
||||
|
||||
**Test scenarios:**
|
||||
- Happy path: 6 个场景全部可切换且无 console error。
|
||||
- Happy path: Scene4 的 banner 与结论 card 完整渲染。
|
||||
- Edge case: 快速切换场景不残留上一个场景的滚动位置(滚动容器复位)。
|
||||
|
||||
**Verification:** `/agent/preview` 可完整演示 6 个场景;构建通过。
|
||||
|
||||
**Pair review:** 由子 agent 检查 mock 数据类型与真实接口是否一致、场景切换是否健壮。
|
||||
|
||||
---
|
||||
|
||||
### U9. BoardMeetingModal VI 适配
|
||||
|
||||
**Goal:** 私董会 Modal 使用新的紫色语义令牌,移除旧 `#8E44AD` 与 indigo 硬编码。
|
||||
|
||||
**Requirements:** §6.1
|
||||
|
||||
**Dependencies:** U1
|
||||
|
||||
**Files:**
|
||||
- `src/agentkit/server/frontend/src/components/chat/BoardMeetingModal.vue`
|
||||
|
||||
**Approach:**
|
||||
- 选中态背景改为 `--accent-board-bg` / `--accent-board-soft`。
|
||||
- 选中边框使用 `--accent-board`。
|
||||
- 主持人标签颜色从 Ant Design `purple` 改为自定义 Tag,背景 `--accent-board-soft`、文字 `--accent-board`。
|
||||
- hover 边框改为 `--color-primary`(近黑)。
|
||||
|
||||
**Test scenarios:**
|
||||
- Happy path: 选中专家后卡片边框为紫色,主持人卡片背景为浅紫色。
|
||||
- Edge case: 深色模式下紫色令牌仍可见。
|
||||
|
||||
**Verification:** 在 ChatInput 打开「私董会」Modal 验证;`vue-tsc` 通过。
|
||||
|
||||
**Pair review:** 由子 agent 检查是否仍有遗漏硬编码色值、深色模式表现。
|
||||
|
||||
---
|
||||
|
||||
### U10. 质量门与后端回归
|
||||
|
||||
**Goal:** 确保前端类型安全、构建成功,后端单元测试不回归。
|
||||
|
||||
**Requirements:** §8.1 完成定义
|
||||
|
||||
**Dependencies:** U1-U9
|
||||
|
||||
**Files:**
|
||||
- `src/agentkit/server/frontend/package.json`
|
||||
- `src/agentkit/server/routes/chat.py`(仅验证上传接口未破坏)
|
||||
- `src/agentkit/server/routes/experts.py`(仅验证专家列表未破坏)
|
||||
|
||||
**Approach:**
|
||||
- 在前端目录运行 `npx vue-tsc --noEmit`。
|
||||
- 运行 `npm run build`。
|
||||
- 在仓库根目录运行 `ruff check src/` 与 `ruff format src/`。
|
||||
- 运行 `python3 -m pytest tests/unit/ -x -q`;若 `test_rewoo_agent_yaml_loads` 失败则按已知问题跳过。
|
||||
- 修复所有阻塞性错误后重新跑门。
|
||||
|
||||
**Test scenarios:**
|
||||
- `vue-tsc --noEmit` 通过。
|
||||
- `npm run build` 生成 `src/agentkit/server/static/` 且无报错。
|
||||
- `pytest tests/unit/ -x -q` 通过(允许跳过已知失败)。
|
||||
- `ruff check src/` 通过。
|
||||
|
||||
**Verification:** 三个命令均返回 0(或 pytest 仅因已知用例跳过)。
|
||||
|
||||
**Pair review:** 由子 agent 检查类型错误修复是否引入新的运行时问题、构建产物是否正常。
|
||||
|
||||
---
|
||||
|
||||
## Scope Boundaries
|
||||
|
||||
### In Scope
|
||||
|
||||
- 聊天主区 VI 令牌补齐与深色模式同步。
|
||||
- `ChatMessage` 分发器与 `IChatMessage` 模型扩展。
|
||||
- `TeamPlanCard`、`BoardBannerCard`、`BoardRoundCard`、`BoardConclusionCard`、`ErrorCard` 的升级/新建。
|
||||
- `ChatInput` 的拖拽上传、清空、语义色按钮。
|
||||
- `SystemMonitorPanel` 的「技能 / 系统 / 知识库」Tab 内容。
|
||||
- `ChatPreviewView` 6 个样例场景精修。
|
||||
- `BoardMeetingModal` VI 适配。
|
||||
- 质量门(vue-tsc / build / ruff / pytest)。
|
||||
- 每个开发单元后的结对代码审查。
|
||||
|
||||
### Out of Scope
|
||||
|
||||
- 侧边栏 `ChatSidebar`、顶部 `TopNav`、抽屉布局改造。
|
||||
- 移动端响应式优化。
|
||||
- 实际 WebSocket 数据流改造(仅前端渲染层适配)。
|
||||
- 可访问性(a11y)专项审计。
|
||||
- 虚拟滚动、消息懒加载等性能优化。
|
||||
- 消息编辑 / 分支对话(Trae 范式)。
|
||||
|
||||
### Deferred to Follow-Up Work
|
||||
|
||||
- UserBubble / AssistantText 的 hover 操作条(编辑/复制/重新生成/反馈)—— 需求 §3.3 提到,但本期以复杂事件 card 和右屏为主,可在后续 PR 补齐。
|
||||
- 流式光标竖线动画 —— 当前使用 `a-spin` 占位,后续可替换为闪烁竖线。
|
||||
|
||||
---
|
||||
|
||||
## Risks & Dependencies
|
||||
|
||||
| 风险 | 等级 | 缓解措施 |
|
||||
|---|---|---|
|
||||
| 扩展 `IChatMessage` 会影响现有对话持久化格式 | 中 | 新增字段均为 optional;后端返回 JSON 不校验未知字段时无影响。 |
|
||||
| `SystemMonitorPanel` 嵌入完整 Skills/KB 组件可能触发重复请求 | 中 | 使用 store 缓存,Tab 切换不重新 fetch;`onMounted` 仍负责首次加载。 |
|
||||
| Preview Scene 精修引入新的假数据类型与真实后端不一致 | 低 | Preview 明确为静态 mockup,不依赖真实 WebSocket。 |
|
||||
| 后端 `pytest` 已有已知失败 `test_rewoo_agent_yaml_loads` | 低 | 按项目规则跳过该用例。 |
|
||||
| 多单元改动叠加导致类型错误难以定位 | 中 | 每单元独立跑 `vue-tsc --noEmit`,不累积错误。 |
|
||||
|
||||
---
|
||||
|
||||
## Open Questions
|
||||
|
||||
以下问题来自需求文档 §9,实施前按默认值处理,后续可再调整:
|
||||
|
||||
1. **Q1** `@team` 蓝 vs `@board` 紫:保持当前分配(蓝=协作,紫=深度)。
|
||||
2. **Q2** 流式光标:保持当前 spin 占位,竖线动画 deferred。
|
||||
3. **Q3** 私董会按钮文字:保持「私董会」。
|
||||
4. **Q4** 专家团按钮文字:保持「专家团」。
|
||||
5. **Q5** 消息引用:本期不做。
|
||||
|
||||
---
|
||||
|
||||
## Success Criteria
|
||||
|
||||
- [x] `tokens.css` + `theme.ts` 新增令牌就位。
|
||||
- [x] `BoardBannerCard` / `BoardRoundCard` / `BoardConclusionCard` 结构与样式就位。
|
||||
- [ ] `/agent/preview` 6 个场景全部可见且可交互。
|
||||
- [ ] 无硬编码旧色残留(U1、U9 收尾)。
|
||||
- [ ] `vue-tsc --noEmit` 与 `npm run build` 通过。
|
||||
- [ ] `ruff check src/` 通过。
|
||||
- [ ] `pytest tests/unit/ -x -q` 通过(允许跳过已知失败)。
|
||||
- [ ] 右屏 4 个 Tab 都有内容。
|
||||
- [ ] ErrorCard 支持重试。
|
||||
- [ ] 每个开发单元都经过子 agent 结对审查并确认无阻塞问题。
|
||||
|
||||
---
|
||||
|
||||
## Sources & Research
|
||||
|
||||
- Origin: [docs/brainstorms/2026-06-18-chat-area-vi-redesign-requirements.md](docs/brainstorms/2026-06-18-chat-area-vi-redesign-requirements.md)
|
||||
- Local patterns: `src/agentkit/server/frontend/src/components/chat/messages/`, `src/agentkit/server/frontend/src/components/layout/RightPanel.vue`
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,448 @@
|
|||
---
|
||||
title: "feat: Enterprise Client-Server Architecture Evolution"
|
||||
type: feat
|
||||
status: completed
|
||||
created: 2026-06-19
|
||||
completed: 2026-06-19
|
||||
origin: docs/plans/2026-06-19-002-enterprise-client-server-architecture-plan.md
|
||||
deepened: false
|
||||
---
|
||||
|
||||
# feat: Enterprise Client-Server Architecture Evolution
|
||||
|
||||
## Summary
|
||||
|
||||
将 Fischer AgentKit 从纯本地运行架构演进为企业级客户端+服务端架构。客户端(Tauri 桌面端)作为 AI 工作台本地执行 Agent/终端/文件操作,服务端作为企业平台提供 LLM 网关(统一 Key 管理)、用户权限、审计日志、知识库共享能力。采用渐进式改造,每个阶段设立检查点验证核心假设。
|
||||
|
||||
## Problem Frame
|
||||
|
||||
当前 AgentKit 是纯本地架构:Tauri 启动本地 Python sidecar,LLM API Key 存在本地 agentkit.yaml,无用户系统、无权限管理、无服务端部署。企业场景需要:统一管控 LLM Key 和成本、团队知识共享、权限与审计、多用户支持。
|
||||
|
||||
## Scope Boundaries
|
||||
|
||||
### In Scope
|
||||
|
||||
- 服务端 LLM 网关(统一 Key + 用量统计 + 流式透传)
|
||||
- 用户认证(JWT + API Key 双轨)
|
||||
- 三级 RBAC + 权限位
|
||||
- 双模式终端(本地 + 服务端)+ 三层白名单
|
||||
- 终端审计日志
|
||||
- 配置同步(轮询版)
|
||||
- 前端连接模式切换
|
||||
|
||||
### Deferred to Follow-Up Work
|
||||
|
||||
- Web 管理台(V1 用 CLI 管理)
|
||||
- 技能市场 / 工作流模板中心(YAML + Git 够用)
|
||||
- 生产系统集成(暴露 Webhook 即可)
|
||||
- SSO/OIDC(V1 本地账号,V2 预留接口)
|
||||
- 代码库语义索引
|
||||
- 多租户隔离(表预留 tenant_id,V2 启用)
|
||||
- 多设备会话同步
|
||||
|
||||
## Key Technical Decisions
|
||||
|
||||
### KTD1: 终端保留在本地 Python sidecar(非 Tauri Rust)
|
||||
|
||||
**决策**:终端 PTY 逻辑保留在 Python sidecar 中,不迁移到 Tauri Rust 层。
|
||||
|
||||
**理由**:当前 `terminal.py` + `PTYSession` 已完整实现 PTY 管理、安全检测、确认流程。用 Rust 重写风险高、收益低。本地 sidecar 模式下终端逻辑不变,服务端终端新增独立端点。
|
||||
|
||||
### KTD2: LLM 代理模式(非客户端直连)
|
||||
|
||||
**决策**:客户端通过服务端 LLM 网关间接调用 LLM,不在本地存储 API Key。
|
||||
|
||||
**理由**:核心价值是成本管控 + 用量审计(非安全边界)。服务端必须支持内网部署以避免跨地域延迟。客户端保留直连降级能力(服务端不可用时用户自填 Key)。
|
||||
|
||||
### KTD3: 配置同步用轮询(非 WebSocket 推送)
|
||||
|
||||
**决策**:启动时全量拉取 + 每 5 分钟轮询版本号 + 手动刷新。
|
||||
|
||||
**理由**:配置变更频率是周/月级,WebSocket 推送是杀鸡用牛刀。轮询实现量是 WebSocket 的 1/10,覆盖 99% 场景。
|
||||
|
||||
### KTD4: 三级 RBAC + 权限位(非四级角色)
|
||||
|
||||
**决策**:member / operator / admin 三级角色 + 独立权限位。
|
||||
|
||||
**理由**:四级中的 viewer 是想象出来的角色。企业真实需求是权限点(能否用终端、能否管理 KB),不是角色等级。权限位比固定等级更灵活。
|
||||
|
||||
### KTD5: 双模式终端 + 三层白名单
|
||||
|
||||
**决策**:支持本地终端(客户端 sidecar PTY)和服务端终端(服务端 PTY),均配三层白名单(内置/全局/用户)+ 黑名单。
|
||||
|
||||
**理由**:本地终端满足开发者本地操作需求,服务端终端满足 DevOps 管理服务器需求。白名单机制平衡安全与效率。
|
||||
|
||||
## High-Level Technical Design
|
||||
|
||||
```
|
||||
Phase 1: LLM Gateway + Auth Phase 2: RBAC + Audit + Terminal
|
||||
┌──────────────┐ ┌──────────────┐
|
||||
│ Client │ │ Client │
|
||||
│ RemoteLLM │──JWT──► │ Terminal │──JWT──►
|
||||
│ Provider │ │ (Local) │
|
||||
└──────┬───────┘ └──────┬───────┘
|
||||
│ SSE │ WS
|
||||
▼ ▼
|
||||
┌──────────────┐ ┌──────────────┐
|
||||
│ Server │ │ Server │
|
||||
│ LLM Gateway │ │ Auth+RBAC │
|
||||
│ + JWT Auth │ │ + Audit │
|
||||
│ + Usage │ │ + Whitelist │
|
||||
└──────────────┘ └──────────────┘
|
||||
|
||||
Phase 3: Server Terminal + KB
|
||||
┌──────────────┐
|
||||
│ Client │
|
||||
│ Terminal │──JWT──►
|
||||
│ (Server) │
|
||||
└──────┬───────┘
|
||||
│ WSS
|
||||
▼
|
||||
┌──────────────┐
|
||||
│ Server │
|
||||
│ Server PTY │
|
||||
│ + Approval │
|
||||
│ + KB Search │
|
||||
└──────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Implementation Units
|
||||
|
||||
### U1. 服务端 LLM 网关 API
|
||||
|
||||
**Goal**: 在服务端新增 LLM 代理端点,接收客户端请求并转发到 LLM Provider,记录用量。
|
||||
|
||||
**Dependencies**: 无(首个单元)
|
||||
|
||||
**Files**:
|
||||
- `src/agentkit/server/routes/llm_gateway.py`(新建)
|
||||
- `src/agentkit/server/app.py`(注册路由)
|
||||
- `tests/unit/test_llm_gateway.py`(新建)
|
||||
|
||||
**Approach**:
|
||||
- 新增 `POST /api/v1/llm/chat`(非流式)和 `POST /api/v1/llm/chat/stream`(SSE 流式)
|
||||
- 复用现有 `LLMGateway.chat()` 和 `chat_stream()`
|
||||
- 流式端点将 `StreamChunk` 序列化为 SSE 格式(`data: {json}\n\n`)
|
||||
- 记录每次调用的 user_id、model、tokens、cost 到 `llm_usage_records` 表
|
||||
- JWT 认证(U2 完成前临时用 API Key)
|
||||
|
||||
**Test scenarios**:
|
||||
- 非流式 chat 请求返回正确的 `LLMResponse` JSON
|
||||
- 流式 chat 请求返回 SSE 格式,每个 chunk 是 `data: {json}\n\n`
|
||||
- 无效 model 返回 404
|
||||
- LLM Provider 失败时返回 502
|
||||
- 用量记录正确写入数据库(model, tokens, cost)
|
||||
- 无认证时返回 401
|
||||
|
||||
**Checkpoint CP1**: 服务端 LLM 网关可独立运行,通过 curl 验证流式和非流式调用。
|
||||
|
||||
---
|
||||
|
||||
### U2. JWT 认证模块
|
||||
|
||||
**Goal**: 实现用户注册/登录/JWT 签发验证,替换当前纯 API Key 认证。
|
||||
|
||||
**Dependencies**: U1
|
||||
|
||||
**Files**:
|
||||
- `src/agentkit/server/auth/__init__.py`(新建)
|
||||
- `src/agentkit/server/auth/models.py`(新建,UserModel + UserApiKeyModel)
|
||||
- `src/agentkit/server/auth/jwt_utils.py`(新建,JWT 签发/验证)
|
||||
- `src/agentkit/server/auth/password.py`(新建,bcrypt 哈希)
|
||||
- `src/agentkit/server/auth/middleware.py`(新建,AuthMiddleware)
|
||||
- `src/agentkit/server/auth/dependencies.py`(新建,require_permission 依赖)
|
||||
- `src/agentkit/server/routes/auth.py`(新建,login/refresh/logout/me)
|
||||
- `src/agentkit/server/app.py`(注册路由 + 中间件替换)
|
||||
- `tests/unit/test_auth.py`(新建)
|
||||
|
||||
**Approach**:
|
||||
- `UserModel`: id, username, email, password_hash, role, is_active, is_terminal_authorized, is_server_terminal_authorized
|
||||
- `UserApiKeyModel`: user_id, key_hash(SHA256), key_prefix, name, expires_at, is_revoked
|
||||
- JWT: access_token(15min) + refresh_token(7d),HS256 签名
|
||||
- AuthMiddleware: JWT 优先 → API Key → 兼容 clients.yaml → dev mode 放行
|
||||
- `require_permission(*perms)`: FastAPI 依赖注入,检查 `request.state.current_user` 的角色权限
|
||||
- 初始 admin 引导: 首次启动时从 `AGENTKIT_ADMIN_USERNAME/PASSWORD` 创建 admin 用户
|
||||
- 数据库: V1 用 SQLite(复用 aiosqlite),后续切换 PostgreSQL
|
||||
|
||||
**Test scenarios**:
|
||||
- 注册/登录返回 access_token + refresh_token
|
||||
- refresh_token 可刷新 access_token
|
||||
- 错误密码返回 401
|
||||
- JWT 过期后返回 401,refresh 后可用
|
||||
- API Key 认证识别用户身份
|
||||
- 无认证 dev mode 放行
|
||||
- 无认证生产 mode 返回 401
|
||||
- `require_permission` 正确拒绝/放行
|
||||
- 初始 admin 创建成功
|
||||
|
||||
**Checkpoint CP2**: 可通过登录 API 获取 JWT,使用 JWT 访问 LLM 网关。
|
||||
|
||||
---
|
||||
|
||||
### U3. RemoteLLMProvider(客户端 LLM 代理)
|
||||
|
||||
**Goal**: 在客户端新增 `RemoteLLMProvider`,通过服务端 LLM 网关调用 LLM。
|
||||
|
||||
**Dependencies**: U1, U2
|
||||
|
||||
**Files**:
|
||||
- `src/agentkit/llm/remote_provider.py`(新建)
|
||||
- `src/agentkit/llm/gateway.py`(增加 RemoteLLMProvider 注册支持)
|
||||
- `tests/unit/test_remote_provider.py`(新建)
|
||||
|
||||
**Approach**:
|
||||
- `RemoteLLMProvider(LLMProvider)`: 实现 `chat()` 和 `chat_stream()`
|
||||
- `chat()`: POST `/api/v1/llm/chat`,带 `Authorization: Bearer {jwt}`
|
||||
- `chat_stream()`: POST `/api/v1/llm/chat/stream`,解析 SSE 流为 `StreamChunk`
|
||||
- 构造函数接收 `server_url` + `auth_token_provider`(callable,返回当前 JWT)
|
||||
- 错误处理: 401 触发 token 刷新重试,502 抛 `LLMProviderError`
|
||||
- 客户端 `LLMGateway` 配置为使用 `RemoteLLMProvider` 时,不加载本地 Provider 配置
|
||||
|
||||
**Test scenarios**:
|
||||
- `chat()` 正确发送请求并解析 `LLMResponse`
|
||||
- `chat_stream()` 正确解析 SSE 流为 `StreamChunk` 序列
|
||||
- 401 响应触发 token 刷新重试
|
||||
- 502 响应抛出 `LLMProviderError`
|
||||
- 网络超时抛出异常
|
||||
- `is_final=True` 的 chunk 正确标记
|
||||
|
||||
**Checkpoint CP3**: 客户端 Agent 通过 RemoteLLMProvider → 服务端网关 → LLM Provider 完成对话。
|
||||
|
||||
---
|
||||
|
||||
### U4. 前端认证 + 连接模式切换
|
||||
|
||||
**Goal**: 前端实现登录页、JWT 管理、API Client 附加 JWT、连接模式切换。
|
||||
|
||||
**Dependencies**: U2, U3
|
||||
|
||||
**Files**:
|
||||
- `src/agentkit/server/frontend/src/stores/auth.ts`(新建)
|
||||
- `src/agentkit/server/frontend/src/api/base.ts`(改造,附加 JWT)
|
||||
- `src/agentkit/server/frontend/src/views/LoginView.vue`(新建)
|
||||
- `src/agentkit/server/frontend/src/router/index.ts`(路由守卫)
|
||||
- `src/agentkit/server/frontend/src/api/auth.ts`(新建)
|
||||
- `src/agentkit/server/frontend/src/App.vue`(启动时初始化认证)
|
||||
|
||||
**Approach**:
|
||||
- `auth` store: user, accessToken, refreshToken, permissions, hasPermission(), canAccessTerminal()
|
||||
- `BaseApiClient.request()`: 自动附加 `Authorization: Bearer {token}`,401 时自动刷新
|
||||
- `BaseApiClient.createWebSocket()`: 附加 `?token={jwt}` 查询参数
|
||||
- 登录页: 用户名 + 密码 → 调用 `/api/v1/auth/login` → 存储 JWT → 跳转主页
|
||||
- 路由守卫: 未登录跳转 `/login`,权限检查
|
||||
- `initApiBaseURL()`: Tauri 模式检查 localStorage 中的远程服务器配置
|
||||
|
||||
**Test scenarios**:
|
||||
- 登录成功后 JWT 存入 localStorage,跳转主页
|
||||
- 登录失败显示错误提示
|
||||
- API 请求自动附加 JWT
|
||||
- 401 响应自动触发 token 刷新
|
||||
- token 刷新失败跳转登录页
|
||||
- WebSocket 连接附加 token 参数
|
||||
- 路由守卫正确拦截未登录用户
|
||||
|
||||
**Checkpoint CP4**: 前端登录 → JWT 认证 → 通过服务端 LLM 网关对话,全链路打通。
|
||||
|
||||
---
|
||||
|
||||
### U5. 权限模型 + RBAC 中间件
|
||||
|
||||
**Goal**: 实现三级 RBAC + 权限位,集成到所有路由。
|
||||
|
||||
**Dependencies**: U2
|
||||
|
||||
**Files**:
|
||||
- `src/agentkit/server/auth/permissions.py`(新建,Permission enum + ROLE_PERMISSIONS)
|
||||
- `src/agentkit/server/auth/dependencies.py`(完善 require_permission)
|
||||
- `src/agentkit/server/routes/portal.py`(增加权限检查)
|
||||
- `src/agentkit/server/routes/terminal.py`(增加权限检查)
|
||||
- `src/agentkit/server/routes/settings.py`(增加权限检查)
|
||||
- `src/agentkit/server/routes/kb_management.py`(增加权限检查)
|
||||
- `tests/unit/test_permissions.py`(新建)
|
||||
|
||||
**Approach**:
|
||||
- `Permission` enum: CHAT, KB_QUERY, KB_WRITE, WORKFLOW_EXECUTE, TERMINAL_LOCAL_USE, TERMINAL_SERVER_USE, TERMINAL_WHITELIST_MANAGE, USER_MANAGE, SYSTEM_CONFIG
|
||||
- `ROLE_PERMISSIONS`: member / operator / admin 权限映射
|
||||
- `require_permission(*perms)`: 检查 `request.state.current_user` 的角色权限
|
||||
- 现有路由增加 `Depends(require_permission(Permission.XXX))`
|
||||
- dev mode(无用户): 终端等高危功能仍需认证,其他放行
|
||||
|
||||
**Test scenarios**:
|
||||
- member 角色可对话但不可访问终端
|
||||
- operator 角色可使用本地终端但不可管理用户
|
||||
- admin 角色可访问所有功能
|
||||
- 未认证用户在 dev mode 可对话但不可用终端
|
||||
- 权限不足返回 403
|
||||
|
||||
**Checkpoint CP5**: 不同角色用户访问不同功能,权限检查生效。
|
||||
|
||||
---
|
||||
|
||||
### U6. 本地终端白名单 + 审计上报
|
||||
|
||||
**Goal**: 本地终端增加三层白名单机制 + 危险命令审计上报。
|
||||
|
||||
**Dependencies**: U5
|
||||
|
||||
**Files**:
|
||||
- `src/agentkit/server/routes/terminal.py`(改造白名单逻辑)
|
||||
- `src/agentkit/server/auth/models.py`(增加白名单表模型)
|
||||
- `src/agentkit/server/routes/terminal_whitelist.py`(新建,白名单管理 API)
|
||||
- `src/agentkit/server/app.py`(注册白名单路由)
|
||||
- `tests/unit/test_terminal_whitelist.py`(新建)
|
||||
|
||||
**Approach**:
|
||||
- 白名单匹配优先级: 黑名单 → 内置安全白名单 → 用户白名单 → 会话白名单 → 危险检测
|
||||
- 用户白名单存储在服务端 DB(`terminal_whitelist_user` 表),客户端缓存
|
||||
- 黑名单存储在服务端 DB(`terminal_blocklist` 表),admin 管理
|
||||
- 危险命令确认后可选加入会话白名单(不持久化到用户白名单)
|
||||
- 危险命令执行后上报审计日志到服务端(`terminal_audit_logs` 表)
|
||||
- 白名单管理 API: GET/POST/DELETE `/api/v1/terminal/whitelist/user`
|
||||
|
||||
**Test scenarios**:
|
||||
- 内置安全白名单命令直接执行
|
||||
- 用户白名单命令直接执行
|
||||
- 会话白名单命令直接执行
|
||||
- 黑名单命令被拒绝
|
||||
- 危险命令弹出确认,确认后执行
|
||||
- 危险命令拒绝后不执行
|
||||
- 危险命令审计日志正确上报
|
||||
- 白名单 CRUD API 正常工作
|
||||
|
||||
**Checkpoint CP6**: 本地终端白名单生效,危险命令需确认且审计上报。
|
||||
|
||||
---
|
||||
|
||||
### U7. 配置同步引擎
|
||||
|
||||
**Goal**: 客户端从服务端同步技能/Agent/工作流配置。
|
||||
|
||||
**Dependencies**: U2
|
||||
|
||||
**Files**:
|
||||
- `src/agentkit/server/routes/config_sync.py`(新建,配置同步 API)
|
||||
- `src/agentkit/server/app.py`(注册路由)
|
||||
- `src/agentkit/client/sync.py`(新建,客户端同步引擎)
|
||||
- `tests/unit/test_config_sync.py`(新建)
|
||||
|
||||
**Approach**:
|
||||
- 服务端 API: `GET /api/v1/config/version`(返回版本号)+ `GET /api/v1/config/skills|agents|workflows`(返回配置列表)
|
||||
- 客户端 `ConfigSync`: 启动时全量拉取 → 每 5 分钟轮询版本号 → 版本变化时全量拉取
|
||||
- 配置缓存在本地 SQLite(`config_cache` 表)
|
||||
- 离线时使用本地缓存
|
||||
|
||||
**Test scenarios**:
|
||||
- 启动时全量拉取配置
|
||||
- 版本号未变化时不拉取
|
||||
- 版本号变化时全量拉取
|
||||
- 离线时使用本地缓存
|
||||
- 配置正确更新到本地
|
||||
|
||||
**Checkpoint CP7**: 客户端启动后自动同步服务端配置,离线时使用缓存。
|
||||
|
||||
---
|
||||
|
||||
### U8. 服务端终端 + 审批机制
|
||||
|
||||
**Goal**: 新增服务端终端模式,非白名单危险命令需 admin 审批。
|
||||
|
||||
**Dependencies**: U5, U6
|
||||
|
||||
**Files**:
|
||||
- `src/agentkit/server/routes/terminal_server.py`(新建,服务端终端 WebSocket)
|
||||
- `src/agentkit/server/auth/models.py`(增加审批表模型)
|
||||
- `src/agentkit/server/routes/terminal_whitelist.py`(增加全局白名单 + 审批 API)
|
||||
- `src/agentkit/server/app.py`(注册路由)
|
||||
- `tests/unit/test_terminal_server.py`(新建)
|
||||
|
||||
**Approach**:
|
||||
- `WS /api/v1/terminal/server/ws`: 服务端 PTY WebSocket
|
||||
- 权限检查: `TERMINAL_SERVER_USE` + `is_server_terminal_authorized`
|
||||
- 命令执行流程: 黑名单 → 内置白名单 → 全局白名单 → 会话白名单 → 非白名单需 admin 审批
|
||||
- 审批机制: 创建 `terminal_approvals` 记录 → WebSocket 推送 admin → admin 批准/拒绝 → 执行/取消
|
||||
- 审批超时: 5 分钟自动拒绝
|
||||
- 全量审计: 所有命令(含执行/拒绝/审批)记录到 `terminal_audit_logs`
|
||||
- 会话隔离: 每个用户独立 PTY 会话,工作目录隔离
|
||||
- 资源限制: 最大并发会话数、会话超时
|
||||
|
||||
**Test scenarios**:
|
||||
- 有权限用户可连接服务端终端
|
||||
- 无权限用户被拒绝
|
||||
- 全局白名单命令直接执行
|
||||
- 非白名单危险命令创建审批请求
|
||||
- admin 批准后命令执行
|
||||
- admin 拒绝后命令取消
|
||||
- 审批超时自动拒绝
|
||||
- 所有命令审计日志记录
|
||||
- 会话隔离正确
|
||||
|
||||
**Checkpoint CP8**: 服务端终端可用,非白名单命令需 admin 审批,全量审计。
|
||||
|
||||
---
|
||||
|
||||
### U9. 前端终端双模式 UI + 白名单管理
|
||||
|
||||
**Goal**: 前端终端支持本地/服务端模式切换,白名单管理页面。
|
||||
|
||||
**Dependencies**: U6, U8
|
||||
|
||||
**Files**:
|
||||
- `src/agentkit/server/frontend/src/components/terminal/TerminalPanel.vue`(改造,模式切换)
|
||||
- `src/agentkit/server/frontend/src/components/terminal/WhitelistManager.vue`(新建)
|
||||
- `src/agentkit/server/frontend/src/api/terminal.ts`(改造,支持双模式)
|
||||
- `src/agentkit/server/frontend/src/stores/auth.ts`(增加 canUseServerTerminal)
|
||||
|
||||
**Approach**:
|
||||
- 终端面板顶部: `[本地终端] [服务端终端]` Tab 切换
|
||||
- 本地终端: 连接本地 sidecar WebSocket
|
||||
- 服务端终端: 连接服务端 WebSocket(带 JWT)
|
||||
- 非白名单命令: 本地终端用户确认 / 服务端终端显示"等待审批"
|
||||
- 白名单管理页: Tab(我的白名单 / 全局白名单 / 黑名单)
|
||||
|
||||
**Test scenarios**:
|
||||
- 本地终端模式正常执行命令
|
||||
- 服务端终端模式正常执行命令
|
||||
- 模式切换正确切换 WebSocket 连接
|
||||
- 无权限用户看不到服务端终端 Tab
|
||||
- 白名单管理 CRUD 正常工作
|
||||
- 危险命令确认对话框正确显示
|
||||
|
||||
**Checkpoint CP9**: 前端双模式终端可用,白名单管理页面正常。
|
||||
|
||||
---
|
||||
|
||||
## Checkpoint Summary
|
||||
|
||||
| 检查点 | 验证内容 | 通过标准 |
|
||||
|--------|---------|---------|
|
||||
| CP1 | 服务端 LLM 网关 | curl 验证流式和非流式调用 |
|
||||
| CP2 | JWT 认证 | 登录获取 JWT,使用 JWT 访问 API |
|
||||
| CP3 | 客户端 LLM 代理 | 客户端 Agent 通过服务端网关完成对话 |
|
||||
| CP4 | 全链路打通 | 前端登录 → JWT → 服务端 LLM → 对话 |
|
||||
| CP5 | 权限模型 | 不同角色访问不同功能,权限检查生效 |
|
||||
| CP6 | 本地终端白名单 | 危险命令需确认且审计上报 |
|
||||
| CP7 | 配置同步 | 客户端自动同步服务端配置 |
|
||||
| CP8 | 服务端终端 | 非白名单命令需 admin 审批 |
|
||||
| CP9 | 前端双模式终端 | 双模式切换 + 白名单管理 |
|
||||
|
||||
## Risks & Dependencies
|
||||
|
||||
| 风险 | 影响 | 缓解 |
|
||||
|------|------|------|
|
||||
| 流式 fallback 跨网络语义变化 | 客户端可能看到内容跳变 | 服务端首 chunk 前完成 fallback,首 chunk 后不 fallback |
|
||||
| `_verify_api_key` 全局替换遗漏 | 部分路由无权限检查 | 代码审查 + 测试覆盖 |
|
||||
| clients.yaml 兼容 | 两套认证并存增加复杂度 | V1 保留兼容,V2 废弃 |
|
||||
| Tauri sidecar 远程地址注入 | 前端 API 调用目标变化 | base.ts 支持双目标 |
|
||||
| 专家团 LLM 调用放大延迟 | 多 expert 串行 × RTT | 服务端网关连接复用 |
|
||||
|
||||
## Verification Strategy
|
||||
|
||||
每个检查点(CP)必须通过以下验证才能进入下一阶段:
|
||||
1. **单元测试**: 新增代码的单元测试全部通过
|
||||
2. **集成测试**: 关键链路的集成测试通过
|
||||
3. **手动验证**: 按检查点描述的手动验证步骤通过
|
||||
4. **回归测试**: `pytest tests/unit/ -x -q` 不新增失败(已知 `test_rewoo_agent_yaml_loads` 除外)
|
||||
5. **前端类型检查**: `npx vue-tsc --noEmit` 通过
|
||||
6. **代码规范**: `ruff check` 改动文件无新增错误
|
||||
|
|
@ -264,8 +264,7 @@ class SqliteConversationStore:
|
|||
db = await self._ensure_db()
|
||||
|
||||
cursor = await db.execute(
|
||||
"SELECT id, created_at, updated_at FROM conversations "
|
||||
"ORDER BY updated_at DESC LIMIT ?",
|
||||
"SELECT id, created_at, updated_at FROM conversations ORDER BY updated_at DESC LIMIT ?",
|
||||
(limit,),
|
||||
)
|
||||
rows = await cursor.fetchall()
|
||||
|
|
|
|||
|
|
@ -0,0 +1 @@
|
|||
"""AgentKit client-side modules (desktop / sidecar)."""
|
||||
|
|
@ -0,0 +1,331 @@
|
|||
"""Client-side configuration sync engine.
|
||||
|
||||
Pulls skill/agent/workflow configs from the server on startup and polls
|
||||
for changes every 5 minutes. Configs are cached in a local SQLite DB so
|
||||
the client can operate offline.
|
||||
|
||||
Usage::
|
||||
|
||||
from agentkit.client.sync import ConfigSync
|
||||
|
||||
sync = ConfigSync(
|
||||
server_url="http://localhost:8001",
|
||||
token_provider=lambda: jwt_token, # or None for dev mode
|
||||
cache_db_path="~/.agentkit/config_cache.db",
|
||||
)
|
||||
await sync.start() # Initial full pull
|
||||
await sync.poll_loop() # Background polling (or run in a task)
|
||||
configs = sync.get_configs() # Returns cached configs
|
||||
await sync.stop()
|
||||
|
||||
Design (KTD3):
|
||||
- Polling, not WebSocket push (config changes are weekly/monthly)
|
||||
- Full pull on version change (not incremental diff)
|
||||
- SQLite cache for offline operation
|
||||
- 5-minute poll interval (configurable)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import sqlite3
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable
|
||||
|
||||
import httpx
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ── Defaults ──────────────────────────────────────────────────────────
|
||||
|
||||
DEFAULT_POLL_INTERVAL = 300 # 5 minutes
|
||||
DEFAULT_TIMEOUT = 30.0
|
||||
DEFAULT_CACHE_DB_PATH = Path(
|
||||
os.environ.get("AGENTKIT_CONFIG_CACHE", str(Path.home() / ".agentkit" / "config_cache.db"))
|
||||
)
|
||||
|
||||
|
||||
# ── SQLite cache schema ───────────────────────────────────────────────
|
||||
|
||||
_CACHE_SCHEMA = """
|
||||
CREATE TABLE IF NOT EXISTS config_cache (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
"""
|
||||
|
||||
|
||||
# ── ConfigSync ────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class ConfigSync:
|
||||
"""Client-side configuration sync engine.
|
||||
|
||||
Pulls configs from the server, caches them locally, and polls for
|
||||
changes on a configurable interval.
|
||||
|
||||
Attributes:
|
||||
server_url: Base URL of the AgentKit server (e.g. ``http://localhost:8001``).
|
||||
token_provider: Callable that returns the current JWT access token
|
||||
(or ``None`` if not authenticated). Called on each request.
|
||||
cache_db_path: Path to the local SQLite cache file.
|
||||
poll_interval: Seconds between version polls (default 300 = 5 min).
|
||||
timeout: HTTP request timeout in seconds.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
server_url: str,
|
||||
token_provider: Callable[[], str | None] | None = None,
|
||||
cache_db_path: str | Path | None = None,
|
||||
poll_interval: int = DEFAULT_POLL_INTERVAL,
|
||||
timeout: float = DEFAULT_TIMEOUT,
|
||||
) -> None:
|
||||
self.server_url = server_url.rstrip("/")
|
||||
self.token_provider = token_provider
|
||||
self.cache_db_path = Path(cache_db_path) if cache_db_path else DEFAULT_CACHE_DB_PATH
|
||||
self.poll_interval = poll_interval
|
||||
self.timeout = timeout
|
||||
|
||||
self._client: httpx.AsyncClient | None = None
|
||||
self._poll_task: asyncio.Task | None = None
|
||||
self._stopped = False
|
||||
|
||||
# In-memory cache (mirrors the SQLite cache for fast access)
|
||||
self._version: str | None = None
|
||||
self._skills: list[dict[str, Any]] = []
|
||||
self._workflows: list[dict[str, Any]] = []
|
||||
self._last_synced_at: str | None = None
|
||||
|
||||
# ── Lifecycle ─────────────────────────────────────────────────
|
||||
|
||||
async def start(self) -> bool:
|
||||
"""Perform an initial full sync.
|
||||
|
||||
Tries to pull configs from the server. On failure, loads the
|
||||
local cache. Returns ``True`` if the server was reachable.
|
||||
"""
|
||||
self._init_cache_db()
|
||||
self._client = httpx.AsyncClient(timeout=self.timeout)
|
||||
|
||||
# Try server sync first
|
||||
success = await self._pull_all()
|
||||
if not success:
|
||||
logger.info("Server unreachable, loading cached configs")
|
||||
self._load_from_cache()
|
||||
|
||||
return success
|
||||
|
||||
async def stop(self) -> None:
|
||||
"""Stop the polling loop and close the HTTP client."""
|
||||
self._stopped = True
|
||||
if self._poll_task and not self._poll_task.done():
|
||||
self._poll_task.cancel()
|
||||
try:
|
||||
await self._poll_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
if self._client:
|
||||
await self._client.aclose()
|
||||
self._client = None
|
||||
|
||||
async def poll_loop(self) -> None:
|
||||
"""Background polling loop.
|
||||
|
||||
Every ``poll_interval`` seconds, checks the server's config
|
||||
version. If it differs from the cached version, re-pulls all
|
||||
configs. On network errors, keeps the existing cache and retries
|
||||
on the next interval.
|
||||
"""
|
||||
while not self._stopped:
|
||||
try:
|
||||
await asyncio.sleep(self.poll_interval)
|
||||
if self._stopped:
|
||||
break
|
||||
await self._check_and_sync()
|
||||
except asyncio.CancelledError:
|
||||
break
|
||||
except Exception as e:
|
||||
logger.warning(f"Config poll error: {e}")
|
||||
|
||||
def start_polling(self) -> asyncio.Task:
|
||||
"""Start the polling loop as a background task."""
|
||||
self._poll_task = asyncio.create_task(self.poll_loop())
|
||||
return self._poll_task
|
||||
|
||||
# ── Sync operations ───────────────────────────────────────────
|
||||
|
||||
async def _check_and_sync(self) -> bool:
|
||||
"""Check the server version; re-pull if changed.
|
||||
|
||||
Returns ``True`` if configs were re-pulled, ``False`` if the
|
||||
version was unchanged or the server was unreachable.
|
||||
"""
|
||||
if not self._client:
|
||||
return False
|
||||
|
||||
try:
|
||||
resp = await self._client.get(
|
||||
f"{self.server_url}/api/v1/config/version",
|
||||
headers=self._build_headers(),
|
||||
)
|
||||
if resp.status_code != 200:
|
||||
logger.warning(f"Version check failed: HTTP {resp.status_code}")
|
||||
return False
|
||||
|
||||
server_version = resp.json().get("version")
|
||||
if server_version == self._version:
|
||||
logger.debug("Config version unchanged, skipping sync")
|
||||
return False
|
||||
|
||||
logger.info(f"Config version changed: {self._version} → {server_version}")
|
||||
return await self._pull_all()
|
||||
|
||||
except (httpx.HTTPError, OSError) as e:
|
||||
logger.warning(f"Version check network error: {e}")
|
||||
return False
|
||||
|
||||
async def _pull_all(self) -> bool:
|
||||
"""Pull all configs from the server and update the cache.
|
||||
|
||||
Returns ``True`` on success, ``False`` on failure.
|
||||
"""
|
||||
if not self._client:
|
||||
return False
|
||||
|
||||
try:
|
||||
resp = await self._client.get(
|
||||
f"{self.server_url}/api/v1/config/all",
|
||||
headers=self._build_headers(),
|
||||
)
|
||||
if resp.status_code != 200:
|
||||
logger.warning(f"Config pull failed: HTTP {resp.status_code}")
|
||||
return False
|
||||
|
||||
data = resp.json()
|
||||
self._version = data.get("version")
|
||||
self._skills = data.get("skills", [])
|
||||
self._workflows = data.get("workflows", [])
|
||||
self._last_synced_at = data.get("synced_at")
|
||||
|
||||
self._save_to_cache(data)
|
||||
logger.info(
|
||||
f"Synced {len(self._skills)} skills, {len(self._workflows)} workflows "
|
||||
f"(version={self._version})"
|
||||
)
|
||||
return True
|
||||
|
||||
except (httpx.HTTPError, OSError, json.JSONDecodeError) as e:
|
||||
logger.warning(f"Config pull error: {e}")
|
||||
return False
|
||||
|
||||
# ── Cache access ──────────────────────────────────────────────
|
||||
|
||||
def get_version(self) -> str | None:
|
||||
"""Return the current cached config version hash."""
|
||||
return self._version
|
||||
|
||||
def get_skills(self) -> list[dict[str, Any]]:
|
||||
"""Return the cached skill configs."""
|
||||
return list(self._skills)
|
||||
|
||||
def get_workflows(self) -> list[dict[str, Any]]:
|
||||
"""Return the cached workflow configs."""
|
||||
return list(self._workflows)
|
||||
|
||||
def get_all(self) -> dict[str, Any]:
|
||||
"""Return all cached configs as a single dict."""
|
||||
return {
|
||||
"version": self._version,
|
||||
"skills": list(self._skills),
|
||||
"workflows": list(self._workflows),
|
||||
"synced_at": self._last_synced_at,
|
||||
}
|
||||
|
||||
def get_skill(self, name: str) -> dict[str, Any] | None:
|
||||
"""Return a single skill config by name, or ``None``."""
|
||||
for skill in self._skills:
|
||||
if skill.get("name") == name:
|
||||
return skill
|
||||
return None
|
||||
|
||||
def get_workflow(self, workflow_id: str) -> dict[str, Any] | None:
|
||||
"""Return a single workflow config by ID, or ``None``."""
|
||||
for wf in self._workflows:
|
||||
if wf.get("workflow_id") == workflow_id:
|
||||
return wf
|
||||
return None
|
||||
|
||||
# ── Internal helpers ──────────────────────────────────────────
|
||||
|
||||
def _build_headers(self) -> dict[str, str]:
|
||||
"""Build HTTP headers with JWT if available."""
|
||||
headers: dict[str, str] = {"Accept": "application/json"}
|
||||
if self.token_provider:
|
||||
token = self.token_provider()
|
||||
if token:
|
||||
headers["Authorization"] = f"Bearer {token}"
|
||||
return headers
|
||||
|
||||
def _init_cache_db(self) -> None:
|
||||
"""Initialize the SQLite cache database."""
|
||||
self.cache_db_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with sqlite3.connect(str(self.cache_db_path)) as conn:
|
||||
conn.executescript(_CACHE_SCHEMA)
|
||||
conn.commit()
|
||||
|
||||
def _save_to_cache(self, data: dict[str, Any]) -> None:
|
||||
"""Save the synced configs to the local SQLite cache."""
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
with sqlite3.connect(str(self.cache_db_path)) as conn:
|
||||
conn.executescript(_CACHE_SCHEMA)
|
||||
entries = [
|
||||
("version", json.dumps(data.get("version")), now),
|
||||
("skills", json.dumps(data.get("skills", [])), now),
|
||||
("workflows", json.dumps(data.get("workflows", [])), now),
|
||||
("synced_at", json.dumps(data.get("synced_at")), now),
|
||||
]
|
||||
conn.executemany(
|
||||
"INSERT OR REPLACE INTO config_cache (key, value, updated_at) VALUES (?, ?, ?)",
|
||||
entries,
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
def _load_from_cache(self) -> bool:
|
||||
"""Load configs from the local SQLite cache.
|
||||
|
||||
Returns ``True`` if the cache had data, ``False`` otherwise.
|
||||
"""
|
||||
try:
|
||||
with sqlite3.connect(str(self.cache_db_path)) as conn:
|
||||
conn.row_factory = sqlite3.Row
|
||||
cursor = conn.execute(
|
||||
"SELECT key, value FROM config_cache WHERE key IN (?, ?, ?, ?)",
|
||||
("version", "skills", "workflows", "synced_at"),
|
||||
)
|
||||
rows = {row["key"]: row["value"] for row in cursor.fetchall()}
|
||||
|
||||
if not rows:
|
||||
return False
|
||||
|
||||
self._version = json.loads(rows.get("version", "null"))
|
||||
self._skills = json.loads(rows.get("skills", "[]"))
|
||||
self._workflows = json.loads(rows.get("workflows", "[]"))
|
||||
self._last_synced_at = json.loads(rows.get("synced_at", "null"))
|
||||
|
||||
logger.info(
|
||||
f"Loaded {len(self._skills)} skills, {len(self._workflows)} workflows "
|
||||
f"from cache (version={self._version})"
|
||||
)
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to load config cache: {e}")
|
||||
return False
|
||||
|
|
@ -24,11 +24,16 @@ logger = logging.getLogger(__name__)
|
|||
# Pattern to match @board or @board:expert1,expert2 prefix
|
||||
BOARD_PREFIX_PATTERN = re.compile(r"^@board(?::(\S+))?\s*(.*)", re.DOTALL)
|
||||
|
||||
# Pattern to extract optional rounds=N option from the topic
|
||||
BOARD_ROUNDS_PATTERN = re.compile(r"^rounds=(\d+)\s*")
|
||||
|
||||
# Valid expert name: alphanumeric, underscore, hyphen, 1-64 chars
|
||||
_EXPERT_NAME_RE = re.compile(r"^[a-zA-Z0-9_-]{1,64}$")
|
||||
|
||||
MAX_EXPERTS = 10 # Maximum number of experts in a board
|
||||
DEFAULT_TEMPLATE = "private_board"
|
||||
DEFAULT_MAX_ROUNDS = 5
|
||||
MAX_ROUNDS_LIMIT = 50
|
||||
|
||||
|
||||
@dataclass
|
||||
|
|
@ -42,6 +47,7 @@ class BoardRoutingResult:
|
|||
topic: Discussion topic extracted from input
|
||||
use_default_template: Whether to use default private_board template
|
||||
match_method: How the match was made ("explicit_board" | "default_template" | "")
|
||||
max_rounds: Optional user-specified max rounds; None means use server default
|
||||
"""
|
||||
|
||||
matched: bool = False
|
||||
|
|
@ -50,6 +56,7 @@ class BoardRoutingResult:
|
|||
topic: str = ""
|
||||
use_default_template: bool = False
|
||||
match_method: str = ""
|
||||
max_rounds: int | None = None
|
||||
|
||||
|
||||
class BoardRouter:
|
||||
|
|
@ -85,6 +92,13 @@ class BoardRouter:
|
|||
expert_list_str = match.group(1) # e.g., "expert1,expert2" or None
|
||||
topic = match.group(2).strip() # The actual topic content
|
||||
|
||||
# Extract optional rounds=N option from the start of the topic
|
||||
rounds_match = BOARD_ROUNDS_PATTERN.match(topic)
|
||||
if rounds_match:
|
||||
requested_rounds = int(rounds_match.group(1))
|
||||
result.max_rounds = max(1, min(requested_rounds, MAX_ROUNDS_LIMIT))
|
||||
topic = topic[rounds_match.end() :].strip()
|
||||
|
||||
result.matched = True
|
||||
result.board_mode = True
|
||||
result.topic = topic if topic else ""
|
||||
|
|
@ -107,9 +121,7 @@ class BoardRouter:
|
|||
result.use_default_template = False
|
||||
else:
|
||||
# All names invalid — fall back to default template
|
||||
logger.warning(
|
||||
"All expert names invalid, falling back to default template"
|
||||
)
|
||||
logger.warning("All expert names invalid, falling back to default template")
|
||||
result.use_default_template = True
|
||||
result.specified_experts = self._load_default_template_members()
|
||||
else:
|
||||
|
|
|
|||
|
|
@ -38,7 +38,7 @@ class ExpertTeamRoutingResult:
|
|||
team_mode: bool = False
|
||||
specified_experts: list[str] = field(default_factory=list)
|
||||
task_content: str = ""
|
||||
auto_compose: bool = False
|
||||
auto_compose: bool = False # Deprecated: always False, kept for backward compatibility
|
||||
match_method: str = "" # "explicit_team" | ""
|
||||
|
||||
|
||||
|
|
@ -90,7 +90,11 @@ class ExpertTeamRouter:
|
|||
template = self._registry.get(stripped)
|
||||
if template and template.config.bound_skills:
|
||||
# This is a team template — expand to its members
|
||||
result.specified_experts = template.config.bound_skills[:MAX_EXPERTS]
|
||||
members = [n for n in template.config.bound_skills if _EXPERT_NAME_RE.match(n)]
|
||||
if len(members) != len(template.config.bound_skills):
|
||||
invalid = set(template.config.bound_skills) - set(members)
|
||||
logger.warning(f"Invalid expert names in team template rejected: {invalid}")
|
||||
result.specified_experts = members[:MAX_EXPERTS]
|
||||
result.auto_compose = False
|
||||
result.match_method = "explicit_team"
|
||||
return result
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ from agentkit.llm.providers.anthropic import AnthropicProvider
|
|||
from agentkit.llm.providers.openai import OpenAICompatibleProvider
|
||||
from agentkit.llm.providers.tracker import UsageSummary, UsageTracker
|
||||
from agentkit.llm.providers.usage_store import UsageRecord
|
||||
from agentkit.llm.remote_provider import RemoteLLMProvider
|
||||
from agentkit.llm.retry import (
|
||||
CircuitBreaker,
|
||||
CircuitBreakerConfig,
|
||||
|
|
@ -31,6 +32,7 @@ __all__ = [
|
|||
"LLMConfig",
|
||||
"ProviderConfig",
|
||||
"OpenAICompatibleProvider",
|
||||
"RemoteLLMProvider",
|
||||
"RetryConfig",
|
||||
"RetryPolicy",
|
||||
"UsageTracker",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,254 @@
|
|||
"""Remote LLM Provider — forwards requests to a server-side LLM gateway."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from collections.abc import AsyncIterator, Awaitable, Callable
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from agentkit.core.exceptions import LLMProviderError, ModelNotFoundError
|
||||
from agentkit.llm.protocol import (
|
||||
LLMProvider,
|
||||
LLMRequest,
|
||||
LLMResponse,
|
||||
StreamChunk,
|
||||
TokenUsage,
|
||||
ToolCall,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class RemoteLLMProvider(LLMProvider):
|
||||
"""LLM Provider that forwards requests to a server-side LLM gateway.
|
||||
|
||||
This provider does NOT store API keys locally — all LLM calls go through
|
||||
the server's LLM gateway which manages keys, usage tracking, and cost control.
|
||||
|
||||
On 401 responses, the provider can optionally invoke a ``refresh_callback``
|
||||
to obtain a fresh JWT, then retry the request once. This mirrors the
|
||||
browser-side refresh flow in ``auth.ts``.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
server_url: str,
|
||||
auth_token_provider: Callable[[], str],
|
||||
timeout: float = 120.0,
|
||||
refresh_callback: Callable[[], Awaitable[bool]] | None = None,
|
||||
):
|
||||
"""Initialize the remote provider.
|
||||
|
||||
Args:
|
||||
server_url: Base URL of the server (e.g., "https://api.example.com")
|
||||
auth_token_provider: Callable that returns the current JWT token
|
||||
timeout: Request timeout in seconds
|
||||
refresh_callback: Optional async callable that attempts a token
|
||||
refresh and returns ``True`` on success. When provided, a 401
|
||||
response triggers one refresh + retry cycle.
|
||||
"""
|
||||
self._server_url = server_url.rstrip("/")
|
||||
self._auth_token_provider = auth_token_provider
|
||||
self._timeout = timeout
|
||||
self._refresh_callback = refresh_callback
|
||||
self._client = httpx.AsyncClient(timeout=timeout)
|
||||
|
||||
async def close(self) -> None:
|
||||
"""Close the HTTP client connection pool."""
|
||||
await self._client.aclose()
|
||||
|
||||
def _headers(self) -> dict[str, str]:
|
||||
"""Build request headers with JWT authentication."""
|
||||
token = self._auth_token_provider()
|
||||
return {
|
||||
"Authorization": f"Bearer {token}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
def _build_payload(self, request: LLMRequest) -> dict[str, Any]:
|
||||
"""Convert LLMRequest to server API payload."""
|
||||
return {
|
||||
"messages": request.messages,
|
||||
"model": request.model,
|
||||
"temperature": request.temperature,
|
||||
"max_tokens": request.max_tokens,
|
||||
"tools": request.tools,
|
||||
"tool_choice": request.tool_choice,
|
||||
"timeout": request.timeout,
|
||||
}
|
||||
|
||||
def _extract_error_detail(self, resp: httpx.Response) -> str:
|
||||
"""Extract a human-readable error detail from an error response."""
|
||||
try:
|
||||
body = resp.json()
|
||||
except Exception:
|
||||
return resp.text
|
||||
if isinstance(body, dict):
|
||||
if "detail" in body:
|
||||
return str(body["detail"])
|
||||
if "error" in body:
|
||||
return str(body["error"])
|
||||
return str(body)
|
||||
|
||||
def _parse_response(self, data: dict[str, Any], request: LLMRequest) -> LLMResponse:
|
||||
"""Parse server response JSON into an LLMResponse."""
|
||||
usage_data = data.get("usage") or {}
|
||||
usage = TokenUsage(
|
||||
prompt_tokens=usage_data.get("prompt_tokens", 0),
|
||||
completion_tokens=usage_data.get("completion_tokens", 0),
|
||||
)
|
||||
tool_calls: list[ToolCall] = []
|
||||
for tc in data.get("tool_calls") or []:
|
||||
tool_calls.append(
|
||||
ToolCall(
|
||||
id=tc.get("id", ""),
|
||||
name=tc.get("name", ""),
|
||||
arguments=tc.get("arguments") or {},
|
||||
)
|
||||
)
|
||||
return LLMResponse(
|
||||
content=data.get("content", ""),
|
||||
model=data.get("model", request.model),
|
||||
usage=usage,
|
||||
tool_calls=tool_calls,
|
||||
latency_ms=data.get("latency_ms", 0.0),
|
||||
)
|
||||
|
||||
def _parse_chunk(self, data: dict[str, Any], request: LLMRequest) -> StreamChunk:
|
||||
"""Parse a single SSE data payload into a StreamChunk."""
|
||||
usage: TokenUsage | None = None
|
||||
usage_data = data.get("usage")
|
||||
if usage_data:
|
||||
usage = TokenUsage(
|
||||
prompt_tokens=usage_data.get("prompt_tokens", 0),
|
||||
completion_tokens=usage_data.get("completion_tokens", 0),
|
||||
)
|
||||
tool_calls: list[ToolCall] = []
|
||||
for tc in data.get("tool_calls") or []:
|
||||
tool_calls.append(
|
||||
ToolCall(
|
||||
id=tc.get("id", ""),
|
||||
name=tc.get("name", ""),
|
||||
arguments=tc.get("arguments") or {},
|
||||
)
|
||||
)
|
||||
return StreamChunk(
|
||||
content=data.get("content", ""),
|
||||
model=data.get("model", request.model),
|
||||
tool_calls=tool_calls,
|
||||
usage=usage,
|
||||
is_final=data.get("is_final", False),
|
||||
)
|
||||
|
||||
async def _try_refresh(self) -> bool:
|
||||
"""Attempt a token refresh via the configured callback.
|
||||
|
||||
Returns ``True`` if the refresh succeeded (or no callback is
|
||||
configured, in which case 401 is not recoverable).
|
||||
"""
|
||||
if self._refresh_callback is None:
|
||||
return False
|
||||
try:
|
||||
return bool(await self._refresh_callback())
|
||||
except Exception as exc:
|
||||
logger.warning(f"Token refresh callback failed: {exc}")
|
||||
return False
|
||||
|
||||
async def chat(self, request: LLMRequest) -> LLMResponse:
|
||||
"""Send a non-streaming chat request to the server gateway."""
|
||||
url = f"{self._server_url}/api/v1/llm/chat"
|
||||
payload = self._build_payload(request)
|
||||
|
||||
for attempt in range(2): # original + 1 retry after refresh
|
||||
headers = self._headers()
|
||||
try:
|
||||
resp = await self._client.post(url, json=payload, headers=headers)
|
||||
except httpx.TimeoutException as e:
|
||||
raise LLMProviderError("remote", f"Request timeout after {self._timeout}s") from e
|
||||
except httpx.HTTPError as e:
|
||||
raise LLMProviderError("remote", str(e)) from e
|
||||
|
||||
if resp.status_code == 401 and attempt == 0 and await self._try_refresh():
|
||||
logger.info("LLM gateway returned 401; refreshed token, retrying once.")
|
||||
continue
|
||||
if resp.status_code == 401:
|
||||
raise ConnectionError("Authentication failed")
|
||||
if resp.status_code == 404:
|
||||
raise ModelNotFoundError(request.model)
|
||||
if resp.status_code == 502:
|
||||
detail = self._extract_error_detail(resp)
|
||||
raise LLMProviderError("remote", f"Server LLM gateway error: {detail}")
|
||||
if resp.status_code != 200:
|
||||
raise LLMProviderError(
|
||||
"remote", f"Unexpected status {resp.status_code}: {resp.text}"
|
||||
)
|
||||
|
||||
data = resp.json()
|
||||
return self._parse_response(data, request)
|
||||
|
||||
# Should be unreachable — loop exits via return or raise above.
|
||||
raise ConnectionError("Authentication failed")
|
||||
|
||||
async def chat_stream(self, request: LLMRequest) -> AsyncIterator[StreamChunk]:
|
||||
"""Send a streaming chat request to the server gateway.
|
||||
|
||||
Parses SSE response (data: {json}\\n\\n) into StreamChunk objects.
|
||||
Terminates on ``data: [DONE]``. Raises LLMProviderError if the
|
||||
stream contains an error payload.
|
||||
"""
|
||||
url = f"{self._server_url}/api/v1/llm/chat/stream"
|
||||
payload = self._build_payload(request)
|
||||
|
||||
try:
|
||||
for attempt in range(2): # original + 1 retry after refresh
|
||||
headers = self._headers()
|
||||
async with self._client.stream(
|
||||
"POST", url, json=payload, headers=headers
|
||||
) as response:
|
||||
if response.status_code == 401 and attempt == 0 and await self._try_refresh():
|
||||
logger.info(
|
||||
"LLM gateway stream returned 401; refreshed token, retrying once."
|
||||
)
|
||||
continue
|
||||
if response.status_code == 401:
|
||||
raise ConnectionError("Authentication failed")
|
||||
if response.status_code == 404:
|
||||
raise ModelNotFoundError(request.model)
|
||||
if response.status_code == 502:
|
||||
await response.aread()
|
||||
detail = self._extract_error_detail(response)
|
||||
raise LLMProviderError(
|
||||
"remote", f"Server LLM gateway error: {detail}"
|
||||
)
|
||||
if response.status_code != 200:
|
||||
await response.aread()
|
||||
raise LLMProviderError(
|
||||
"remote",
|
||||
f"Unexpected status {response.status_code}: {response.text}",
|
||||
)
|
||||
|
||||
async for line in response.aiter_lines():
|
||||
line = line.strip()
|
||||
if not line or not line.startswith("data: "):
|
||||
continue
|
||||
data_str = line[6:]
|
||||
if data_str == "[DONE]":
|
||||
break
|
||||
try:
|
||||
data = json.loads(data_str)
|
||||
except json.JSONDecodeError:
|
||||
logger.warning(f"Failed to parse SSE line: {data_str}")
|
||||
continue
|
||||
if "error" in data:
|
||||
raise LLMProviderError(
|
||||
"remote",
|
||||
f"Stream error: {data.get('detail', data['error'])}",
|
||||
)
|
||||
yield self._parse_chunk(data, request)
|
||||
# Stream completed successfully — exit retry loop.
|
||||
break
|
||||
except httpx.TimeoutException as e:
|
||||
raise LLMProviderError("remote", f"Request timeout after {self._timeout}s") from e
|
||||
except httpx.HTTPError as e:
|
||||
raise LLMProviderError("remote", str(e)) from e
|
||||
|
|
@ -28,6 +28,7 @@ from agentkit.server.routes import (
|
|||
tasks,
|
||||
skills,
|
||||
llm,
|
||||
llm_gateway as llm_gateway_routes,
|
||||
health,
|
||||
metrics,
|
||||
ws,
|
||||
|
|
@ -39,10 +40,18 @@ from agentkit.server.routes import (
|
|||
skill_management,
|
||||
workflows,
|
||||
chat,
|
||||
config_sync,
|
||||
terminal,
|
||||
terminal_server,
|
||||
terminal_whitelist,
|
||||
settings,
|
||||
experts,
|
||||
system,
|
||||
auth as auth_routes,
|
||||
)
|
||||
from agentkit.server.middleware import APIKeyAuthMiddleware, RateLimitMiddleware
|
||||
from agentkit.server.auth.jwt_utils import get_jwt_secret
|
||||
from agentkit.server.auth.middleware import AuthMiddleware
|
||||
from agentkit.server.middleware import RateLimitMiddleware
|
||||
from agentkit.server.task_store import create_task_store
|
||||
from agentkit.server.runner import BackgroundRunner
|
||||
from agentkit.core.logging import setup_structured_logging
|
||||
|
|
@ -355,9 +364,7 @@ async def lifespan(app: FastAPI):
|
|||
if p.is_dir():
|
||||
loaded = expert_registry.load_from_directory(str(p))
|
||||
if loaded:
|
||||
logger.info(
|
||||
f"Loaded {len(loaded)} ExpertTemplates from {p}"
|
||||
)
|
||||
logger.info(f"Loaded {len(loaded)} ExpertTemplates from {p}")
|
||||
total_loaded += len(loaded)
|
||||
|
||||
app.state.expert_template_registry = expert_registry
|
||||
|
|
@ -563,7 +570,40 @@ def create_app(
|
|||
)
|
||||
|
||||
# Auth middleware
|
||||
app.add_middleware(APIKeyAuthMiddleware, api_key=effective_api_key)
|
||||
# AuthMiddleware (JWT + API Key dual-track) runs *before* the legacy
|
||||
# APIKeyAuthMiddleware so browser sessions (JWT) and programmatic clients
|
||||
# (API key) can coexist during the U2 → U5 migration. Both layers are
|
||||
# kept; AuthMiddleware short-circuits on success, so APIKeyAuthMiddleware
|
||||
# only sees requests that AuthMiddleware could not authenticate.
|
||||
#
|
||||
# JWT auth is only active when AGENTKIT_JWT_SECRET is explicitly set.
|
||||
# When unset, the middleware runs in dev mode (no JWT enforcement) but
|
||||
# the auth routes still issue tokens signed with an ephemeral secret.
|
||||
jwt_secret = get_jwt_secret()
|
||||
client_keys: dict[str, str] = {}
|
||||
try:
|
||||
from agentkit.server.middleware import _load_client_keys
|
||||
|
||||
client_keys = _load_client_keys()
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to load client keys for AuthMiddleware: {e}")
|
||||
|
||||
app.add_middleware(
|
||||
AuthMiddleware,
|
||||
jwt_secret=jwt_secret or "",
|
||||
api_key=effective_api_key,
|
||||
client_keys=client_keys,
|
||||
)
|
||||
# NOTE: APIKeyAuthMiddleware is intentionally NOT added here.
|
||||
# AuthMiddleware above already handles X-API-Key headers (with
|
||||
# constant-time comparison and proper current_user assignment).
|
||||
# Adding APIKeyAuthMiddleware would make it the outermost middleware
|
||||
# (Starlette add_middleware inserts at position 0), causing it to run
|
||||
# BEFORE AuthMiddleware and reject JWT-only requests in production.
|
||||
|
||||
# Expose JWT secret on app.state for routes (None = dev mode, routes will
|
||||
# generate an ephemeral secret via get_or_create_jwt_secret()).
|
||||
app.state.jwt_secret = jwt_secret
|
||||
|
||||
# Rate limiting middleware
|
||||
if effective_rate_limit is not None:
|
||||
|
|
@ -864,8 +904,10 @@ def create_app(
|
|||
app.include_router(tasks.router, prefix="/api/v1")
|
||||
app.include_router(skills.router, prefix="/api/v1")
|
||||
app.include_router(llm.router, prefix="/api/v1")
|
||||
app.include_router(llm_gateway_routes.router, prefix="/api/v1")
|
||||
app.include_router(health.router, prefix="/api/v1")
|
||||
app.include_router(metrics.router, prefix="/api/v1")
|
||||
app.include_router(system.router, prefix="/api/v1")
|
||||
app.include_router(ws.router, prefix="/api/v1")
|
||||
app.include_router(evolution.router, prefix="/api/v1")
|
||||
app.include_router(memory.router, prefix="/api/v1")
|
||||
|
|
@ -875,8 +917,13 @@ def create_app(
|
|||
app.include_router(skill_management.router, prefix="/api/v1")
|
||||
app.include_router(workflows.router, prefix="/api/v1")
|
||||
app.include_router(chat.router, prefix="/api/v1")
|
||||
app.include_router(config_sync.router, prefix="/api/v1")
|
||||
app.include_router(settings.router, prefix="/api/v1")
|
||||
app.include_router(terminal.router, prefix="/api/v1")
|
||||
app.include_router(terminal_server.router, prefix="/api/v1")
|
||||
app.include_router(terminal_whitelist.router, prefix="/api/v1")
|
||||
app.include_router(experts.router, prefix="/api/v1")
|
||||
app.include_router(auth_routes.router, prefix="/api/v1")
|
||||
|
||||
# Serve GUI when in GUI mode
|
||||
gui_mode = os.environ.get("AGENTKIT_GUI_MODE")
|
||||
|
|
|
|||
|
|
@ -0,0 +1,9 @@
|
|||
"""Authentication subsystem (JWT + API Key).
|
||||
|
||||
Modules:
|
||||
- password: bcrypt hashing/verification
|
||||
- models: SQLAlchemy 2 user/api-key/session models + init_auth_db
|
||||
- jwt_utils: access/refresh token pair creation and verification
|
||||
- middleware: AuthMiddleware (JWT + API Key dual-track)
|
||||
- dependencies: FastAPI dependencies (get_current_user, require_authenticated)
|
||||
"""
|
||||
|
|
@ -0,0 +1,248 @@
|
|||
"""FastAPI dependency injections for authentication and authorization.
|
||||
|
||||
These dependencies read the ``current_user`` payload that
|
||||
:class:`AuthMiddleware` stores on ``request.state``.
|
||||
|
||||
- :func:`get_current_user` — returns the user payload or ``None`` (dev mode).
|
||||
- :func:`require_authenticated` — 401 if not authenticated.
|
||||
- :func:`require_permission` — 403 if the user's role lacks the permission.
|
||||
- :func:`require_terminal_authorized` — 403 if the user is not authorized
|
||||
to use the local terminal (checks both the role permission and the
|
||||
per-user ``is_terminal_authorized`` flag from the DB).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any, Callable
|
||||
|
||||
import aiosqlite
|
||||
from fastapi import HTTPException, Request
|
||||
|
||||
from agentkit.server.auth.models import DEFAULT_AUTH_DB_PATH
|
||||
from agentkit.server.auth.permissions import Permission, has_permission, is_dev_mode
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def get_current_user(request: Request) -> dict[str, Any] | None:
|
||||
"""Return the current user payload, or ``None`` in dev mode.
|
||||
|
||||
The payload is set by :class:`AuthMiddleware` and contains
|
||||
``user_id``, ``username``, and ``role``. When no auth middleware is
|
||||
active (e.g. dev mode with no keys configured), returns ``None``.
|
||||
"""
|
||||
return getattr(request.state, "current_user", None)
|
||||
|
||||
|
||||
async def require_authenticated(request: Request) -> dict[str, Any]:
|
||||
"""Require an authenticated user. Raises 401 if not authenticated.
|
||||
|
||||
Use this as a FastAPI dependency for endpoints that need *any*
|
||||
logged-in user but do not require a specific role.
|
||||
"""
|
||||
user = await get_current_user(request)
|
||||
if user is None:
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail="Authentication required",
|
||||
)
|
||||
return user
|
||||
|
||||
|
||||
def require_permission(*permissions: Permission) -> Callable[..., Any]:
|
||||
"""Build a FastAPI dependency that requires the given permissions.
|
||||
|
||||
The user must have *all* of the given permissions (AND semantics).
|
||||
For OR semantics, call the dependency multiple times or use
|
||||
:func:`require_any_permission`.
|
||||
|
||||
Usage::
|
||||
|
||||
from agentkit.server.auth.dependencies import require_permission
|
||||
from agentkit.server.auth.permissions import Permission
|
||||
|
||||
@router.post("/users", dependencies=[Depends(require_permission(Permission.USER_MANAGE))])
|
||||
async def create_user(...): ...
|
||||
|
||||
Dev mode policy:
|
||||
- If the user is ``None`` (dev mode) AND none of the required
|
||||
permissions are "high-risk" (terminal / user / system), the
|
||||
request is allowed with a synthetic dev-mode user.
|
||||
- High-risk permissions always require authentication, even in
|
||||
dev mode.
|
||||
|
||||
Args:
|
||||
permissions: One or more permissions the user must have.
|
||||
|
||||
Returns:
|
||||
A FastAPI dependency function.
|
||||
"""
|
||||
|
||||
async def _dependency(request: Request) -> dict[str, Any]:
|
||||
user = await get_current_user(request)
|
||||
|
||||
# Dev mode: no authenticated user
|
||||
if is_dev_mode(user):
|
||||
high_risk = any(
|
||||
p
|
||||
in {
|
||||
Permission.TERMINAL_LOCAL_USE,
|
||||
Permission.TERMINAL_SERVER_USE,
|
||||
Permission.TERMINAL_WHITELIST_MANAGE,
|
||||
Permission.USER_MANAGE,
|
||||
Permission.SYSTEM_CONFIG,
|
||||
}
|
||||
for p in permissions
|
||||
)
|
||||
if high_risk:
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail="Authentication required for this endpoint",
|
||||
)
|
||||
# Synthetic dev-mode user with admin-like permissions for non-high-risk
|
||||
return {"user_id": None, "username": "dev", "role": "admin", "dev_mode": True}
|
||||
|
||||
# Authenticated user — check each permission
|
||||
for perm in permissions:
|
||||
if not has_permission(user, perm):
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail=f"Permission denied: requires {perm.value}",
|
||||
)
|
||||
return user
|
||||
|
||||
return _dependency
|
||||
|
||||
|
||||
def require_any_permission(*permissions: Permission) -> Callable[..., Any]:
|
||||
"""Build a FastAPI dependency that requires at least one of the permissions.
|
||||
|
||||
Similar to :func:`require_permission` but uses OR semantics.
|
||||
"""
|
||||
|
||||
async def _dependency(request: Request) -> dict[str, Any]:
|
||||
user = await get_current_user(request)
|
||||
|
||||
if is_dev_mode(user):
|
||||
high_risk = any(
|
||||
p
|
||||
in {
|
||||
Permission.TERMINAL_LOCAL_USE,
|
||||
Permission.TERMINAL_SERVER_USE,
|
||||
Permission.TERMINAL_WHITELIST_MANAGE,
|
||||
Permission.USER_MANAGE,
|
||||
Permission.SYSTEM_CONFIG,
|
||||
}
|
||||
for p in permissions
|
||||
)
|
||||
if high_risk:
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail="Authentication required for this endpoint",
|
||||
)
|
||||
return {"user_id": None, "username": "dev", "role": "admin", "dev_mode": True}
|
||||
|
||||
if not any(has_permission(user, p) for p in permissions):
|
||||
needed = " or ".join(p.value for p in permissions)
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail=f"Permission denied: requires {needed}",
|
||||
)
|
||||
return user
|
||||
|
||||
return _dependency
|
||||
|
||||
|
||||
async def _resolve_db_path(request: Request):
|
||||
"""Resolve the auth DB path from app.state or the default."""
|
||||
path = getattr(request.app.state, "auth_db_path", None)
|
||||
if path:
|
||||
from pathlib import Path
|
||||
|
||||
return Path(path)
|
||||
return DEFAULT_AUTH_DB_PATH
|
||||
|
||||
|
||||
async def require_terminal_authorized(request: Request) -> dict[str, Any]:
|
||||
"""Require a user authorized to use the local terminal.
|
||||
|
||||
This checks both:
|
||||
1. The user's role has ``TERMINAL_LOCAL_USE`` permission.
|
||||
2. The user's ``is_terminal_authorized`` flag is True in the DB.
|
||||
|
||||
The DB check ensures that an admin can revoke terminal access in
|
||||
real-time without waiting for the JWT to expire.
|
||||
"""
|
||||
user = await require_permission(Permission.TERMINAL_LOCAL_USE)(request)
|
||||
|
||||
if user.get("dev_mode"):
|
||||
return user
|
||||
|
||||
user_id = user.get("user_id")
|
||||
if not user_id:
|
||||
raise HTTPException(status_code=401, detail="Authentication required")
|
||||
|
||||
db_path = await _resolve_db_path(request)
|
||||
async with aiosqlite.connect(str(db_path)) as db:
|
||||
db.row_factory = aiosqlite.Row
|
||||
cursor = await db.execute(
|
||||
"SELECT is_terminal_authorized, is_active FROM users WHERE id = ?",
|
||||
(user_id,),
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
|
||||
if row is None:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
if not bool(row["is_active"]):
|
||||
raise HTTPException(status_code=403, detail="Account is disabled")
|
||||
|
||||
if not bool(row["is_terminal_authorized"]):
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="Terminal access not authorized for this account",
|
||||
)
|
||||
|
||||
return user
|
||||
|
||||
|
||||
async def require_server_terminal_authorized(request: Request) -> dict[str, Any]:
|
||||
"""Require a user authorized to use the server terminal.
|
||||
|
||||
Checks:
|
||||
1. The user's role has ``TERMINAL_SERVER_USE`` permission.
|
||||
2. The user's ``is_server_terminal_authorized`` flag is True in the DB.
|
||||
3. The user's ``is_active`` flag is True (account not disabled).
|
||||
"""
|
||||
user = await require_permission(Permission.TERMINAL_SERVER_USE)(request)
|
||||
|
||||
if user.get("dev_mode"):
|
||||
return user
|
||||
|
||||
user_id = user.get("user_id")
|
||||
if not user_id:
|
||||
raise HTTPException(status_code=401, detail="Authentication required")
|
||||
|
||||
db_path = await _resolve_db_path(request)
|
||||
async with aiosqlite.connect(str(db_path)) as db:
|
||||
db.row_factory = aiosqlite.Row
|
||||
cursor = await db.execute(
|
||||
"SELECT is_server_terminal_authorized, is_active FROM users WHERE id = ?",
|
||||
(user_id,),
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
|
||||
if row is None:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
if not bool(row["is_active"]):
|
||||
raise HTTPException(status_code=403, detail="Account is disabled")
|
||||
|
||||
if not bool(row["is_server_terminal_authorized"]):
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="Server terminal access not authorized for this account",
|
||||
)
|
||||
|
||||
return user
|
||||
|
|
@ -0,0 +1,163 @@
|
|||
"""JWT issuance and verification utilities.
|
||||
|
||||
Tokens are HS256-signed with a secret read from the ``AGENTKIT_JWT_SECRET``
|
||||
environment variable. In dev mode (no secret configured) a random secret is
|
||||
generated in-process and a warning is logged — this secret is *not* persisted
|
||||
and will invalidate all tokens on restart, so it must never be used in
|
||||
production.
|
||||
|
||||
Access tokens are short-lived (15 min) and carry ``type="access"``.
|
||||
Refresh tokens are long-lived (7 days) and carry ``type="refresh"``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import secrets
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any
|
||||
|
||||
import jwt
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Token lifetimes
|
||||
ACCESS_TOKEN_TTL = timedelta(minutes=15)
|
||||
REFRESH_TOKEN_TTL = timedelta(days=7)
|
||||
|
||||
# JWT algorithm
|
||||
JWT_ALGORITHM = "HS256"
|
||||
|
||||
|
||||
@dataclass
|
||||
class TokenPair:
|
||||
"""A signed access + refresh JWT pair with their expiry timestamps."""
|
||||
|
||||
access_token: str
|
||||
refresh_token: str
|
||||
access_expires_at: datetime
|
||||
refresh_expires_at: datetime
|
||||
|
||||
|
||||
def get_jwt_secret() -> str | None:
|
||||
"""Return the configured JWT secret, or ``None`` if not configured.
|
||||
|
||||
Reads from the ``AGENTKIT_JWT_SECRET`` env var. Returns ``None`` when
|
||||
the env var is not set — callers (middleware, routes) decide how to
|
||||
handle the unset case. Use :func:`get_or_create_jwt_secret` when a
|
||||
non-empty secret is required (e.g. for signing tokens in dev mode).
|
||||
"""
|
||||
secret = os.environ.get("AGENTKIT_JWT_SECRET")
|
||||
if secret:
|
||||
return secret
|
||||
logger.warning(
|
||||
"AGENTKIT_JWT_SECRET is not set. JWT auth is disabled in the "
|
||||
"middleware; token-signing routes will use an ephemeral secret "
|
||||
"that is invalidated on restart. Set AGENTKIT_JWT_SECRET for "
|
||||
"production use."
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def get_or_create_jwt_secret() -> str:
|
||||
"""Return the configured JWT secret, or generate an ephemeral one.
|
||||
|
||||
Use this when a non-empty secret is required (e.g. signing tokens).
|
||||
For middleware configuration, prefer :func:`get_jwt_secret` so that
|
||||
the absence of a secret disables JWT auth (dev mode).
|
||||
"""
|
||||
secret = os.environ.get("AGENTKIT_JWT_SECRET")
|
||||
if secret:
|
||||
return secret
|
||||
ephemeral = secrets.token_urlsafe(48)
|
||||
logger.warning(
|
||||
"AGENTKIT_JWT_SECRET is not set — generated an ephemeral dev secret. "
|
||||
"All JWTs will be invalidated on process restart. "
|
||||
"Set AGENTKIT_JWT_SECRET in the environment for production use."
|
||||
)
|
||||
return ephemeral
|
||||
|
||||
|
||||
def create_token_pair(
|
||||
user_id: str,
|
||||
username: str,
|
||||
role: str,
|
||||
secret: str,
|
||||
*,
|
||||
now: datetime | None = None,
|
||||
) -> TokenPair:
|
||||
"""Create a signed access + refresh JWT pair.
|
||||
|
||||
Args:
|
||||
user_id: Subject (user id) — stored as ``sub``.
|
||||
username: Username claim.
|
||||
role: Role claim (e.g. ``member``, ``admin``).
|
||||
secret: HS256 signing secret.
|
||||
now: Override the issued-at time (for testing). Defaults to UTC now.
|
||||
|
||||
Returns:
|
||||
A :class:`TokenPair` with both signed tokens and their expiry times.
|
||||
"""
|
||||
if not secret:
|
||||
raise ValueError("JWT secret must not be empty")
|
||||
|
||||
issued_at = now or datetime.now(timezone.utc)
|
||||
access_exp = issued_at + ACCESS_TOKEN_TTL
|
||||
refresh_exp = issued_at + REFRESH_TOKEN_TTL
|
||||
|
||||
access_payload: dict[str, Any] = {
|
||||
"sub": user_id,
|
||||
"username": username,
|
||||
"role": role,
|
||||
"type": "access",
|
||||
"iat": int(issued_at.timestamp()),
|
||||
"exp": int(access_exp.timestamp()),
|
||||
}
|
||||
refresh_payload: dict[str, Any] = {
|
||||
"sub": user_id,
|
||||
"username": username,
|
||||
"role": role,
|
||||
"type": "refresh",
|
||||
"iat": int(issued_at.timestamp()),
|
||||
"exp": int(refresh_exp.timestamp()),
|
||||
}
|
||||
|
||||
access_token = jwt.encode(access_payload, secret, algorithm=JWT_ALGORITHM)
|
||||
refresh_token = jwt.encode(refresh_payload, secret, algorithm=JWT_ALGORITHM)
|
||||
|
||||
# PyJWT >= 2 returns str; older versions returned bytes. Normalize.
|
||||
if isinstance(access_token, bytes):
|
||||
access_token = access_token.decode("utf-8")
|
||||
if isinstance(refresh_token, bytes):
|
||||
refresh_token = refresh_token.decode("utf-8")
|
||||
|
||||
return TokenPair(
|
||||
access_token=access_token,
|
||||
refresh_token=refresh_token,
|
||||
access_expires_at=access_exp,
|
||||
refresh_expires_at=refresh_exp,
|
||||
)
|
||||
|
||||
|
||||
def verify_token(token: str, secret: str) -> dict[str, Any]:
|
||||
"""Verify a JWT and return its payload.
|
||||
|
||||
Args:
|
||||
token: The JWT string to verify.
|
||||
secret: The HS256 signing secret.
|
||||
|
||||
Returns:
|
||||
The decoded payload as a dict (contains ``sub``, ``username``,
|
||||
``role``, ``type``, ``iat``, ``exp``).
|
||||
|
||||
Raises:
|
||||
jwt.InvalidTokenError: If the token is malformed, expired, or has
|
||||
an invalid signature. Subclasses include ``ExpiredSignatureError``
|
||||
and ``DecodeError``.
|
||||
"""
|
||||
if not secret:
|
||||
raise jwt.InvalidTokenError("JWT secret must not be empty")
|
||||
|
||||
return jwt.decode(token, secret, algorithms=[JWT_ALGORITHM])
|
||||
|
|
@ -0,0 +1,191 @@
|
|||
"""Authentication middleware — JWT + API Key dual-track.
|
||||
|
||||
This middleware runs *before* the legacy :class:`APIKeyAuthMiddleware` so that
|
||||
JWT-authenticated requests (browser sessions) and API-Key-authenticated
|
||||
requests (programmatic clients) can coexist during the U2 → U5 migration.
|
||||
|
||||
Authentication order per request:
|
||||
|
||||
1. **Whitelist** — paths in :data:`AuthMiddleware.WHITELIST_PATHS` pass through.
|
||||
2. **JWT** — ``Authorization: Bearer <token>`` header. On success, the decoded
|
||||
payload is stored on ``request.state.current_user``.
|
||||
3. **API Key** — ``X-API-Key`` header. Compared in constant time against the
|
||||
global ``api_key`` and any ``client_keys`` (loaded from ``clients.yaml``).
|
||||
4. **Dev mode** — when no JWT secret, no global API key, and no client keys
|
||||
are configured, all requests pass through (with a one-time warning).
|
||||
5. Otherwise → ``401 Unauthorized``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hmac
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
import jwt
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import JSONResponse
|
||||
|
||||
from agentkit.server.auth.jwt_utils import verify_token
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AuthMiddleware(BaseHTTPMiddleware):
|
||||
"""Dual-track JWT + API Key authentication middleware.
|
||||
|
||||
Args:
|
||||
app: ASGI app to wrap.
|
||||
jwt_secret: HS256 signing secret. Empty string disables JWT auth.
|
||||
api_key: Global API key (e.g. from ``server.api_key`` in agentkit.yaml).
|
||||
client_keys: Mapping of ``client_name -> api_key`` (from clients.yaml).
|
||||
"""
|
||||
|
||||
WHITELIST_PATHS = (
|
||||
"/api/v1/health",
|
||||
"/api/v1/auth/login",
|
||||
"/api/v1/auth/refresh",
|
||||
"/api/v1/auth/logout",
|
||||
"/docs",
|
||||
"/openapi.json",
|
||||
"/redoc",
|
||||
)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
app,
|
||||
jwt_secret: str = "",
|
||||
api_key: str | None = None,
|
||||
client_keys: dict[str, str] | None = None,
|
||||
) -> None:
|
||||
super().__init__(app)
|
||||
self._jwt_secret = jwt_secret or ""
|
||||
self._api_key = api_key
|
||||
self._client_keys: dict[str, str] = dict(client_keys) if client_keys else {}
|
||||
self._dev_mode_warned = False
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Internal helpers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _is_whitelisted(self, path: str) -> bool:
|
||||
"""Return True if ``path`` starts with any whitelisted prefix."""
|
||||
return any(path.startswith(p) for p in self.WHITELIST_PATHS)
|
||||
|
||||
def _is_dev_mode(self) -> bool:
|
||||
"""Dev mode = no JWT secret, no global API key, no client keys."""
|
||||
return not self._jwt_secret and self._api_key is None and not self._client_keys
|
||||
|
||||
def _verify_jwt(self, token: str) -> dict[str, Any] | None:
|
||||
"""Verify a JWT bearer token. Returns payload or None."""
|
||||
if not self._jwt_secret:
|
||||
return None
|
||||
try:
|
||||
payload = verify_token(token, self._jwt_secret)
|
||||
except jwt.InvalidTokenError:
|
||||
return None
|
||||
# Only accept access tokens for request auth (not refresh tokens)
|
||||
if payload.get("type") != "access":
|
||||
return None
|
||||
return payload
|
||||
|
||||
def _verify_api_key(self, provided_key: str) -> tuple[bool, str | None]:
|
||||
"""Verify an API key in constant time against all known keys.
|
||||
|
||||
Returns ``(matched, client_name)``. ``client_name`` is the key from
|
||||
``client_keys`` that matched, or ``None`` for the global API key.
|
||||
"""
|
||||
if not provided_key:
|
||||
return False, None
|
||||
candidates: list[tuple[str, str | None]] = []
|
||||
if self._api_key:
|
||||
candidates.append((self._api_key, None))
|
||||
for name, key in self._client_keys.items():
|
||||
candidates.append((key, name))
|
||||
if not candidates:
|
||||
return False, None
|
||||
provided_bytes = provided_key.encode("utf-8")
|
||||
# Compare against ALL candidates (no short-circuit) to maintain
|
||||
# constant-time behavior at the multi-key level.
|
||||
matched_name: str | None = None
|
||||
found = False
|
||||
for candidate, name in candidates:
|
||||
if hmac.compare_digest(provided_bytes, candidate.encode("utf-8")):
|
||||
found = True
|
||||
matched_name = name
|
||||
return found, matched_name
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Dispatch
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def dispatch(self, request: Request, call_next):
|
||||
path = request.url.path
|
||||
|
||||
# 1. Whitelist
|
||||
if self._is_whitelisted(path):
|
||||
return await call_next(request)
|
||||
|
||||
# 2. JWT (Authorization: Bearer <token>) — also accept ?token=<jwt>
|
||||
# query parameter for WebSocket clients where setting headers is
|
||||
# not possible. The query param is only honored for /ws paths to
|
||||
# limit its exposure in access logs.
|
||||
auth_header = request.headers.get("Authorization", "")
|
||||
if auth_header.lower().startswith("bearer "):
|
||||
token = auth_header[7:].strip()
|
||||
payload = self._verify_jwt(token)
|
||||
if payload is not None:
|
||||
request.state.current_user = {
|
||||
"user_id": payload.get("sub"),
|
||||
"username": payload.get("username"),
|
||||
"role": payload.get("role"),
|
||||
}
|
||||
return await call_next(request)
|
||||
# Fall through to API key check, then 401
|
||||
elif path.startswith("/api/v1/ws") or path.startswith("/ws"):
|
||||
token = request.query_params.get("token")
|
||||
if token:
|
||||
payload = self._verify_jwt(token)
|
||||
if payload is not None:
|
||||
request.state.current_user = {
|
||||
"user_id": payload.get("sub"),
|
||||
"username": payload.get("username"),
|
||||
"role": payload.get("role"),
|
||||
}
|
||||
return await call_next(request)
|
||||
|
||||
# 3. API Key (X-API-Key header)
|
||||
api_key = request.headers.get("X-API-Key")
|
||||
if api_key:
|
||||
matched, client_name = self._verify_api_key(api_key)
|
||||
if matched:
|
||||
# Set current_user so RBAC (require_permission) works.
|
||||
# API key clients get 'operator' role by default — enough
|
||||
# for chat/KB/workflow but not user management.
|
||||
request.state.current_user = {
|
||||
"user_id": None,
|
||||
"username": client_name or "api_client",
|
||||
"role": "operator",
|
||||
}
|
||||
return await call_next(request)
|
||||
|
||||
# 4. Dev mode (no auth configured)
|
||||
if self._is_dev_mode():
|
||||
if not self._dev_mode_warned:
|
||||
logger.warning(
|
||||
"AuthMiddleware running in dev mode (no JWT secret, "
|
||||
"no API key, no client keys). All requests pass through. "
|
||||
"Set AGENTKIT_JWT_SECRET or server.api_key for production."
|
||||
)
|
||||
self._dev_mode_warned = True
|
||||
return await call_next(request)
|
||||
|
||||
# 5. Unauthorized
|
||||
return JSONResponse(
|
||||
status_code=401,
|
||||
content={
|
||||
"error": "Unauthorized",
|
||||
"message": "Valid JWT (Authorization: Bearer) or API key (X-API-Key) required",
|
||||
},
|
||||
)
|
||||
|
|
@ -0,0 +1,355 @@
|
|||
"""SQLAlchemy 2 user / API-key / session models for the auth subsystem.
|
||||
|
||||
V1 uses SQLite (via aiosqlite) for zero-config local deployments. UUIDs are
|
||||
stored as ``String(36)`` so the same schema works on both SQLite and
|
||||
PostgreSQL without dialect-specific types.
|
||||
|
||||
Use :func:`init_auth_db` to create the tables on startup.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
from collections.abc import Mapping
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import aiosqlite
|
||||
from sqlalchemy import String
|
||||
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Default auth DB path: project-local data/auth.db
|
||||
# (mirrors portal.py's pattern of using Path(__file__).parents[4] / "data" / ...)
|
||||
_PROJECT_ROOT = Path(__file__).parents[4]
|
||||
DEFAULT_AUTH_DB_PATH = Path(os.environ.get("AGENTKIT_AUTH_DB", _PROJECT_ROOT / "data" / "auth.db"))
|
||||
|
||||
|
||||
def _now_iso() -> str:
|
||||
"""Return current UTC time as ISO 8601 string."""
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SQLAlchemy 2 declarative models
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
"""Declarative base for auth models."""
|
||||
|
||||
|
||||
class UserModel(Base):
|
||||
"""User account record.
|
||||
|
||||
Attributes mirror the V1 user schema: UUID id, unique username/email,
|
||||
bcrypt password hash, role, active flag, terminal-authorization flags,
|
||||
and audit timestamps. ``created_by`` is a self-reference (nullable) used
|
||||
when an admin creates another user.
|
||||
"""
|
||||
|
||||
__tablename__ = "users"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True)
|
||||
username: Mapped[str] = mapped_column(String(64), unique=True, nullable=False, index=True)
|
||||
email: Mapped[str] = mapped_column(String(255), unique=True, nullable=False, index=True)
|
||||
password_hash: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
role: Mapped[str] = mapped_column(String(32), nullable=False, default="member")
|
||||
is_active: Mapped[bool] = mapped_column(default=True, nullable=False)
|
||||
is_terminal_authorized: Mapped[bool] = mapped_column(default=False, nullable=False)
|
||||
is_server_terminal_authorized: Mapped[bool] = mapped_column(default=False, nullable=False)
|
||||
created_at: Mapped[str] = mapped_column(String(64), nullable=False, default=_now_iso)
|
||||
updated_at: Mapped[str] = mapped_column(String(64), nullable=False, default=_now_iso)
|
||||
last_login_at: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
created_by: Mapped[str | None] = mapped_column(String(36), nullable=True)
|
||||
|
||||
|
||||
class UserApiKeyModel(Base):
|
||||
"""Per-user API key (issued via ``agentkit pair`` or UI).
|
||||
|
||||
The full key is never stored — only its SHA-256 hash. ``key_prefix`` is
|
||||
the first 16 chars of the plaintext key, shown to users for identification.
|
||||
"""
|
||||
|
||||
__tablename__ = "user_api_keys"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True)
|
||||
user_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
|
||||
key_hash: Mapped[str] = mapped_column(String(64), unique=True, nullable=False, index=True)
|
||||
key_prefix: Mapped[str] = mapped_column(String(16), nullable=False)
|
||||
name: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
created_at: Mapped[str] = mapped_column(String(64), nullable=False, default=_now_iso)
|
||||
last_used_at: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
expires_at: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
is_revoked: Mapped[bool] = mapped_column(default=False, nullable=False)
|
||||
|
||||
|
||||
class UserSessionModel(Base):
|
||||
"""Refresh-token session record.
|
||||
|
||||
Stores the SHA-256 hash of the refresh token (never the plaintext).
|
||||
``revoked_at`` is set on logout / forced revocation.
|
||||
"""
|
||||
|
||||
__tablename__ = "user_sessions"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True)
|
||||
user_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
|
||||
refresh_token_hash: Mapped[str] = mapped_column(
|
||||
String(64), unique=True, nullable=False, index=True
|
||||
)
|
||||
device_info: Mapped[str] = mapped_column(String(2048), nullable=False, default="{}")
|
||||
created_at: Mapped[str] = mapped_column(String(64), nullable=False, default=_now_iso)
|
||||
expires_at: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
revoked_at: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
|
||||
|
||||
class TerminalWhitelistUserModel(Base):
|
||||
"""Per-user terminal command whitelist.
|
||||
|
||||
Each row is a command prefix (e.g. ``"docker"``, ``"git push"``) that
|
||||
the user has approved for direct execution without confirmation.
|
||||
``scope`` is ``"user"`` for the user-managed list. (Reserved for
|
||||
future ``"global"`` scope managed by admins — see U8.)
|
||||
"""
|
||||
|
||||
__tablename__ = "terminal_whitelist_user"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True)
|
||||
user_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
|
||||
command_pattern: Mapped[str] = mapped_column(String(512), nullable=False)
|
||||
scope: Mapped[str] = mapped_column(String(16), nullable=False, default="user")
|
||||
created_at: Mapped[str] = mapped_column(String(64), nullable=False, default=_now_iso)
|
||||
created_by: Mapped[str | None] = mapped_column(String(36), nullable=True)
|
||||
|
||||
|
||||
class TerminalBlocklistModel(Base):
|
||||
"""Admin-managed terminal command blocklist.
|
||||
|
||||
Commands matching any pattern here are always rejected, regardless
|
||||
of other whitelist membership. Patterns are matched as command-prefix
|
||||
(case-insensitive).
|
||||
"""
|
||||
|
||||
__tablename__ = "terminal_blocklist"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True)
|
||||
command_pattern: Mapped[str] = mapped_column(String(512), nullable=False, unique=True)
|
||||
reason: Mapped[str | None] = mapped_column(String(512), nullable=True)
|
||||
created_at: Mapped[str] = mapped_column(String(64), nullable=False, default=_now_iso)
|
||||
created_by: Mapped[str | None] = mapped_column(String(36), nullable=True)
|
||||
|
||||
|
||||
class TerminalAuditLogModel(Base):
|
||||
"""Audit log for every terminal command decision.
|
||||
|
||||
``decision`` is one of: ``executed`` (ran without confirmation),
|
||||
``confirmed`` (ran after user confirmation), ``rejected`` (blocked
|
||||
by user or blocklist), ``denied`` (blocked by safety check).
|
||||
"""
|
||||
|
||||
__tablename__ = "terminal_audit_logs"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True)
|
||||
user_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
|
||||
username: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
session_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
|
||||
command: Mapped[str] = mapped_column(String(4096), nullable=False)
|
||||
decision: Mapped[str] = mapped_column(String(16), nullable=False)
|
||||
reason: Mapped[str | None] = mapped_column(String(512), nullable=True)
|
||||
cwd: Mapped[str | None] = mapped_column(String(1024), nullable=True)
|
||||
exit_code: Mapped[int | None] = mapped_column(nullable=True)
|
||||
terminal_mode: Mapped[str] = mapped_column(String(16), nullable=False, default="local")
|
||||
created_at: Mapped[str] = mapped_column(String(64), nullable=False, default=_now_iso)
|
||||
|
||||
|
||||
class TerminalApprovalModel(Base):
|
||||
"""Approval request for a non-whitelisted server-terminal command.
|
||||
|
||||
Lifecycle:
|
||||
- ``pending``: Created when a user runs a non-whitelisted command
|
||||
on the server terminal. Admins see it in their approval queue.
|
||||
- ``approved``: Admin approved → command executes.
|
||||
- ``rejected``: Admin rejected → command cancelled.
|
||||
- ``expired``: 5-minute timeout reached → auto-rejected.
|
||||
- ``cancelled``: User cancelled the request before admin acted.
|
||||
"""
|
||||
|
||||
__tablename__ = "terminal_approvals"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True)
|
||||
user_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
|
||||
username: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
session_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
|
||||
command: Mapped[str] = mapped_column(String(4096), nullable=False)
|
||||
reason: Mapped[str | None] = mapped_column(String(512), nullable=True)
|
||||
status: Mapped[str] = mapped_column(String(16), nullable=False, default="pending")
|
||||
reviewer_id: Mapped[str | None] = mapped_column(String(36), nullable=True)
|
||||
reviewer_username: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
review_note: Mapped[str | None] = mapped_column(String(512), nullable=True)
|
||||
created_at: Mapped[str] = mapped_column(String(64), nullable=False, default=_now_iso)
|
||||
reviewed_at: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
expires_at: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Schema DDL (kept in sync with the models above for aiosqlite bootstrap)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_SCHEMA_SQL = """
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id TEXT PRIMARY KEY,
|
||||
username TEXT NOT NULL UNIQUE,
|
||||
email TEXT NOT NULL UNIQUE,
|
||||
password_hash TEXT NOT NULL,
|
||||
role TEXT NOT NULL DEFAULT 'member',
|
||||
is_active INTEGER NOT NULL DEFAULT 1,
|
||||
is_terminal_authorized INTEGER NOT NULL DEFAULT 0,
|
||||
is_server_terminal_authorized INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
last_login_at TEXT,
|
||||
created_by TEXT
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_users_username ON users(username);
|
||||
CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_api_keys (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
key_hash TEXT NOT NULL UNIQUE,
|
||||
key_prefix TEXT NOT NULL,
|
||||
name TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
last_used_at TEXT,
|
||||
expires_at TEXT,
|
||||
is_revoked INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_user_api_keys_user_id ON user_api_keys(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_user_api_keys_key_hash ON user_api_keys(key_hash);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_sessions (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
refresh_token_hash TEXT NOT NULL UNIQUE,
|
||||
device_info TEXT NOT NULL DEFAULT '{}',
|
||||
created_at TEXT NOT NULL,
|
||||
expires_at TEXT NOT NULL,
|
||||
revoked_at TEXT
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_user_sessions_user_id ON user_sessions(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_user_sessions_refresh_token_hash
|
||||
ON user_sessions(refresh_token_hash);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS terminal_whitelist_user (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
command_pattern TEXT NOT NULL,
|
||||
scope TEXT NOT NULL DEFAULT 'user',
|
||||
created_at TEXT NOT NULL,
|
||||
created_by TEXT
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_terminal_whitelist_user_user_id
|
||||
ON terminal_whitelist_user(user_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS terminal_blocklist (
|
||||
id TEXT PRIMARY KEY,
|
||||
command_pattern TEXT NOT NULL UNIQUE,
|
||||
reason TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
created_by TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS terminal_audit_logs (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT,
|
||||
username TEXT,
|
||||
session_id TEXT NOT NULL,
|
||||
command TEXT NOT NULL,
|
||||
decision TEXT NOT NULL,
|
||||
reason TEXT,
|
||||
cwd TEXT,
|
||||
exit_code INTEGER,
|
||||
terminal_mode TEXT NOT NULL DEFAULT 'local',
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_terminal_audit_logs_user_id
|
||||
ON terminal_audit_logs(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_terminal_audit_logs_session_id
|
||||
ON terminal_audit_logs(session_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_terminal_audit_logs_created_at
|
||||
ON terminal_audit_logs(created_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS terminal_approvals (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
username TEXT NOT NULL,
|
||||
session_id TEXT NOT NULL,
|
||||
command TEXT NOT NULL,
|
||||
reason TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
reviewer_id TEXT,
|
||||
reviewer_username TEXT,
|
||||
review_note TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
reviewed_at TEXT,
|
||||
expires_at TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_terminal_approvals_user_id
|
||||
ON terminal_approvals(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_terminal_approvals_session_id
|
||||
ON terminal_approvals(session_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_terminal_approvals_status
|
||||
ON terminal_approvals(status);
|
||||
"""
|
||||
|
||||
|
||||
async def init_auth_db(db_path: str | Path | None = None) -> Path:
|
||||
"""Create auth tables if they do not exist.
|
||||
|
||||
Uses aiosqlite directly (no SQLAlchemy engine) for a lightweight,
|
||||
zero-config bootstrap that mirrors :class:`SqliteConversationStore`.
|
||||
|
||||
Args:
|
||||
db_path: Path to the SQLite file. Defaults to
|
||||
:data:`DEFAULT_AUTH_DB_PATH` (``data/auth.db`` under the project
|
||||
root, overridable via ``AGENTKIT_AUTH_DB`` env var).
|
||||
|
||||
Returns:
|
||||
The resolved :class:`Path` to the auth DB file.
|
||||
"""
|
||||
path = Path(db_path) if db_path is not None else DEFAULT_AUTH_DB_PATH
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
async with aiosqlite.connect(str(path)) as db:
|
||||
await db.execute("PRAGMA journal_mode=WAL")
|
||||
await db.executescript(_SCHEMA_SQL)
|
||||
await db.commit()
|
||||
|
||||
logger.info(f"Auth DB initialized at {path}")
|
||||
return path
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Row → dict helpers (used by routes to avoid exposing ORM internals)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def user_row_to_dict(row: aiosqlite.Row | Mapping[str, object]) -> dict[str, Any]:
|
||||
"""Convert a ``users`` row into a JSON-safe dict."""
|
||||
return {
|
||||
"id": row["id"],
|
||||
"username": row["username"],
|
||||
"email": row["email"],
|
||||
"role": row["role"],
|
||||
"is_active": bool(row["is_active"]),
|
||||
"is_terminal_authorized": bool(row["is_terminal_authorized"]),
|
||||
"is_server_terminal_authorized": bool(row["is_server_terminal_authorized"]),
|
||||
"created_at": row["created_at"],
|
||||
"updated_at": row["updated_at"],
|
||||
"last_login_at": row["last_login_at"],
|
||||
"created_by": row["created_by"],
|
||||
}
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
"""Password hashing utilities (bcrypt)."""
|
||||
|
||||
import bcrypt
|
||||
|
||||
|
||||
def hash_password(password: str) -> str:
|
||||
"""Hash a password using bcrypt with cost factor 12.
|
||||
|
||||
Args:
|
||||
password: Plain-text password.
|
||||
|
||||
Returns:
|
||||
bcrypt hash as a utf-8 string (includes salt + cost factor).
|
||||
"""
|
||||
salt = bcrypt.gensalt(rounds=12)
|
||||
return bcrypt.hashpw(password.encode("utf-8"), salt).decode("utf-8")
|
||||
|
||||
|
||||
def verify_password(password: str, password_hash: str) -> bool:
|
||||
"""Verify a password against a bcrypt hash.
|
||||
|
||||
Args:
|
||||
password: Plain-text password to verify.
|
||||
password_hash: bcrypt hash string (as produced by :func:`hash_password`).
|
||||
|
||||
Returns:
|
||||
True if the password matches the hash, False otherwise.
|
||||
Also returns False on malformed hash strings to avoid leaking
|
||||
exception details to callers.
|
||||
"""
|
||||
try:
|
||||
return bcrypt.checkpw(
|
||||
password.encode("utf-8"),
|
||||
password_hash.encode("utf-8"),
|
||||
)
|
||||
except (ValueError, TypeError):
|
||||
return False
|
||||
|
|
@ -0,0 +1,147 @@
|
|||
"""Permission model and role-permission mapping (U5).
|
||||
|
||||
This module defines the canonical set of permissions and the default
|
||||
permission grants for each role. It is the single source of truth for
|
||||
both the server (FastAPI dependencies) and the client (frontend store).
|
||||
|
||||
Roles
|
||||
-----
|
||||
- ``member`` — regular user: chat, KB query, workflow execution.
|
||||
- ``operator`` — power user: member permissions + local terminal +
|
||||
KB write + whitelist management.
|
||||
- ``admin`` — full access: operator permissions + server terminal +
|
||||
user management + system config.
|
||||
|
||||
The ``is_terminal_authorized`` / ``is_server_terminal_authorized`` flags
|
||||
on :class:`UserModel` act as *additional gates* on top of the role-based
|
||||
permission. A user must have *both* the role permission AND the personal
|
||||
authorization flag to use a terminal. This allows admins to grant
|
||||
terminal access on a per-user basis without changing the user's role.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from enum import Enum
|
||||
from typing import Any
|
||||
|
||||
|
||||
class Permission(str, Enum):
|
||||
"""Permission bits.
|
||||
|
||||
The string values are stable identifiers used in JWT payloads,
|
||||
the frontend store, and audit logs. Do not rename existing values
|
||||
(only add new ones) once released.
|
||||
"""
|
||||
|
||||
# Chat / Agent
|
||||
CHAT = "CHAT"
|
||||
|
||||
# Knowledge base
|
||||
KB_QUERY = "KB_QUERY"
|
||||
KB_WRITE = "KB_WRITE"
|
||||
|
||||
# Workflows
|
||||
WORKFLOW_EXECUTE = "WORKFLOW_EXECUTE"
|
||||
|
||||
# Terminal — local (client sidecar)
|
||||
TERMINAL_LOCAL_USE = "TERMINAL_LOCAL_USE"
|
||||
|
||||
# Terminal — server (server PTY)
|
||||
TERMINAL_SERVER_USE = "TERMINAL_SERVER_USE"
|
||||
|
||||
# Terminal whitelist management (user's own + global if admin)
|
||||
TERMINAL_WHITELIST_MANAGE = "TERMINAL_WHITELIST_MANAGE"
|
||||
|
||||
# User management (create / disable / change role)
|
||||
USER_MANAGE = "USER_MANAGE"
|
||||
|
||||
# System configuration (server settings, LLM keys)
|
||||
SYSTEM_CONFIG = "SYSTEM_CONFIG"
|
||||
|
||||
|
||||
# Default role → permission mapping.
|
||||
#
|
||||
# Order matters for documentation but not for lookup (we use set membership).
|
||||
ROLE_PERMISSIONS: dict[str, frozenset[Permission]] = {
|
||||
"member": frozenset(
|
||||
{
|
||||
Permission.CHAT,
|
||||
Permission.KB_QUERY,
|
||||
Permission.WORKFLOW_EXECUTE,
|
||||
}
|
||||
),
|
||||
"operator": frozenset(
|
||||
{
|
||||
Permission.CHAT,
|
||||
Permission.KB_QUERY,
|
||||
Permission.KB_WRITE,
|
||||
Permission.WORKFLOW_EXECUTE,
|
||||
Permission.TERMINAL_LOCAL_USE,
|
||||
Permission.TERMINAL_WHITELIST_MANAGE,
|
||||
}
|
||||
),
|
||||
"admin": frozenset(
|
||||
{
|
||||
Permission.CHAT,
|
||||
Permission.KB_QUERY,
|
||||
Permission.KB_WRITE,
|
||||
Permission.WORKFLOW_EXECUTE,
|
||||
Permission.TERMINAL_LOCAL_USE,
|
||||
Permission.TERMINAL_SERVER_USE,
|
||||
Permission.TERMINAL_WHITELIST_MANAGE,
|
||||
Permission.USER_MANAGE,
|
||||
Permission.SYSTEM_CONFIG,
|
||||
}
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def get_role_permissions(role: str | None) -> frozenset[Permission]:
|
||||
"""Return the permission set for a role.
|
||||
|
||||
Unknown roles get an empty set (no permissions). ``None`` (dev mode,
|
||||
no authenticated user) also returns an empty set — callers must
|
||||
handle the dev-mode bypass separately.
|
||||
"""
|
||||
if role is None:
|
||||
return frozenset()
|
||||
return ROLE_PERMISSIONS.get(role, frozenset())
|
||||
|
||||
|
||||
def has_permission(user: dict[str, Any] | None, permission: Permission) -> bool:
|
||||
"""Check if a user payload has a specific permission.
|
||||
|
||||
Args:
|
||||
user: The ``current_user`` dict set by :class:`AuthMiddleware`,
|
||||
containing at least ``role``. May be ``None`` in dev mode.
|
||||
permission: The required permission.
|
||||
|
||||
Returns:
|
||||
``True`` if the user's role grants the permission.
|
||||
``False`` if the user is ``None`` or the role lacks the permission.
|
||||
|
||||
Note:
|
||||
This only checks the *role-based* permission. Terminal endpoints
|
||||
must additionally check ``is_terminal_authorized`` /
|
||||
``is_server_terminal_authorized`` on the user record (re-fetched
|
||||
from the DB, not the JWT payload, to reflect real-time changes).
|
||||
"""
|
||||
if user is None:
|
||||
return False
|
||||
role = user.get("role")
|
||||
return permission in get_role_permissions(role)
|
||||
|
||||
|
||||
def is_dev_mode(user: dict[str, Any] | None) -> bool:
|
||||
"""Return True if the request is in dev mode (no authenticated user).
|
||||
|
||||
Dev mode is when ``AuthMiddleware`` passes through requests without
|
||||
authentication (no JWT secret, no API key configured). In dev mode,
|
||||
the ``current_user`` attribute is not set, so ``user`` is ``None``.
|
||||
|
||||
Dev mode policy:
|
||||
- Read-only / low-risk endpoints (chat, KB query, workflows) are allowed.
|
||||
- High-risk endpoints (terminal, user management, system config) require
|
||||
explicit authentication even in dev mode.
|
||||
"""
|
||||
return user is None
|
||||
|
|
@ -0,0 +1,418 @@
|
|||
"""Six-layer terminal whitelist + audit log helpers.
|
||||
|
||||
Whitelist evaluation priority (highest first):
|
||||
1. Blocklist (admin-managed, always rejects)
|
||||
2. Built-in safe whitelist (``_SAFE_COMMAND_PREFIXES`` from shell.py)
|
||||
3. Global whitelist (admin-managed, DB-backed)
|
||||
4. User whitelist (per-user, stored in DB, cached in-memory)
|
||||
5. Session whitelist (per-session, in-memory only)
|
||||
6. Danger detection (``_is_dangerous`` — requires confirmation)
|
||||
|
||||
Security note: shell operators (&&, ;, |, $(), backticks) are ALWAYS
|
||||
checked before any whitelist match returns SAFE. A whitelisted prefix
|
||||
like ``git push`` will NOT auto-execute ``git push && rm -rf /`` —
|
||||
the shell operator triggers danger detection regardless of whitelist
|
||||
membership.
|
||||
|
||||
This module is import-safe: it does not open any DB connection at import
|
||||
time. All DB access is via explicit ``db_path`` arguments.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import shlex
|
||||
import uuid
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
|
||||
import aiosqlite
|
||||
|
||||
from agentkit.tools.shell import (
|
||||
_DANGEROUS_BINARY_FLAGS,
|
||||
_SAFE_COMMAND_PREFIXES,
|
||||
)
|
||||
from agentkit.server.auth.models import _now_iso
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ── Shell operator detection ─────────────────────────────────────────
|
||||
|
||||
# Shell chain/substitution operators that bypass whitelist prefix matching.
|
||||
# If ANY of these appear in a command, the command is treated as dangerous
|
||||
# regardless of whitelist membership — the whitelist pattern only matches
|
||||
# the prefix, not the chained payload.
|
||||
_SHELL_OPERATORS = ("&&", "||", ";", "|", "$(", "`", ">", "<", "\n")
|
||||
|
||||
|
||||
def _has_shell_operators(command: str) -> bool:
|
||||
"""Return True if the command contains shell chain/substitution operators.
|
||||
|
||||
These operators allow chaining arbitrary commands behind a whitelisted
|
||||
prefix (e.g. ``git push && rm -rf /``). When present, the command must
|
||||
go through danger detection even if the prefix matches a whitelist.
|
||||
"""
|
||||
return any(op in command for op in _SHELL_OPERATORS)
|
||||
|
||||
|
||||
# ── Decision constants ────────────────────────────────────────────────
|
||||
|
||||
DECISION_EXECUTED = "executed" # Ran without confirmation (whitelisted)
|
||||
DECISION_CONFIRMED = "confirmed" # Ran after user confirmation
|
||||
DECISION_REJECTED = "rejected" # User rejected confirmation prompt
|
||||
DECISION_BLOCKED = "blocked" # Blocked by blocklist
|
||||
DECISION_DENIED = "denied" # Blocked by safety check (non-whitelist)
|
||||
|
||||
|
||||
# ── Pattern matching ─────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _extract_binary(command: str) -> str | None:
|
||||
"""Extract the binary name (basename) from a command string.
|
||||
|
||||
Returns ``None`` if the command cannot be parsed.
|
||||
"""
|
||||
try:
|
||||
tokens = shlex.split(command.strip())
|
||||
if not tokens:
|
||||
return None
|
||||
return os.path.basename(tokens[0]).lower()
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def _matches_pattern(command: str, pattern: str) -> bool:
|
||||
"""Check if a command matches a whitelist/blocklist pattern.
|
||||
|
||||
A pattern matches if:
|
||||
- The pattern is a single word and equals the command's binary name.
|
||||
- The pattern is a multi-word phrase and the command starts with it.
|
||||
|
||||
Matching is case-insensitive.
|
||||
"""
|
||||
cmd_lower = command.strip().lower()
|
||||
pattern_lower = pattern.strip().lower()
|
||||
if not pattern_lower:
|
||||
return False
|
||||
|
||||
if " " in pattern_lower:
|
||||
return cmd_lower.startswith(pattern_lower)
|
||||
|
||||
binary = _extract_binary(command)
|
||||
if binary is None:
|
||||
return False
|
||||
return binary == pattern_lower
|
||||
|
||||
|
||||
def _matches_any_pattern(command: str, patterns: list[str]) -> tuple[bool, str | None]:
|
||||
"""Return (matched, matched_pattern) for the first matching pattern."""
|
||||
for pattern in patterns:
|
||||
if _matches_pattern(command, pattern):
|
||||
return True, pattern
|
||||
return False, None
|
||||
|
||||
|
||||
# ── Built-in whitelist check ──────────────────────────────────────────
|
||||
|
||||
|
||||
def _matches_builtin_whitelist(command: str) -> bool:
|
||||
"""Check if a command matches the built-in safe whitelist.
|
||||
|
||||
Mirrors the logic in :func:`terminal._is_single_command_dangerous` —
|
||||
a command is built-in safe if its binary (or full prefix) appears in
|
||||
``_SAFE_COMMAND_PREFIXES``.
|
||||
"""
|
||||
cmd_lower = command.strip().lower()
|
||||
if not cmd_lower:
|
||||
return False
|
||||
|
||||
binary = _extract_binary(command)
|
||||
if binary is None:
|
||||
return False
|
||||
|
||||
for prefix in _SAFE_COMMAND_PREFIXES:
|
||||
prefix_lower = prefix.lower().strip()
|
||||
if " " in prefix_lower:
|
||||
if cmd_lower.startswith(prefix_lower):
|
||||
return True
|
||||
else:
|
||||
if binary == prefix_lower:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
# ── DB-backed whitelist / blocklist lookups ───────────────────────────
|
||||
|
||||
|
||||
async def _fetch_user_whitelist(db_path: str | Path, user_id: str) -> list[str]:
|
||||
"""Fetch a user's whitelist command patterns from the DB."""
|
||||
patterns: list[str] = []
|
||||
try:
|
||||
async with aiosqlite.connect(str(db_path)) as db:
|
||||
cursor = await db.execute(
|
||||
"SELECT command_pattern FROM terminal_whitelist_user "
|
||||
"WHERE user_id = ? AND scope = 'user'",
|
||||
(user_id,),
|
||||
)
|
||||
rows = await cursor.fetchall()
|
||||
patterns = [row[0] for row in rows]
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to fetch user whitelist for {user_id}: {e}")
|
||||
return patterns
|
||||
|
||||
|
||||
async def _fetch_global_whitelist(db_path: str | Path) -> list[str]:
|
||||
"""Fetch the global (admin-managed) whitelist command patterns."""
|
||||
patterns: list[str] = []
|
||||
try:
|
||||
async with aiosqlite.connect(str(db_path)) as db:
|
||||
cursor = await db.execute(
|
||||
"SELECT command_pattern FROM terminal_whitelist_user WHERE scope = 'global'"
|
||||
)
|
||||
rows = await cursor.fetchall()
|
||||
patterns = [row[0] for row in rows]
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to fetch global whitelist: {e}")
|
||||
return patterns
|
||||
|
||||
|
||||
async def _fetch_blocklist(db_path: str | Path) -> list[tuple[str, str | None]]:
|
||||
"""Fetch the global blocklist (pattern, reason) pairs from the DB."""
|
||||
patterns: list[tuple[str, str | None]] = []
|
||||
try:
|
||||
async with aiosqlite.connect(str(db_path)) as db:
|
||||
cursor = await db.execute(
|
||||
"SELECT command_pattern, reason FROM terminal_blocklist"
|
||||
)
|
||||
rows = await cursor.fetchall()
|
||||
patterns = [(row[0], row[1]) for row in rows]
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to fetch blocklist: {e}")
|
||||
return patterns
|
||||
|
||||
|
||||
# ── Audit log writer ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
async def write_audit_log(
|
||||
db_path: str | Path,
|
||||
*,
|
||||
user_id: str | None,
|
||||
username: str | None,
|
||||
session_id: str,
|
||||
command: str,
|
||||
decision: str,
|
||||
reason: str | None = None,
|
||||
cwd: str | None = None,
|
||||
exit_code: int | None = None,
|
||||
terminal_mode: str = "local",
|
||||
) -> None:
|
||||
"""Write a terminal audit log entry.
|
||||
|
||||
Failures are logged but never raised — auditing must not break the
|
||||
terminal flow.
|
||||
"""
|
||||
try:
|
||||
async with aiosqlite.connect(str(db_path)) as db:
|
||||
await db.execute(
|
||||
"""INSERT INTO terminal_audit_logs
|
||||
(id, user_id, username, session_id, command, decision,
|
||||
reason, cwd, exit_code, terminal_mode, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||
(
|
||||
str(uuid.uuid4()),
|
||||
user_id,
|
||||
username,
|
||||
session_id,
|
||||
command[:4096],
|
||||
decision,
|
||||
reason,
|
||||
cwd,
|
||||
exit_code,
|
||||
terminal_mode,
|
||||
_now_iso(),
|
||||
),
|
||||
)
|
||||
await db.commit()
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to write terminal audit log: {e}")
|
||||
|
||||
|
||||
# ── Top-level safety check ───────────────────────────────────────────
|
||||
|
||||
|
||||
class SafetyDecision:
|
||||
"""Result of a six-layer whitelist safety check."""
|
||||
|
||||
__slots__ = ("safe", "decision", "reason", "matched_layer")
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
safe: bool,
|
||||
decision: str,
|
||||
reason: str | None = None,
|
||||
matched_layer: str | None = None,
|
||||
) -> None:
|
||||
self.safe = safe
|
||||
self.decision = decision
|
||||
self.reason = reason
|
||||
self.matched_layer = matched_layer
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return (
|
||||
f"SafetyDecision(safe={self.safe}, decision={self.decision!r}, "
|
||||
f"matched_layer={self.matched_layer!r})"
|
||||
)
|
||||
|
||||
|
||||
async def check_command_safety_v2(
|
||||
command: str,
|
||||
*,
|
||||
session_id: str,
|
||||
session_whitelist: set[str],
|
||||
user_id: str | None,
|
||||
db_path: str | Path | None,
|
||||
is_dangerous_fn: Callable[[str], bool] | None = None,
|
||||
) -> SafetyDecision:
|
||||
"""Evaluate a command against the six-layer whitelist.
|
||||
|
||||
Args:
|
||||
command: The raw command string.
|
||||
session_id: Current terminal session ID (for logging).
|
||||
session_whitelist: In-memory per-session whitelist (binary names
|
||||
or command prefixes). Mutated by the caller after confirmation.
|
||||
user_id: The authenticated user's ID (``None`` in dev mode).
|
||||
db_path: Path to the auth DB. If ``None``, DB-backed layers are
|
||||
skipped (dev mode without DB).
|
||||
is_dangerous_fn: Override for the danger-detection function.
|
||||
Defaults to :func:`terminal._is_dangerous`.
|
||||
|
||||
Returns:
|
||||
A :class:`SafetyDecision` describing whether the command may
|
||||
execute and which layer matched.
|
||||
"""
|
||||
if is_dangerous_fn is None:
|
||||
from agentkit.server.routes.terminal import _is_dangerous
|
||||
|
||||
is_dangerous_fn = _is_dangerous
|
||||
|
||||
command_stripped = command.strip()
|
||||
if not command_stripped:
|
||||
return SafetyDecision(
|
||||
safe=False,
|
||||
decision=DECISION_DENIED,
|
||||
reason="空命令",
|
||||
matched_layer=None,
|
||||
)
|
||||
|
||||
# Security: detect shell operators early. Commands with shell operators
|
||||
# (&&, ;, |, $(), etc.) can chain arbitrary payloads behind a whitelisted
|
||||
# prefix. Such commands must ALWAYS go through danger detection, even if
|
||||
# the prefix matches a whitelist entry.
|
||||
has_shell_ops = _has_shell_operators(command_stripped)
|
||||
|
||||
# Layer 1: Blocklist (admin-managed, always rejects)
|
||||
if db_path is not None:
|
||||
blocklist = await _fetch_blocklist(db_path)
|
||||
for pattern, reason in blocklist:
|
||||
if _matches_pattern(command_stripped, pattern):
|
||||
return SafetyDecision(
|
||||
safe=False,
|
||||
decision=DECISION_BLOCKED,
|
||||
reason=reason or f"命令匹配黑名单规则: {pattern}",
|
||||
matched_layer="blocklist",
|
||||
)
|
||||
|
||||
# Layer 2: Built-in safe whitelist
|
||||
# Shell operators bypass ALL whitelist layers — even built-in safe
|
||||
# commands like "ls" cannot chain "ls && rm -rf /" without confirmation.
|
||||
if not has_shell_ops and _matches_builtin_whitelist(command_stripped):
|
||||
return SafetyDecision(
|
||||
safe=True,
|
||||
decision=DECISION_EXECUTED,
|
||||
matched_layer="builtin",
|
||||
)
|
||||
|
||||
# Layer 3: Global whitelist (admin-managed, DB-backed)
|
||||
if not has_shell_ops and db_path is not None:
|
||||
global_patterns = await _fetch_global_whitelist(db_path)
|
||||
matched, _ = _matches_any_pattern(command_stripped, global_patterns)
|
||||
if matched:
|
||||
return SafetyDecision(
|
||||
safe=True,
|
||||
decision=DECISION_EXECUTED,
|
||||
matched_layer="global_whitelist",
|
||||
)
|
||||
|
||||
# Layer 4: User whitelist (DB-backed)
|
||||
if not has_shell_ops and db_path is not None and user_id is not None:
|
||||
user_patterns = await _fetch_user_whitelist(db_path, user_id)
|
||||
matched, _ = _matches_any_pattern(command_stripped, user_patterns)
|
||||
if matched:
|
||||
return SafetyDecision(
|
||||
safe=True,
|
||||
decision=DECISION_EXECUTED,
|
||||
matched_layer="user_whitelist",
|
||||
)
|
||||
|
||||
# Layer 5: Session whitelist (in-memory)
|
||||
if not has_shell_ops:
|
||||
for wl_entry in session_whitelist:
|
||||
if _matches_pattern(command_stripped, wl_entry):
|
||||
return SafetyDecision(
|
||||
safe=True,
|
||||
decision=DECISION_EXECUTED,
|
||||
matched_layer="session_whitelist",
|
||||
)
|
||||
|
||||
# Layer 6: Danger detection
|
||||
if not is_dangerous_fn(command_stripped):
|
||||
return SafetyDecision(
|
||||
safe=True,
|
||||
decision=DECISION_EXECUTED,
|
||||
matched_layer="danger_check_passed",
|
||||
)
|
||||
|
||||
# Not whitelisted and dangerous — require confirmation (local) or
|
||||
# approval (server terminal).
|
||||
return SafetyDecision(
|
||||
safe=False,
|
||||
decision=DECISION_DENIED,
|
||||
reason="此命令被识别为潜在危险操作,需要用户确认",
|
||||
matched_layer=None,
|
||||
)
|
||||
|
||||
|
||||
# ── Whitelist mutation helpers ────────────────────────────────────────
|
||||
|
||||
|
||||
def compute_whitelist_entry(command: str) -> str | None:
|
||||
"""Compute the whitelist entry to add for a confirmed command.
|
||||
|
||||
For binaries with dangerous-flag variants (e.g. ``rm -rf``), we return
|
||||
``None`` — dangerous binaries must ALWAYS require confirmation and are
|
||||
never added to the session whitelist. For other binaries, we add just
|
||||
the binary name so subsequent calls with the same binary auto-execute.
|
||||
|
||||
Returns ``None`` if the command cannot be parsed or contains shell
|
||||
operators (such commands should never be whitelisted).
|
||||
"""
|
||||
try:
|
||||
tokens = shlex.split(command.strip())
|
||||
if not tokens:
|
||||
return None
|
||||
# Never whitelist commands with shell operators — they can chain
|
||||
# arbitrary payloads and must always require confirmation.
|
||||
if _has_shell_operators(command.strip()):
|
||||
return None
|
||||
binary = os.path.basename(tokens[0]).lower()
|
||||
if binary in _DANGEROUS_BINARY_FLAGS:
|
||||
# Dangerous binaries always require confirmation — do NOT
|
||||
# add to session whitelist.
|
||||
return None
|
||||
return binary
|
||||
except ValueError:
|
||||
return None
|
||||
|
|
@ -41,6 +41,7 @@ declare module 'vue' {
|
|||
ASelectOption: typeof import('ant-design-vue/es')['SelectOption']
|
||||
ASpace: typeof import('ant-design-vue/es')['Space']
|
||||
ASpin: typeof import('ant-design-vue/es')['Spin']
|
||||
AssistantText: typeof import('./src/components/chat/messages/AssistantText.vue')['default']
|
||||
ASwitch: typeof import('ant-design-vue/es')['Switch']
|
||||
ATable: typeof import('ant-design-vue/es')['Table']
|
||||
ATabPane: typeof import('ant-design-vue/es')['TabPane']
|
||||
|
|
@ -48,8 +49,14 @@ declare module 'vue' {
|
|||
ATag: typeof import('ant-design-vue/es')['Tag']
|
||||
ATextarea: typeof import('ant-design-vue/es')['Textarea']
|
||||
AUploadDragger: typeof import('ant-design-vue/es')['UploadDragger']
|
||||
BoardBannerCard: typeof import('./src/components/chat/messages/BoardBannerCard.vue')['default']
|
||||
BoardConclusionCard: typeof import('./src/components/chat/messages/BoardConclusionCard.vue')['default']
|
||||
BoardMeetingModal: typeof import('./src/components/chat/BoardMeetingModal.vue')['default']
|
||||
BoardRoundCard: typeof import('./src/components/chat/messages/BoardRoundCard.vue')['default']
|
||||
BoardStatusView: typeof import('./src/components/chat/BoardStatusView.vue')['default']
|
||||
ChatInput: typeof import('./src/components/chat/ChatInput.vue')['default']
|
||||
ChatMessage: typeof import('./src/components/chat/ChatMessage.vue')['default']
|
||||
ChatPreviewShell: typeof import('./src/components/preview/ChatPreviewShell.vue')['default']
|
||||
ChatSidebar: typeof import('./src/components/chat/ChatSidebar.vue')['default']
|
||||
CodeDiffViewer: typeof import('./src/components/code/CodeDiffViewer.vue')['default']
|
||||
CommandHistory: typeof import('./src/components/terminal/CommandHistory.vue')['default']
|
||||
|
|
@ -57,15 +64,19 @@ declare module 'vue' {
|
|||
ContextPill: typeof import('./src/components/chat/ContextPill.vue')['default']
|
||||
DashboardOverview: typeof import('./src/components/evolution/DashboardOverview.vue')['default']
|
||||
DocumentUpload: typeof import('./src/components/kb/DocumentUpload.vue')['default']
|
||||
ErrorCard: typeof import('./src/components/chat/messages/ErrorCard.vue')['default']
|
||||
ExperiencePanel: typeof import('./src/components/evolution/ExperiencePanel.vue')['default']
|
||||
ExperienceTimeline: typeof import('./src/components/evolution/ExperienceTimeline.vue')['default']
|
||||
ExpertMessage: typeof import('./src/components/chat/ExpertMessage.vue')['default']
|
||||
ExpertTeamView: typeof import('./src/components/chat/ExpertTeamView.vue')['default']
|
||||
FileAttachment: typeof import('./src/components/chat/messages/FileAttachment.vue')['default']
|
||||
FilePreview: typeof import('./src/components/chat/FilePreview.vue')['default']
|
||||
FileTree: typeof import('./src/components/code/FileTree.vue')['default']
|
||||
FlowCanvas: typeof import('./src/components/workflow/FlowCanvas.vue')['default']
|
||||
IconNav: typeof import('./src/components/layout/IconNav.vue')['default']
|
||||
KnowledgeTab: typeof import('./src/components/layout/tabs/KnowledgeTab.vue')['default']
|
||||
MentionDropdown: typeof import('./src/components/chat/MentionDropdown.vue')['default']
|
||||
MessageShell: typeof import('./src/components/chat/messages/MessageShell.vue')['default']
|
||||
MetricsChart: typeof import('./src/components/evolution/MetricsChart.vue')['default']
|
||||
MetricsPanel: typeof import('./src/components/evolution/MetricsPanel.vue')['default']
|
||||
NodePalette: typeof import('./src/components/workflow/NodePalette.vue')['default']
|
||||
|
|
@ -77,22 +88,37 @@ declare module 'vue' {
|
|||
PlanVisualization: typeof import('./src/components/chat/PlanVisualization.vue')['default']
|
||||
PropertyPanel: typeof import('./src/components/workflow/PropertyPanel.vue')['default']
|
||||
QuadrantPanel: typeof import('./src/components/layout/QuadrantPanel.vue')['default']
|
||||
RightPanel: typeof import('./src/components/layout/RightPanel.vue')['default']
|
||||
RouterLink: typeof import('vue-router')['RouterLink']
|
||||
RouterView: typeof import('vue-router')['RouterView']
|
||||
Scene1Welcome: typeof import('./src/components/preview/scenes/Scene1Welcome.vue')['default']
|
||||
Scene2UserAssistant: typeof import('./src/components/preview/scenes/Scene2UserAssistant.vue')['default']
|
||||
Scene3TeamPlan: typeof import('./src/components/preview/scenes/Scene3TeamPlan.vue')['default']
|
||||
Scene4BoardDiscussion: typeof import('./src/components/preview/scenes/Scene4BoardDiscussion.vue')['default']
|
||||
Scene5ToolCall: typeof import('./src/components/preview/scenes/Scene5ToolCall.vue')['default']
|
||||
Scene6Error: typeof import('./src/components/preview/scenes/Scene6Error.vue')['default']
|
||||
SearchTest: typeof import('./src/components/kb/SearchTest.vue')['default']
|
||||
SideNav: typeof import('./src/components/layout/SideNav.vue')['default']
|
||||
SkillCard: typeof import('./src/components/skills/SkillCard.vue')['default']
|
||||
SkillDetail: typeof import('./src/components/skills/SkillDetail.vue')['default']
|
||||
SkillNode: typeof import('./src/components/workflow/SkillNode.vue')['default']
|
||||
SkillsTab: typeof import('./src/components/layout/tabs/SkillsTab.vue')['default']
|
||||
SourceConfig: typeof import('./src/components/kb/SourceConfig.vue')['default']
|
||||
SplashScreen: typeof import('./src/components/layout/SplashScreen.vue')['default']
|
||||
SplitPane: typeof import('./src/components/layout/SplitPane.vue')['default']
|
||||
SystemMonitorPanel: typeof import('./src/components/layout/SystemMonitorPanel.vue')['default']
|
||||
SystemTab: typeof import('./src/components/layout/tabs/SystemTab.vue')['default']
|
||||
TeamModal: typeof import('./src/components/chat/TeamModal.vue')['default']
|
||||
TeamPlanCard: typeof import('./src/components/chat/messages/TeamPlanCard.vue')['default']
|
||||
TerminalEmulator: typeof import('./src/components/terminal/TerminalEmulator.vue')['default']
|
||||
TerminalPanel: typeof import('./src/components/terminal/TerminalPanel.vue')['default']
|
||||
ThinkingBlock: typeof import('./src/components/chat/ThinkingBlock.vue')['default']
|
||||
TitleBar: typeof import('./src/components/layout/TitleBar.vue')['default']
|
||||
ToolCallCard: typeof import('./src/components/chat/ToolCallCard.vue')['default']
|
||||
ToolCallIndicator: typeof import('./src/components/chat/ToolCallIndicator.vue')['default']
|
||||
TopNav: typeof import('./src/components/layout/TopNav.vue')['default']
|
||||
UsagePanel: typeof import('./src/components/evolution/UsagePanel.vue')['default']
|
||||
UserBubble: typeof import('./src/components/chat/messages/UserBubble.vue')['default']
|
||||
WhitelistManager: typeof import('./src/components/terminal/WhitelistManager.vue')['default']
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,18 +9,36 @@
|
|||
import { ref, onMounted } from 'vue'
|
||||
import { ConfigProvider as AConfigProvider } from 'ant-design-vue'
|
||||
import zhCN from 'ant-design-vue/es/locale/zh_CN'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useThemeStore } from './stores/theme'
|
||||
import { useAuthStore } from './stores/auth'
|
||||
import { isTauri, startBackend, checkBackendHealth } from './api/tauri'
|
||||
import { initApiBaseURL } from './api/base'
|
||||
import {
|
||||
initApiBaseURL,
|
||||
setTokenProvider,
|
||||
setRefreshProvider,
|
||||
setUnauthorizedHandler,
|
||||
} from './api/base'
|
||||
import SplashScreen from './components/layout/SplashScreen.vue'
|
||||
|
||||
const themeStore = useThemeStore()
|
||||
const authStore = useAuthStore()
|
||||
const router = useRouter()
|
||||
|
||||
const loading = ref(isTauri())
|
||||
const loadingStatus = ref('正在初始化...')
|
||||
const loadError = ref('')
|
||||
|
||||
onMounted(async () => {
|
||||
// Wire the auth store into the API client so every request gets a JWT
|
||||
// attached automatically, and 401s trigger a token refresh.
|
||||
setTokenProvider(() => authStore.accessToken)
|
||||
setRefreshProvider(() => authStore.refreshIfPossible())
|
||||
setUnauthorizedHandler(() => {
|
||||
authStore.logoutLocal()
|
||||
router.replace({ name: 'login' })
|
||||
})
|
||||
|
||||
if (isTauri()) {
|
||||
try {
|
||||
loadingStatus.value = '正在启动后端...'
|
||||
|
|
|
|||
|
|
@ -0,0 +1,66 @@
|
|||
/**
|
||||
* Auth API client — login / refresh / logout / me
|
||||
*
|
||||
* Talks to the server-side ``/api/v1/auth/*`` endpoints implemented in
|
||||
* ``src/agentkit/server/routes/auth.py``.
|
||||
*/
|
||||
|
||||
import { BaseApiClient } from './base'
|
||||
|
||||
/** Public user representation returned by the server. */
|
||||
export interface IAuthUser {
|
||||
id: string
|
||||
username: string
|
||||
email: string
|
||||
role: 'member' | 'operator' | 'admin' | string
|
||||
is_active: boolean
|
||||
is_terminal_authorized: boolean
|
||||
is_server_terminal_authorized: boolean
|
||||
}
|
||||
|
||||
/** JWT pair + user info returned by /auth/login and /auth/refresh. */
|
||||
export interface ITokenPair {
|
||||
access_token: string
|
||||
refresh_token: string
|
||||
token_type: 'bearer'
|
||||
expires_in: number
|
||||
user: IAuthUser
|
||||
}
|
||||
|
||||
class AuthApiClient extends BaseApiClient {
|
||||
constructor(baseUrl: string = '/api/v1') {
|
||||
super(baseUrl)
|
||||
}
|
||||
|
||||
/** Authenticate with username + password. */
|
||||
async login(username: string, password: string): Promise<ITokenPair> {
|
||||
return this.request<ITokenPair>('/auth/login', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ username, password }),
|
||||
})
|
||||
}
|
||||
|
||||
/** Exchange a refresh token for a new access token. */
|
||||
async refresh(refreshToken: string): Promise<ITokenPair> {
|
||||
return this.request<ITokenPair>('/auth/refresh', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ refresh_token: refreshToken }),
|
||||
})
|
||||
}
|
||||
|
||||
/** Revoke a refresh token (idempotent). */
|
||||
async logout(refreshToken: string): Promise<{ revoked: boolean }> {
|
||||
return this.request<{ revoked: boolean }>('/auth/logout', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ refresh_token: refreshToken }),
|
||||
})
|
||||
}
|
||||
|
||||
/** Fetch the current user's profile (requires Authorization header). */
|
||||
async me(): Promise<IAuthUser> {
|
||||
return this.request<IAuthUser>('/auth/me')
|
||||
}
|
||||
}
|
||||
|
||||
export const authApi = new AuthApiClient()
|
||||
export { AuthApiClient }
|
||||
|
|
@ -23,6 +23,36 @@ export function getDynamicBaseURL(): string {
|
|||
return _dynamicBaseURL
|
||||
}
|
||||
|
||||
/**
|
||||
* Token provider hook.
|
||||
*
|
||||
* The auth store registers itself here via ``setTokenProvider`` so that
|
||||
* ``BaseApiClient`` can read the current access token without a circular
|
||||
* import (base.ts ← auth.ts ← stores/auth.ts ← base.ts).
|
||||
*/
|
||||
type TokenProvider = () => string | null
|
||||
type RefreshProvider = () => Promise<string | null>
|
||||
type UnauthorizedHandler = () => void
|
||||
|
||||
let _tokenProvider: TokenProvider | null = null
|
||||
let _refreshProvider: RefreshProvider | null = null
|
||||
let _unauthorizedHandler: UnauthorizedHandler | null = null
|
||||
|
||||
/** Register the access-token provider (called by the auth store on init). */
|
||||
export function setTokenProvider(provider: TokenProvider): void {
|
||||
_tokenProvider = provider
|
||||
}
|
||||
|
||||
/** Register the refresh-token provider (called by the auth store on init). */
|
||||
export function setRefreshProvider(provider: RefreshProvider): void {
|
||||
_refreshProvider = provider
|
||||
}
|
||||
|
||||
/** Register a handler invoked when refresh fails (redirect to /login). */
|
||||
export function setUnauthorizedHandler(handler: UnauthorizedHandler): void {
|
||||
_unauthorizedHandler = handler
|
||||
}
|
||||
|
||||
export class BaseApiClient {
|
||||
protected baseUrl: string
|
||||
|
||||
|
|
@ -30,46 +60,121 @@ export class BaseApiClient {
|
|||
this.baseUrl = baseUrl
|
||||
}
|
||||
|
||||
protected async request<T>(path: string, options: RequestInit = {}): Promise<T> {
|
||||
const effectiveUrl = _dynamicBaseURL
|
||||
? (path.startsWith('/api/') ? `${_dynamicBaseURL}${path}` : `${_dynamicBaseURL}${this.baseUrl}${path}`)
|
||||
: (path.startsWith('/api/') ? path : `${this.baseUrl}${path}`)
|
||||
/**
|
||||
* Resolve the effective URL for a request path.
|
||||
*
|
||||
* - Paths starting with ``/api/`` are treated as absolute (no ``baseUrl``
|
||||
* prefix).
|
||||
* - Other paths are prefixed with ``this.baseUrl``.
|
||||
* - In Tauri mode, the dynamic sidecar base URL is prepended.
|
||||
*/
|
||||
private _resolveUrl(path: string): string {
|
||||
const isAbsoluteApi = path.startsWith('/api/')
|
||||
const suffix = isAbsoluteApi ? path : `${this.baseUrl}${path}`
|
||||
return _dynamicBaseURL ? `${_dynamicBaseURL}${suffix}` : suffix
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the request headers, attaching the JWT if available.
|
||||
*/
|
||||
private _buildHeaders(options: RequestInit): Record<string, string> {
|
||||
const headers: Record<string, string> = {
|
||||
...options.headers as Record<string, string>,
|
||||
}
|
||||
// Don't set Content-Type for FormData (upload)
|
||||
if (!(options.body instanceof FormData)) {
|
||||
headers['Content-Type'] = 'application/json'
|
||||
}
|
||||
|
||||
const response = await fetch(effectiveUrl, { ...options, headers })
|
||||
|
||||
if (!response.ok) {
|
||||
const error: IApiError = {
|
||||
status: response.status,
|
||||
message: response.statusText,
|
||||
}
|
||||
try {
|
||||
const body = await response.json()
|
||||
error.detail = body.detail ?? body.message
|
||||
error.message = error.detail ?? error.message
|
||||
} catch {
|
||||
// response body is not JSON
|
||||
}
|
||||
throw error
|
||||
const token = _tokenProvider?.()
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`
|
||||
}
|
||||
|
||||
return response.json() as Promise<T>
|
||||
return headers
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a request, retrying once with a refreshed token on 401.
|
||||
*
|
||||
* If the refresh fails, the unauthorized handler is invoked (typically
|
||||
* redirecting to /login) and the original 401 error is re-thrown.
|
||||
*/
|
||||
protected async request<T>(path: string, options: RequestInit = {}): Promise<T> {
|
||||
const response = await this._send(path, options)
|
||||
|
||||
if (response.status === 401 && _refreshProvider) {
|
||||
const newToken = await _refreshProvider()
|
||||
if (newToken) {
|
||||
// Retry the original request with the new token
|
||||
const retried = await this._send(path, options)
|
||||
if (!retried.ok) {
|
||||
throw await this._buildError(retried)
|
||||
}
|
||||
return (retried.json() as Promise<T>) ?? null
|
||||
}
|
||||
// Refresh failed — notify the app to redirect to login
|
||||
_unauthorizedHandler?.()
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw await this._buildError(response)
|
||||
}
|
||||
|
||||
// Some endpoints (logout) return empty body; guard against JSON parse errors
|
||||
const text = await response.text()
|
||||
if (!text) {
|
||||
return null as unknown as T
|
||||
}
|
||||
try {
|
||||
return JSON.parse(text) as T
|
||||
} catch {
|
||||
return text as unknown as T
|
||||
}
|
||||
}
|
||||
|
||||
/** Low-level fetch with headers + URL resolved. */
|
||||
private async _send(path: string, options: RequestInit): Promise<Response> {
|
||||
const effectiveUrl = this._resolveUrl(path)
|
||||
const headers = this._buildHeaders(options)
|
||||
return fetch(effectiveUrl, { ...options, headers })
|
||||
}
|
||||
|
||||
private async _buildError(response: Response): Promise<IApiError> {
|
||||
const error: IApiError = {
|
||||
status: response.status,
|
||||
message: response.statusText,
|
||||
}
|
||||
try {
|
||||
const body = await response.json()
|
||||
error.detail = body.detail ?? body.message
|
||||
error.message = error.detail ?? error.message
|
||||
} catch {
|
||||
// response body is not JSON
|
||||
}
|
||||
return error
|
||||
}
|
||||
|
||||
/** Get the current access token (for WebSocket URL construction). */
|
||||
protected getToken(): string | null {
|
||||
return _tokenProvider?.() ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a WebSocket connection, attaching the JWT as a query parameter.
|
||||
*
|
||||
* The server's AuthMiddleware accepts ``?token=<jwt>`` as a fallback for
|
||||
* WebSocket connections where setting the Authorization header is not
|
||||
* possible.
|
||||
*/
|
||||
protected createWebSocket(path: string): WebSocket {
|
||||
const token = _tokenProvider?.()
|
||||
const tokenSuffix = token ? `?token=${encodeURIComponent(token)}` : ''
|
||||
|
||||
if (_dynamicBaseURL) {
|
||||
const url = new URL(_dynamicBaseURL)
|
||||
const protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||
return new WebSocket(`${protocol}//${url.host}${this.baseUrl}${path}`)
|
||||
return new WebSocket(`${protocol}//${url.host}${this.baseUrl}${path}${tokenSuffix}`)
|
||||
}
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||
const host = window.location.host
|
||||
return new WebSocket(`${protocol}//${host}${this.baseUrl}${path}`)
|
||||
return new WebSocket(`${protocol}//${host}${this.baseUrl}${path}${tokenSuffix}`)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,8 @@ import type {
|
|||
IConversation,
|
||||
ITaskRecord,
|
||||
TaskStatus,
|
||||
IExpertsResponse,
|
||||
IUploadResponse,
|
||||
} from './types'
|
||||
import { BaseApiClient } from './base'
|
||||
|
||||
|
|
@ -55,6 +57,21 @@ class ApiClient extends BaseApiClient {
|
|||
createWebSocket(): WebSocket {
|
||||
return super.createWebSocket('/ws')
|
||||
}
|
||||
|
||||
/** List available expert templates for @board mode (uses /api/v1 prefix) */
|
||||
async getExperts(): Promise<IExpertsResponse> {
|
||||
return this.request<IExpertsResponse>('/api/v1/experts')
|
||||
}
|
||||
|
||||
/** Upload a file to be referenced in chat messages */
|
||||
async uploadFile(file: File): Promise<IUploadResponse> {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
return this.request<IUploadResponse>('/api/v1/chat/upload', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export const apiClient = new ApiClient()
|
||||
|
|
|
|||
|
|
@ -0,0 +1,71 @@
|
|||
/** System monitoring API client — /api/v1/health, /api/v1/metrics, /api/v1/system */
|
||||
|
||||
import { BaseApiClient } from './base'
|
||||
|
||||
const API_BASE = '/api/v1'
|
||||
|
||||
export interface IHealthCheck {
|
||||
status: string
|
||||
version: string
|
||||
checks: Record<string, unknown>
|
||||
}
|
||||
|
||||
export interface IMetricsData {
|
||||
tasks: {
|
||||
total_tasks: number
|
||||
completed_tasks: number
|
||||
failed_tasks: number
|
||||
pending_tasks: number
|
||||
}
|
||||
agents: {
|
||||
total_agents: number
|
||||
}
|
||||
skills: {
|
||||
total_skills: number
|
||||
}
|
||||
version: string
|
||||
}
|
||||
|
||||
export interface ISystemResources {
|
||||
cpu: {
|
||||
count: number
|
||||
load_average: number[] | null
|
||||
}
|
||||
memory: {
|
||||
total_bytes: number
|
||||
used_bytes: number
|
||||
free_bytes: number
|
||||
percent: number | null
|
||||
}
|
||||
disk: {
|
||||
total_bytes: number
|
||||
used_bytes: number
|
||||
free_bytes: number
|
||||
percent: number | null
|
||||
}
|
||||
timestamp: number
|
||||
}
|
||||
|
||||
class SystemApiClient extends BaseApiClient {
|
||||
constructor(baseUrl: string = API_BASE) {
|
||||
super(baseUrl)
|
||||
}
|
||||
|
||||
async getHealth(): Promise<IHealthCheck> {
|
||||
return this.request<IHealthCheck>('/health')
|
||||
}
|
||||
|
||||
async getMetrics(): Promise<IMetricsData> {
|
||||
return this.request<IMetricsData>('/metrics')
|
||||
}
|
||||
|
||||
async getResources(): Promise<ISystemResources> {
|
||||
return this.request<ISystemResources>('/system/resources')
|
||||
}
|
||||
}
|
||||
|
||||
export const systemApi = new SystemApiClient()
|
||||
|
||||
export const getHealth = systemApi.getHealth.bind(systemApi)
|
||||
export const getMetrics = systemApi.getMetrics.bind(systemApi)
|
||||
export const getResources = systemApi.getResources.bind(systemApi)
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
/** Terminal API client */
|
||||
/** Terminal API client — supports local + server terminal modes. */
|
||||
|
||||
import { BaseApiClient, getDynamicBaseURL } from './base'
|
||||
|
||||
|
|
@ -30,24 +30,99 @@ export interface IExecuteResponse {
|
|||
reason?: string
|
||||
}
|
||||
|
||||
// ── Whitelist / Blocklist / Audit / Approval types ───────────────────
|
||||
|
||||
export interface IWhitelistEntry {
|
||||
id: string
|
||||
user_id?: string
|
||||
command_pattern: string
|
||||
scope: 'user' | 'global'
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface IBlocklistEntry {
|
||||
id: string
|
||||
command_pattern: string
|
||||
reason: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface IAuditLogEntry {
|
||||
id: string
|
||||
user_id: string | null
|
||||
session_id: string
|
||||
command: string
|
||||
decision: string
|
||||
reason: string | null
|
||||
exit_code: number | null
|
||||
terminal_mode: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface IApprovalEntry {
|
||||
id: string
|
||||
user_id: string
|
||||
username: string
|
||||
session_id: string
|
||||
command: string
|
||||
reason: string | null
|
||||
status: 'pending' | 'approved' | 'rejected' | 'expired' | 'cancelled'
|
||||
reviewer_id: string | null
|
||||
reviewer_username: string | null
|
||||
review_note: string | null
|
||||
created_at: string
|
||||
reviewed_at: string | null
|
||||
expires_at: string
|
||||
}
|
||||
|
||||
class TerminalApiClient extends BaseApiClient {
|
||||
constructor(baseUrl: string = API_BASE) {
|
||||
super(baseUrl)
|
||||
}
|
||||
|
||||
/** Create a terminal WebSocket URL (Tauri-aware) */
|
||||
/** Create a local terminal WebSocket URL (Tauri-aware, JWT-authenticated). */
|
||||
createTerminalWsUrl(sessionId?: string): string {
|
||||
const token = this.getToken()
|
||||
const params = new URLSearchParams()
|
||||
if (sessionId) params.set('session_id', sessionId)
|
||||
if (token) params.set('token', token)
|
||||
const query = params.toString() ? `?${params.toString()}` : ''
|
||||
|
||||
const dynamicBase = getDynamicBaseURL()
|
||||
if (dynamicBase) {
|
||||
const url = new URL(dynamicBase)
|
||||
const protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||
let base = `${protocol}//${url.host}${this.baseUrl}/terminal/ws`
|
||||
return sessionId ? `${base}?session_id=${sessionId}` : base
|
||||
return `${protocol}//${url.host}${this.baseUrl}/terminal/ws${query}`
|
||||
}
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||
const host = window.location.host
|
||||
const base = `${protocol}//${host}${this.baseUrl}/terminal/ws`
|
||||
return sessionId ? `${base}?session_id=${sessionId}` : base
|
||||
return `${protocol}//${host}${this.baseUrl}/terminal/ws${query}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a server terminal WebSocket URL with JWT authentication.
|
||||
*
|
||||
* Server terminal requires ``TERMINAL_SERVER_USE`` permission and the
|
||||
* ``is_server_terminal_authorized`` DB flag. The JWT is passed as a
|
||||
* ``?token=`` query parameter since browsers cannot set Authorization
|
||||
* headers on WebSocket connections.
|
||||
*/
|
||||
createServerTerminalWsUrl(sessionId?: string): string {
|
||||
const token = this.getToken()
|
||||
const params = new URLSearchParams()
|
||||
if (sessionId) params.set('session_id', sessionId)
|
||||
if (token) params.set('token', token)
|
||||
const query = params.toString() ? `?${params.toString()}` : ''
|
||||
|
||||
const dynamicBase = getDynamicBaseURL()
|
||||
if (dynamicBase) {
|
||||
const url = new URL(dynamicBase)
|
||||
const protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||
return `${protocol}//${url.host}${this.baseUrl}/terminal/server/ws${query}`
|
||||
}
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||
const host = window.location.host
|
||||
return `${protocol}//${host}${this.baseUrl}/terminal/server/ws${query}`
|
||||
}
|
||||
|
||||
/** Execute a command via REST API */
|
||||
|
|
@ -79,6 +154,103 @@ class TerminalApiClient extends BaseApiClient {
|
|||
method: 'DELETE',
|
||||
})
|
||||
}
|
||||
|
||||
// ── User whitelist ──────────────────────────────────────────────
|
||||
|
||||
async listUserWhitelist(): Promise<{ entries: IWhitelistEntry[] }> {
|
||||
return this.request<{ entries: IWhitelistEntry[] }>('/terminal/whitelist/user')
|
||||
}
|
||||
|
||||
async addUserWhitelist(commandPattern: string): Promise<IWhitelistEntry> {
|
||||
return this.request<IWhitelistEntry>('/terminal/whitelist/user', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ command_pattern: commandPattern }),
|
||||
})
|
||||
}
|
||||
|
||||
async deleteUserWhitelist(entryId: string): Promise<void> {
|
||||
await this.request<void>(`/terminal/whitelist/user/${entryId}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
}
|
||||
|
||||
// ── Global whitelist (admin) ────────────────────────────────────
|
||||
|
||||
async listGlobalWhitelist(): Promise<{ entries: IWhitelistEntry[] }> {
|
||||
return this.request<{ entries: IWhitelistEntry[] }>('/terminal/whitelist/global')
|
||||
}
|
||||
|
||||
async addGlobalWhitelist(commandPattern: string): Promise<IWhitelistEntry> {
|
||||
return this.request<IWhitelistEntry>('/terminal/whitelist/global', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ command_pattern: commandPattern }),
|
||||
})
|
||||
}
|
||||
|
||||
async deleteGlobalWhitelist(entryId: string): Promise<void> {
|
||||
await this.request<void>(`/terminal/whitelist/global/${entryId}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
}
|
||||
|
||||
// ── Blocklist (admin) ───────────────────────────────────────────
|
||||
|
||||
async listBlocklist(): Promise<{ entries: IBlocklistEntry[] }> {
|
||||
return this.request<{ entries: IBlocklistEntry[] }>('/terminal/blocklist')
|
||||
}
|
||||
|
||||
async addBlocklist(commandPattern: string, reason: string): Promise<IBlocklistEntry> {
|
||||
return this.request<IBlocklistEntry>('/terminal/blocklist', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ command_pattern: commandPattern, reason }),
|
||||
})
|
||||
}
|
||||
|
||||
async deleteBlocklist(entryId: string): Promise<void> {
|
||||
await this.request<void>(`/terminal/blocklist/${entryId}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
}
|
||||
|
||||
// ── Audit logs (admin) ──────────────────────────────────────────
|
||||
|
||||
async listAuditLogs(params?: {
|
||||
user_id?: string
|
||||
session_id?: string
|
||||
terminal_mode?: string
|
||||
limit?: number
|
||||
offset?: number
|
||||
}): Promise<{ entries: IAuditLogEntry[]; total: number }> {
|
||||
const query = new URLSearchParams()
|
||||
if (params?.user_id) query.set('user_id', params.user_id)
|
||||
if (params?.session_id) query.set('session_id', params.session_id)
|
||||
if (params?.terminal_mode) query.set('terminal_mode', params.terminal_mode)
|
||||
if (params?.limit) query.set('limit', String(params.limit))
|
||||
if (params?.offset) query.set('offset', String(params.offset))
|
||||
const qs = query.toString() ? `?${query.toString()}` : ''
|
||||
return this.request<{ entries: IAuditLogEntry[]; total: number }>(`/terminal/audit-logs${qs}`)
|
||||
}
|
||||
|
||||
// ── Approvals (admin) ───────────────────────────────────────────
|
||||
|
||||
async listApprovals(status?: string): Promise<{ approvals: IApprovalEntry[]; total: number }> {
|
||||
const qs = status ? `?status=${encodeURIComponent(status)}` : ''
|
||||
return this.request<{ approvals: IApprovalEntry[]; total: number }>(`/terminal/approvals${qs}`)
|
||||
}
|
||||
|
||||
async approveApproval(approvalId: string, note?: string): Promise<IApprovalEntry> {
|
||||
return this.request<IApprovalEntry>(`/terminal/approvals/${approvalId}/approve`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ note: note ?? '' }),
|
||||
})
|
||||
}
|
||||
|
||||
async rejectApproval(approvalId: string, note?: string): Promise<IApprovalEntry> {
|
||||
return this.request<IApprovalEntry>(`/terminal/approvals/${approvalId}/reject`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ note: note ?? '' }),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export const terminalApi = new TerminalApiClient()
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ export interface IToolCallData {
|
|||
result?: string
|
||||
error?: string
|
||||
duration?: number
|
||||
children?: IToolCallData[]
|
||||
}
|
||||
|
||||
/** Single chat message */
|
||||
|
|
@ -38,16 +39,30 @@ export interface IChatMessage {
|
|||
routing_method?: string
|
||||
confidence?: number
|
||||
task_id?: string
|
||||
status?: 'completed' | 'pending'
|
||||
status?: 'completed' | 'pending' | 'error'
|
||||
tool_calls?: IToolCallData[]
|
||||
thinking?: string
|
||||
expert_id?: string
|
||||
expert_name?: string
|
||||
expert_color?: string
|
||||
expert_avatar?: string
|
||||
message_type?: 'chat' | 'handoff' | 'assist_request' | 'plan_update' | 'milestone' | 'board_speech' | 'board_summary' | 'board_conclusion'
|
||||
message_type?:
|
||||
| 'chat'
|
||||
| 'handoff'
|
||||
| 'assist_request'
|
||||
| 'plan_update'
|
||||
| 'milestone'
|
||||
| 'board_started'
|
||||
| 'board_speech'
|
||||
| 'board_summary'
|
||||
| 'board_conclusion'
|
||||
| 'error'
|
||||
board_round?: number
|
||||
board_role?: 'moderator' | 'expert' | 'user' | 'summary'
|
||||
plan_phases?: ITeamPlanPhase[]
|
||||
error_detail?: string
|
||||
board_started?: IBoardStartedData
|
||||
board_conclusion?: IBoardConcludedData
|
||||
}
|
||||
|
||||
/** Conversation with messages */
|
||||
|
|
@ -218,6 +233,34 @@ export interface IBoardMessage {
|
|||
timestamp: number
|
||||
}
|
||||
|
||||
/** Expert template (matches backend GET /api/v1/experts response item) */
|
||||
export interface IExpertTemplate {
|
||||
name: string
|
||||
description: string
|
||||
is_builtin: boolean
|
||||
avatar: string
|
||||
color: string
|
||||
persona: string
|
||||
thinking_style: string
|
||||
speaking_style: string
|
||||
decision_framework: string
|
||||
}
|
||||
|
||||
/** Experts list response (matches backend GET /api/v1/experts) */
|
||||
export interface IExpertsResponse {
|
||||
experts: IExpertTemplate[]
|
||||
total: number
|
||||
}
|
||||
|
||||
/** File upload response (matches backend POST /api/v1/chat/upload) */
|
||||
export interface IUploadResponse {
|
||||
filename: string
|
||||
stored_name: string
|
||||
content_type: string
|
||||
size: number
|
||||
download_url: string
|
||||
}
|
||||
|
||||
/** API error */
|
||||
export interface IApiError {
|
||||
status: number
|
||||
|
|
|
|||
|
|
@ -0,0 +1,479 @@
|
|||
<template>
|
||||
<a-modal
|
||||
:open="open"
|
||||
:width="640"
|
||||
:ok-text="okText"
|
||||
:ok-button-props="{ disabled: !canSubmit, type: 'primary' }"
|
||||
:confirm-loading="submitting"
|
||||
cancel-text="取消"
|
||||
class="board-meeting-modal"
|
||||
@ok="handleSubmit"
|
||||
@cancel="handleCancel"
|
||||
>
|
||||
<template #title>
|
||||
<span class="board-meeting-modal__title">
|
||||
<BankOutlined class="board-meeting-modal__title-icon" />
|
||||
发起私董会
|
||||
</span>
|
||||
</template>
|
||||
<div class="board-modal">
|
||||
<!-- 讨论主题 -->
|
||||
<div class="board-modal__field">
|
||||
<label class="board-modal__label">
|
||||
讨论主题 <span class="board-modal__required">*</span>
|
||||
</label>
|
||||
<a-textarea
|
||||
v-model:value="topic"
|
||||
:auto-size="{ minRows: 2, maxRows: 4 }"
|
||||
placeholder="例如:是否应该投入资源做这个功能?"
|
||||
:disabled="submitting"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 选择专家 -->
|
||||
<div class="board-modal__field">
|
||||
<label class="board-modal__label">
|
||||
选择专家 <span class="board-modal__required">*</span>
|
||||
<span class="board-modal__hint">
|
||||
(已选 {{ selectedExperts.length }} / {{ MAX_EXPERTS }},首位为主持人)
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<div v-if="loading" class="board-modal__loading">
|
||||
<a-spin size="small" /> 加载专家列表...
|
||||
</div>
|
||||
|
||||
<div v-else-if="experts.length === 0" class="board-modal__empty">
|
||||
<a-empty description="未找到可用专家模板" :image="simpleImage" />
|
||||
</div>
|
||||
|
||||
<div v-else class="board-modal__experts">
|
||||
<div
|
||||
v-for="expert in experts"
|
||||
:key="expert.name"
|
||||
class="board-modal__expert-card"
|
||||
:class="{
|
||||
'board-modal__expert-card--selected': isSelected(expert.name),
|
||||
'board-modal__expert-card--moderator': isModerator(expert.name),
|
||||
}"
|
||||
@click="toggleExpert(expert.name)"
|
||||
>
|
||||
<div class="board-modal__expert-header">
|
||||
<span
|
||||
class="board-modal__expert-avatar"
|
||||
:class="{ 'board-modal__expert-avatar--moderator': isModerator(expert.name) }"
|
||||
>{{ expert.avatar }}</span>
|
||||
<span class="board-modal__expert-name">{{ expert.name }}</span>
|
||||
<span v-if="isModerator(expert.name)" class="board-modal__tag board-modal__tag--moderator">主持人</span>
|
||||
<span v-else-if="isSelected(expert.name)" class="board-modal__tag board-modal__tag--selected">
|
||||
#{{ selectedOrder(expert.name) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="board-modal__expert-persona">
|
||||
{{ truncate(expert.persona, 80) }}
|
||||
</div>
|
||||
<div v-if="expert.decision_framework" class="board-modal__expert-framework">
|
||||
<ThunderboltOutlined /> {{ expert.decision_framework }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 最大轮次 -->
|
||||
<div class="board-modal__field">
|
||||
<label class="board-modal__label">最大讨论轮次</label>
|
||||
<div class="board-modal__rounds">
|
||||
<a-slider
|
||||
v-model:value="maxRounds"
|
||||
:min="1"
|
||||
:max="10"
|
||||
:marks="{ 1: '1', 3: '3', 5: '5', 10: '10' }"
|
||||
:disabled="submitting"
|
||||
class="board-modal__rounds-slider"
|
||||
/>
|
||||
<a-input-number
|
||||
v-model:value="maxRounds"
|
||||
:min="1"
|
||||
:max="10"
|
||||
:disabled="submitting"
|
||||
class="board-modal__rounds-input"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 预览 -->
|
||||
<div v-if="previewCommand" class="board-modal__preview">
|
||||
<div class="board-modal__preview-label">将发送:</div>
|
||||
<code class="board-modal__preview-code">{{ previewCommand }}</code>
|
||||
</div>
|
||||
</div>
|
||||
</a-modal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import {
|
||||
Modal as AModal,
|
||||
Textarea as ATextarea,
|
||||
Spin as ASpin,
|
||||
Empty as AEmpty,
|
||||
Slider as ASlider,
|
||||
InputNumber as AInputNumber,
|
||||
message,
|
||||
} from 'ant-design-vue'
|
||||
import { ThunderboltOutlined, BankOutlined } from '@ant-design/icons-vue'
|
||||
import { apiClient } from '@/api/client'
|
||||
import type { IExpertTemplate } from '@/api/types'
|
||||
|
||||
const MAX_EXPERTS = 10
|
||||
|
||||
interface IProps {
|
||||
open: boolean
|
||||
}
|
||||
|
||||
const props = defineProps<IProps>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:open': [value: boolean]
|
||||
submit: [command: string]
|
||||
}>()
|
||||
|
||||
const topic = ref('')
|
||||
const selectedExperts = ref<string[]>([])
|
||||
const maxRounds = ref(5)
|
||||
const experts = ref<IExpertTemplate[]>([])
|
||||
const loading = ref(false)
|
||||
const submitting = ref(false)
|
||||
|
||||
const simpleImage = AEmpty.PRESENTED_IMAGE_SIMPLE
|
||||
|
||||
const canSubmit = computed(() => {
|
||||
return topic.value.trim().length > 0 && selectedExperts.value.length >= 1
|
||||
})
|
||||
|
||||
const okText = computed(() => {
|
||||
if (!canSubmit.value) return '发起讨论'
|
||||
return `发起讨论(${selectedExperts.value.length} 位专家)`
|
||||
})
|
||||
|
||||
const previewCommand = computed(() => {
|
||||
if (!canSubmit.value) return ''
|
||||
const expertList = selectedExperts.value.join(',')
|
||||
const trimmedTopic = topic.value.trim()
|
||||
return `@board:${expertList} rounds=${maxRounds.value} ${trimmedTopic}`
|
||||
})
|
||||
|
||||
async function loadExperts(): Promise<void> {
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await apiClient.getExperts()
|
||||
experts.value = data.experts || []
|
||||
} catch (error) {
|
||||
console.error('Failed to load experts:', error)
|
||||
message.error('专家列表加载失败,请稍后重试')
|
||||
experts.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function isSelected(name: string): boolean {
|
||||
return selectedExperts.value.includes(name)
|
||||
}
|
||||
|
||||
function isModerator(name: string): boolean {
|
||||
return selectedExperts.value[0] === name
|
||||
}
|
||||
|
||||
function selectedOrder(name: string): number {
|
||||
const idx = selectedExperts.value.indexOf(name)
|
||||
return idx + 1
|
||||
}
|
||||
|
||||
function toggleExpert(name: string): void {
|
||||
if (submitting.value) return
|
||||
const idx = selectedExperts.value.indexOf(name)
|
||||
if (idx === -1) {
|
||||
if (selectedExperts.value.length >= MAX_EXPERTS) return
|
||||
selectedExperts.value.push(name)
|
||||
} else {
|
||||
selectedExperts.value.splice(idx, 1)
|
||||
}
|
||||
}
|
||||
|
||||
function truncate(text: string, max: number): string {
|
||||
if (!text) return ''
|
||||
const cleaned = text.replace(/\s+/g, ' ').trim()
|
||||
return cleaned.length > max ? cleaned.slice(0, max) + '...' : cleaned
|
||||
}
|
||||
|
||||
function handleSubmit(): void {
|
||||
if (!canSubmit.value) return
|
||||
submitting.value = true
|
||||
// Emit the constructed @board command; the parent sends it via the normal chat pipeline
|
||||
emit('submit', previewCommand.value)
|
||||
// Close after a short tick to let the parent process the send
|
||||
setTimeout(() => {
|
||||
submitting.value = false
|
||||
resetForm()
|
||||
emit('update:open', false)
|
||||
}, 50)
|
||||
}
|
||||
|
||||
function handleCancel(): void {
|
||||
if (submitting.value) return
|
||||
resetForm()
|
||||
emit('update:open', false)
|
||||
}
|
||||
|
||||
function resetForm(): void {
|
||||
topic.value = ''
|
||||
selectedExperts.value = []
|
||||
maxRounds.value = 5
|
||||
}
|
||||
|
||||
// Load experts when modal opens
|
||||
watch(
|
||||
() => props.open,
|
||||
(newOpen) => {
|
||||
if (newOpen) {
|
||||
loadExperts()
|
||||
}
|
||||
}
|
||||
)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.board-modal {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.board-modal__field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.board-modal__label {
|
||||
font-weight: var(--font-weight-semibold);
|
||||
font-size: var(--font-sm);
|
||||
color: var(--text-primary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.board-modal__required {
|
||||
color: var(--color-error);
|
||||
}
|
||||
|
||||
.board-modal__hint {
|
||||
font-weight: var(--font-weight-normal);
|
||||
font-size: var(--font-xs);
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.board-modal__loading,
|
||||
.board-modal__empty {
|
||||
padding: var(--space-4);
|
||||
text-align: center;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.board-modal__experts {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: var(--space-2);
|
||||
max-height: 280px;
|
||||
overflow-y: auto;
|
||||
padding: var(--space-1);
|
||||
}
|
||||
|
||||
.board-modal__expert-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-md);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
background: var(--bg-primary);
|
||||
}
|
||||
|
||||
.board-modal__expert-card:hover {
|
||||
border-color: var(--accent-board);
|
||||
background: var(--bg-secondary, var(--bg-primary));
|
||||
}
|
||||
|
||||
.board-modal__expert-card--selected {
|
||||
background: var(--accent-board-bg);
|
||||
border-color: var(--accent-board);
|
||||
border-width: 2px;
|
||||
}
|
||||
|
||||
.board-modal__expert-card--moderator {
|
||||
background: var(--accent-board-soft);
|
||||
}
|
||||
|
||||
.board-modal__expert-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.board-modal__expert-avatar {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.board-modal__expert-name {
|
||||
font-weight: var(--font-weight-semibold);
|
||||
font-size: var(--font-sm);
|
||||
color: var(--text-primary);
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.board-modal__expert-persona {
|
||||
font-size: var(--font-xs);
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.board-modal__expert-framework {
|
||||
font-size: var(--font-xs);
|
||||
color: var(--text-tertiary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.board-modal__tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
height: 20px;
|
||||
padding: 0 6px;
|
||||
font-size: var(--font-xs);
|
||||
border-radius: var(--radius-sm);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.board-modal__tag--moderator {
|
||||
background: var(--accent-board);
|
||||
color: var(--text-inverse);
|
||||
}
|
||||
|
||||
.board-modal__tag--selected {
|
||||
background: var(--accent-board-soft);
|
||||
color: var(--accent-board);
|
||||
}
|
||||
|
||||
.board-modal__rounds {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.board-modal__rounds-slider {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.board-modal__rounds-input {
|
||||
margin-left: 16px;
|
||||
width: 80px;
|
||||
}
|
||||
|
||||
.board-modal__preview {
|
||||
padding: var(--space-2) var(--space-3);
|
||||
background: var(--bg-secondary, var(--bg-primary));
|
||||
border: 1px dashed var(--border-color);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.board-modal__preview-label {
|
||||
font-size: var(--font-xs);
|
||||
color: var(--text-tertiary);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.board-modal__preview-code {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--font-xs);
|
||||
color: var(--accent-board);
|
||||
word-break: break-all;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.board-modal__expert-avatar--moderator {
|
||||
background: var(--accent-board-soft);
|
||||
border-radius: var(--radius-full);
|
||||
padding: 2px;
|
||||
}
|
||||
|
||||
/* ── Modal-level VI overrides ── */
|
||||
.board-meeting-modal__title {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.board-meeting-modal__title-icon {
|
||||
color: var(--accent-board);
|
||||
}
|
||||
|
||||
:deep(.ant-modal-header) {
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
:deep(.ant-modal-title) {
|
||||
font-weight: var(--font-weight-semibold);
|
||||
}
|
||||
|
||||
:deep(.ant-btn-primary) {
|
||||
background: var(--accent-board);
|
||||
border-color: var(--accent-board);
|
||||
}
|
||||
|
||||
:deep(.ant-btn-primary:hover),
|
||||
:deep(.ant-btn-primary:focus) {
|
||||
background: var(--accent-board-hover);
|
||||
border-color: var(--accent-board-hover);
|
||||
}
|
||||
|
||||
:deep(.ant-btn-primary:disabled) {
|
||||
background: var(--color-gray-300);
|
||||
border-color: var(--color-gray-300);
|
||||
color: var(--text-inverse);
|
||||
}
|
||||
|
||||
:deep(.ant-input:focus),
|
||||
:deep(.ant-input-focused) {
|
||||
border-color: var(--accent-board);
|
||||
box-shadow: 0 0 0 2px var(--accent-board-soft);
|
||||
}
|
||||
|
||||
:deep(.ant-input-number-focused),
|
||||
:deep(.ant-input-number:focus-within) {
|
||||
border-color: var(--accent-board);
|
||||
box-shadow: 0 0 0 2px var(--accent-board-soft);
|
||||
}
|
||||
|
||||
:deep(.ant-input-number-input:focus) {
|
||||
border-color: var(--accent-board);
|
||||
}
|
||||
|
||||
:deep(.ant-slider-track) {
|
||||
background: var(--accent-board) !important;
|
||||
}
|
||||
|
||||
:deep(.ant-slider-handle) {
|
||||
border-color: var(--accent-board);
|
||||
}
|
||||
|
||||
:deep(.ant-slider-handle:focus) {
|
||||
border-color: var(--accent-board-hover);
|
||||
box-shadow: 0 0 0 4px var(--accent-board-soft);
|
||||
}
|
||||
</style>
|
||||
|
|
@ -2,145 +2,93 @@
|
|||
<div v-if="chatStore.boardState" class="board-status-view">
|
||||
<div class="board-status-view__header">
|
||||
<div class="board-status-view__title">
|
||||
<span class="board-status-view__icon">🏛️</span>
|
||||
<span class="board-status-view__label">私董会</span>
|
||||
<a-tag v-if="chatStore.boardState.status === 'discussing'" color="processing" size="small">
|
||||
讨论中
|
||||
</a-tag>
|
||||
<a-tag v-else-if="chatStore.boardState.status === 'concluding'" color="warning" size="small">
|
||||
总结中
|
||||
</a-tag>
|
||||
<a-tag v-else-if="chatStore.boardState.status === 'completed'" color="success" size="small">
|
||||
已完成
|
||||
</a-tag>
|
||||
<span class="board-status-view__topic">{{ chatStore.boardState.topic }}</span>
|
||||
</div>
|
||||
<div class="board-status-view__topic">
|
||||
{{ chatStore.boardState.topic }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="board-status-view__experts">
|
||||
<div
|
||||
v-for="expert in chatStore.boardState.experts"
|
||||
:key="expert.name"
|
||||
class="board-status-view__expert-chip"
|
||||
:style="{ borderColor: expert.color }"
|
||||
>
|
||||
<span class="board-status-view__expert-avatar">{{ expert.avatar }}</span>
|
||||
<span class="board-status-view__expert-name">{{ expert.name }}</span>
|
||||
<a-tag v-if="expert.is_moderator" color="purple" size="small">主持人</a-tag>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="board-status-view__progress">
|
||||
<span class="board-status-view__round-info">
|
||||
<span class="board-status-view__round">
|
||||
第 {{ chatStore.boardState.current_round }} / {{ chatStore.boardState.max_rounds }} 轮
|
||||
</span>
|
||||
<a-progress
|
||||
:percent="progressPercent"
|
||||
size="small"
|
||||
:show-info="false"
|
||||
:stroke-color="'#8E44AD'"
|
||||
/>
|
||||
</div>
|
||||
<div class="board-status-view__experts">
|
||||
<span
|
||||
v-for="expert in chatStore.boardState.experts"
|
||||
:key="expert.name"
|
||||
class="board-status-view__expert"
|
||||
:class="{ 'board-status-view__expert--moderator': expert.is_moderator }"
|
||||
>
|
||||
{{ expert.name }}
|
||||
<span v-if="expert.is_moderator" class="board-status-view__role">主持人</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { Progress as AProgress, Tag as ATag } from 'ant-design-vue'
|
||||
import { useChatStore } from '@/stores/chat'
|
||||
|
||||
const chatStore = useChatStore()
|
||||
|
||||
const progressPercent = computed(() => {
|
||||
if (!chatStore.boardState) return 0
|
||||
const { current_round, max_rounds } = chatStore.boardState
|
||||
if (max_rounds <= 0) return 0
|
||||
return Math.min(100, Math.round((current_round / max_rounds) * 100))
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.board-status-view {
|
||||
margin: 0 var(--space-4);
|
||||
padding: var(--space-3) var(--space-4);
|
||||
background: linear-gradient(135deg, rgba(142, 68, 173, 0.08), rgba(142, 68, 173, 0.03));
|
||||
border: 1px solid rgba(142, 68, 173, 0.2);
|
||||
border-radius: var(--radius-lg);
|
||||
margin-bottom: var(--space-2);
|
||||
margin: 0 var(--space-5);
|
||||
padding: var(--space-3) 0;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.board-status-view__header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-3);
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
.board-status-view__title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
align-items: baseline;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.board-status-view__icon {
|
||||
font-size: 18px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.board-status-view__label {
|
||||
font-weight: var(--font-weight-semibold);
|
||||
font-size: var(--font-base);
|
||||
font-size: var(--font-sm);
|
||||
font-weight: var(--font-weight-medium);
|
||||
color: var(--text-primary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.board-status-view__topic {
|
||||
font-size: var(--font-sm);
|
||||
color: var(--text-secondary);
|
||||
padding-left: 26px;
|
||||
color: var(--text-tertiary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.board-status-view__round {
|
||||
font-size: var(--font-xs);
|
||||
color: var(--text-tertiary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.board-status-view__experts {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-2);
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
.board-status-view__expert-chip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 2px 8px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-full);
|
||||
background: var(--bg-primary);
|
||||
font-size: var(--font-xs);
|
||||
}
|
||||
|
||||
.board-status-view__expert-avatar {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.board-status-view__expert-name {
|
||||
color: var(--text-primary);
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
|
||||
.board-status-view__progress {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.board-status-view__round-info {
|
||||
gap: var(--space-3);
|
||||
font-size: var(--font-xs);
|
||||
color: var(--text-tertiary);
|
||||
white-space: nowrap;
|
||||
min-width: 80px;
|
||||
}
|
||||
|
||||
.board-status-view :deep(.ant-progress) {
|
||||
flex: 1;
|
||||
.board-status-view__expert {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
}
|
||||
|
||||
.board-status-view__expert--moderator {
|
||||
color: var(--accent-board);
|
||||
}
|
||||
|
||||
.board-status-view__role {
|
||||
margin-left: 4px;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,15 @@
|
|||
<template>
|
||||
<div class="chat-input" style="position: relative;">
|
||||
<div
|
||||
class="chat-input"
|
||||
:class="{ 'chat-input--drag-over': isDragOver }"
|
||||
@dragenter="handleDragEnter"
|
||||
@dragover="handleDragOver"
|
||||
@dragleave="handleDragLeave"
|
||||
@drop="handleDrop"
|
||||
>
|
||||
<div v-if="isDragOver" class="chat-input__drag-overlay">
|
||||
<span class="chat-input__drag-text">拖放以附加</span>
|
||||
</div>
|
||||
<div v-if="contextPills.length > 0" class="chat-input__pills">
|
||||
<ContextPill
|
||||
v-for="(pill, idx) in contextPills"
|
||||
|
|
@ -40,26 +50,86 @@
|
|||
</a-button>
|
||||
</div>
|
||||
<div class="chat-input__footer">
|
||||
<a-select
|
||||
v-model:value="selectedModel"
|
||||
size="small"
|
||||
class="chat-input__model-select"
|
||||
:loading="modelsLoading"
|
||||
:options="modelOptions"
|
||||
placeholder="选择模型"
|
||||
/>
|
||||
<div class="chat-input__footer-left">
|
||||
<a-button
|
||||
size="small"
|
||||
:disabled="disabled"
|
||||
@click="showTeamModal = true"
|
||||
class="chat-input__action-btn chat-input__action-btn--team"
|
||||
>
|
||||
<template #icon><UsergroupAddOutlined /></template>
|
||||
专家团
|
||||
</a-button>
|
||||
<a-button
|
||||
size="small"
|
||||
:disabled="disabled"
|
||||
@click="showBoardModal = true"
|
||||
class="chat-input__action-btn chat-input__action-btn--board"
|
||||
>
|
||||
<template #icon><TeamOutlined /></template>
|
||||
私董会
|
||||
</a-button>
|
||||
<a-button
|
||||
size="small"
|
||||
:disabled="disabled || fileUploading"
|
||||
:loading="fileUploading"
|
||||
@click="openFilePicker"
|
||||
class="chat-input__action-btn chat-input__action-btn--file"
|
||||
>
|
||||
<template #icon><PaperClipOutlined v-if="!fileUploading" /></template>
|
||||
{{ fileUploading ? '上传中' : '文件' }}
|
||||
</a-button>
|
||||
<input
|
||||
ref="fileInputRef"
|
||||
type="file"
|
||||
class="chat-input__file-input"
|
||||
@change="handleFileChange"
|
||||
/>
|
||||
</div>
|
||||
<div class="chat-input__footer-right">
|
||||
<a-select
|
||||
v-model:value="selectedModel"
|
||||
size="small"
|
||||
class="chat-input__model-select"
|
||||
:loading="modelsLoading"
|
||||
:options="modelOptions"
|
||||
placeholder="模型"
|
||||
/>
|
||||
<a-button
|
||||
size="small"
|
||||
:disabled="disabled || (!inputText && contextPills.length === 0)"
|
||||
class="chat-input__action-btn chat-input__action-btn--clear"
|
||||
@click="handleClear"
|
||||
>
|
||||
清空
|
||||
</a-button>
|
||||
<span class="chat-input__hint">Enter 发送,Shift + Enter 换行</span>
|
||||
</div>
|
||||
</div>
|
||||
<BoardMeetingModal
|
||||
v-model:open="showBoardModal"
|
||||
@submit="handleBoardSubmit"
|
||||
/>
|
||||
<TeamModal
|
||||
v-model:open="showTeamModal"
|
||||
@submit="handleTeamSubmit"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, type Component } from 'vue'
|
||||
import { ref, computed, onMounted, onUnmounted, type Component } from 'vue'
|
||||
import { Input as AInput, Button as AButton, Select as ASelect } from 'ant-design-vue'
|
||||
import { SendOutlined } from '@ant-design/icons-vue'
|
||||
import { SendOutlined, TeamOutlined, UsergroupAddOutlined, PaperClipOutlined } from '@ant-design/icons-vue'
|
||||
import ContextPill from './ContextPill.vue'
|
||||
import MentionDropdown from './MentionDropdown.vue'
|
||||
import BoardMeetingModal from './BoardMeetingModal.vue'
|
||||
import TeamModal from './TeamModal.vue'
|
||||
import { useSkillsStore } from '@/stores/skills'
|
||||
import type { ISkillInfo } from '@/api/skills'
|
||||
import { getDynamicBaseURL } from '@/api/base'
|
||||
import { apiClient } from '@/api/client'
|
||||
import { message as antMessage } from 'ant-design-vue'
|
||||
|
||||
const ATextarea = AInput.TextArea
|
||||
|
||||
|
|
@ -103,9 +173,15 @@ const textareaRef = ref<InstanceType<typeof ATextarea> | null>(null)
|
|||
const selectedModel = ref<string | undefined>(undefined)
|
||||
const availableModels = ref<ModelInfo[]>([])
|
||||
const modelsLoading = ref(false)
|
||||
const showBoardModal = ref(false)
|
||||
const showTeamModal = ref(false)
|
||||
const fileInputRef = ref<HTMLInputElement | null>(null)
|
||||
const fileUploading = ref(false)
|
||||
const isDragOver = ref(false)
|
||||
let dragLeaveTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
const modelOptions = computed(() => {
|
||||
return availableModels.value.map(m => ({
|
||||
return availableModels.value.map((m: ModelInfo) => ({
|
||||
value: m.id,
|
||||
label: m.model,
|
||||
}))
|
||||
|
|
@ -129,6 +205,13 @@ async function fetchModels() {
|
|||
|
||||
onMounted(fetchModels)
|
||||
|
||||
onUnmounted(() => {
|
||||
if (dragLeaveTimer) {
|
||||
clearTimeout(dragLeaveTimer)
|
||||
dragLeaveTimer = null
|
||||
}
|
||||
})
|
||||
|
||||
// @-mention state
|
||||
const mentionVisible = ref(false)
|
||||
const mentionQuery = ref('')
|
||||
|
|
@ -137,7 +220,7 @@ const mentionPosition = ref({ left: 0 })
|
|||
const skillsStore = useSkillsStore()
|
||||
|
||||
const skillSuggestions = computed<SkillSuggestion[]>(() => {
|
||||
return (skillsStore.skills || []).map((s: any) => ({
|
||||
return (skillsStore.skills || []).map((s: ISkillInfo) => ({
|
||||
name: s.name,
|
||||
description: s.description || '',
|
||||
}))
|
||||
|
|
@ -207,6 +290,30 @@ function handleSend(): void {
|
|||
setTimeout(() => { inputText.value = '' }, 0)
|
||||
}
|
||||
|
||||
function handleBoardSubmit(command: string): void {
|
||||
// The BoardMeetingModal constructs an @board:expert1,expert2 topic command.
|
||||
// Send it through the normal chat pipeline — the backend intercepts @board prefix.
|
||||
if (!command.trim()) return
|
||||
emit('send', command, selectedModel.value)
|
||||
}
|
||||
|
||||
function handleTeamSubmit(command: string): void {
|
||||
if (!command.trim()) return
|
||||
emit('send', command, selectedModel.value)
|
||||
}
|
||||
|
||||
function openFilePicker(): void {
|
||||
fileInputRef.value?.click()
|
||||
}
|
||||
|
||||
async function handleFileChange(event: Event): Promise<void> {
|
||||
const target = event.target as HTMLInputElement
|
||||
const file = target.files?.[0]
|
||||
if (!file) return
|
||||
await uploadFile(file)
|
||||
target.value = ''
|
||||
}
|
||||
|
||||
function handlePressEnter(event: KeyboardEvent): void {
|
||||
if (mentionVisible.value) return
|
||||
if (event.shiftKey) return
|
||||
|
|
@ -214,6 +321,63 @@ function handlePressEnter(event: KeyboardEvent): void {
|
|||
handleSend()
|
||||
}
|
||||
|
||||
function handleClear(): void {
|
||||
inputText.value = ''
|
||||
contextPills.value = []
|
||||
closeMention()
|
||||
}
|
||||
|
||||
function handleDragEnter(event: DragEvent): void {
|
||||
if (!event.dataTransfer?.types.includes('Files')) return
|
||||
event.preventDefault()
|
||||
if (dragLeaveTimer) {
|
||||
clearTimeout(dragLeaveTimer)
|
||||
dragLeaveTimer = null
|
||||
}
|
||||
isDragOver.value = true
|
||||
}
|
||||
|
||||
function handleDragOver(event: DragEvent): void {
|
||||
if (!event.dataTransfer?.types.includes('Files')) return
|
||||
event.preventDefault()
|
||||
isDragOver.value = true
|
||||
}
|
||||
|
||||
function handleDragLeave(event: DragEvent): void {
|
||||
if (!event.dataTransfer?.types.includes('Files')) return
|
||||
// Delay to avoid flicker when dragging over child elements
|
||||
dragLeaveTimer = setTimeout(() => {
|
||||
isDragOver.value = false
|
||||
}, 50)
|
||||
}
|
||||
|
||||
function handleDrop(event: DragEvent): void {
|
||||
isDragOver.value = false
|
||||
if (dragLeaveTimer) {
|
||||
clearTimeout(dragLeaveTimer)
|
||||
dragLeaveTimer = null
|
||||
}
|
||||
const files = event.dataTransfer?.files
|
||||
if (files && files.length > 0) {
|
||||
event.preventDefault()
|
||||
const file = files[0]
|
||||
uploadFile(file)
|
||||
}
|
||||
}
|
||||
|
||||
async function uploadFile(file: File): Promise<void> {
|
||||
fileUploading.value = true
|
||||
try {
|
||||
const response = await apiClient.uploadFile(file)
|
||||
emit('send', `[文件] [${response.filename}](${response.download_url})`, selectedModel.value)
|
||||
antMessage.success(`文件上传成功: ${response.filename}`)
|
||||
} catch (error: unknown) {
|
||||
antMessage.error(`上传失败: ${error instanceof Error ? error.message : '未知错误'}`)
|
||||
} finally {
|
||||
fileUploading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function removePill(idx: number): void {
|
||||
contextPills.value.splice(idx, 1)
|
||||
}
|
||||
|
|
@ -221,11 +385,40 @@ function removePill(idx: number): void {
|
|||
|
||||
<style scoped>
|
||||
.chat-input {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: var(--space-3) var(--space-4);
|
||||
padding: var(--space-3) var(--space-5);
|
||||
background: var(--bg-primary);
|
||||
border-top: 1px solid var(--border-color-split);
|
||||
border-top: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.chat-input__drag-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 2px dashed var(--accent-team);
|
||||
border-radius: var(--radius-lg);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.chat-input__drag-overlay::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: -1;
|
||||
background: var(--bg-primary);
|
||||
opacity: 0.9;
|
||||
border-radius: inherit;
|
||||
}
|
||||
|
||||
.chat-input__drag-text {
|
||||
font-size: var(--font-md);
|
||||
color: var(--accent-team);
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
|
||||
.chat-input__pills {
|
||||
|
|
@ -237,34 +430,26 @@ function removePill(idx: number): void {
|
|||
|
||||
.chat-input__row {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
background: var(--bg-primary);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--space-1) var(--space-2);
|
||||
border: 1px solid var(--border-color);
|
||||
transition: border-color var(--transition-fast), box-shadow var(--transition-fast);
|
||||
}
|
||||
|
||||
.chat-input__row:focus-within {
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: 0 0 0 3px var(--color-primary-light);
|
||||
padding: var(--space-2) 0;
|
||||
border-top: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.chat-input__textarea {
|
||||
flex: 1;
|
||||
min-height: 40px;
|
||||
min-height: 32px;
|
||||
}
|
||||
|
||||
.chat-input__textarea :deep(.ant-input) {
|
||||
min-height: 40px;
|
||||
min-height: 32px;
|
||||
line-height: 1.5;
|
||||
padding-top: 8px;
|
||||
padding-bottom: 8px;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
box-shadow: none !important;
|
||||
font-size: var(--font-base);
|
||||
resize: none;
|
||||
}
|
||||
|
||||
.chat-input__textarea :deep(.ant-input:focus) {
|
||||
|
|
@ -277,39 +462,102 @@ function removePill(idx: number): void {
|
|||
|
||||
.chat-input__send {
|
||||
flex-shrink: 0;
|
||||
height: 40px;
|
||||
min-height: 40px;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: var(--radius-md) !important;
|
||||
transition: transform var(--transition-fast), box-shadow var(--transition-fast);
|
||||
background: var(--bg-secondary) !important;
|
||||
border: 1px solid var(--border-color) !important;
|
||||
color: var(--text-tertiary) !important;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
.chat-input__send:not(:disabled):hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 2px 8px rgba(99, 102, 241, 0.3);
|
||||
}
|
||||
|
||||
.chat-input__send:not(:disabled):active {
|
||||
transform: translateY(0);
|
||||
background: var(--text-primary) !important;
|
||||
color: var(--text-inverse) !important;
|
||||
border-color: var(--text-primary) !important;
|
||||
}
|
||||
|
||||
.chat-input__footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-2);
|
||||
padding-top: var(--space-1);
|
||||
}
|
||||
|
||||
.chat-input__footer-left,
|
||||
.chat-input__footer-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.chat-input__hint {
|
||||
font-size: var(--font-xs);
|
||||
color: var(--text-placeholder);
|
||||
}
|
||||
|
||||
.chat-input__action-btn {
|
||||
flex-shrink: 0;
|
||||
font-size: var(--font-xs);
|
||||
color: var(--text-tertiary);
|
||||
border: none;
|
||||
background: transparent;
|
||||
padding: 0 var(--space-1);
|
||||
}
|
||||
|
||||
.chat-input__action-btn:not(:disabled):hover {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.chat-input__action-btn--team {
|
||||
color: var(--accent-team);
|
||||
}
|
||||
|
||||
.chat-input__action-btn--team:not(:disabled):hover {
|
||||
color: var(--accent-team);
|
||||
background: var(--accent-team-soft);
|
||||
}
|
||||
|
||||
.chat-input__action-btn--board {
|
||||
color: var(--accent-board);
|
||||
}
|
||||
|
||||
.chat-input__action-btn--board:not(:disabled):hover {
|
||||
color: var(--accent-board);
|
||||
background: var(--accent-board-soft);
|
||||
}
|
||||
|
||||
.chat-input__action-btn--clear {
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.chat-input__file-input {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.chat-input__model-select {
|
||||
min-width: 160px;
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
.chat-input__model-select :deep(.ant-select-selector) {
|
||||
border: none !important;
|
||||
border-bottom: 1px dashed var(--border-color) !important;
|
||||
box-shadow: none !important;
|
||||
background: transparent !important;
|
||||
padding-left: 0 !important;
|
||||
font-size: var(--font-sm, 12px);
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--font-xs);
|
||||
color: var(--text-tertiary);
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -1,475 +1,49 @@
|
|||
<template>
|
||||
<div class="chat-message" :class="[`chat-message--${message.role}`]">
|
||||
<div class="chat-message__avatar">
|
||||
<a-avatar
|
||||
v-if="message.role === 'assistant'"
|
||||
:size="32"
|
||||
class="chat-message__avatar--assistant"
|
||||
>
|
||||
<template #icon><RobotOutlined /></template>
|
||||
</a-avatar>
|
||||
<a-avatar
|
||||
v-else
|
||||
:size="32"
|
||||
class="chat-message__avatar--user"
|
||||
>
|
||||
<template #icon><UserOutlined /></template>
|
||||
</a-avatar>
|
||||
</div>
|
||||
<div class="chat-message__body">
|
||||
<!-- Expert message wrapper -->
|
||||
<ExpertMessage
|
||||
v-if="message.expert_id || message.expert_name"
|
||||
:expert-name="message.expert_name || ''"
|
||||
:expert-color="message.expert_color || '#1890ff'"
|
||||
:expert-avatar="message.expert_avatar || ''"
|
||||
:is-lead="false"
|
||||
:is-moderator="message.board_role === 'moderator'"
|
||||
:board-round="message.board_round"
|
||||
:message-type="message.message_type || 'chat'"
|
||||
>
|
||||
<div class="chat-message__content chat-message__content--assistant">
|
||||
<div ref="markdownRef" class="chat-message__markdown" v-html="renderedContent"></div>
|
||||
<a-spin v-if="isLoading" size="small" class="chat-message__loading" />
|
||||
</div>
|
||||
</ExpertMessage>
|
||||
<!-- Non-expert message body -->
|
||||
<template v-else>
|
||||
<!-- Thinking block (collapsible) -->
|
||||
<ThinkingBlock
|
||||
v-if="message.thinking"
|
||||
:content="message.thinking"
|
||||
:is-streaming="isLoading && !message.content"
|
||||
/>
|
||||
<!-- Tool call cards -->
|
||||
<div v-if="message.tool_calls && message.tool_calls.length > 0" class="chat-message__tool-cards">
|
||||
<ToolCallCard
|
||||
v-for="tc in message.tool_calls"
|
||||
:key="tc.id"
|
||||
:tool-call="tc"
|
||||
/>
|
||||
</div>
|
||||
<!-- Tool call indicators (legacy) -->
|
||||
<div v-else-if="toolCalls.length > 0" class="chat-message__tools">
|
||||
<ToolCallIndicator
|
||||
v-for="(tc, idx) in toolCalls"
|
||||
:key="idx"
|
||||
:type="tc.type"
|
||||
:name="tc.name"
|
||||
/>
|
||||
</div>
|
||||
<!-- Message content (final answer) -->
|
||||
<div v-if="message.content" class="chat-message__content" :class="[`chat-message__content--${message.role}`]">
|
||||
<div v-if="message.role === 'assistant'" ref="markdownRef" class="chat-message__markdown" v-html="renderedContent"></div>
|
||||
<span v-else>{{ message.content }}</span>
|
||||
</div>
|
||||
<!-- Loading indicator (no content yet) -->
|
||||
<div v-else-if="isLoading" class="chat-message__content chat-message__content--assistant">
|
||||
<a-spin size="small" class="chat-message__loading" />
|
||||
</div>
|
||||
<!-- Routing info -->
|
||||
<div v-if="showRouting" class="chat-message__routing">
|
||||
<a-tag color="purple">
|
||||
<ThunderboltOutlined /> {{ message.matched_skill }}
|
||||
</a-tag>
|
||||
<a-tag v-if="message.confidence !== undefined" color="green">
|
||||
置信度: {{ (message.confidence * 100).toFixed(1) }}%
|
||||
</a-tag>
|
||||
<a-tag v-if="message.routing_method" color="default">
|
||||
{{ message.routing_method }}
|
||||
</a-tag>
|
||||
</div>
|
||||
</template>
|
||||
<div class="chat-message__time">
|
||||
{{ formattedTime }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<MessageShell
|
||||
:role="message.role"
|
||||
:name="spec.shell.name"
|
||||
:meta="spec.shell.meta"
|
||||
:avatar="spec.shell.avatar"
|
||||
:color="spec.shell.color"
|
||||
>
|
||||
<component
|
||||
:is="spec.component"
|
||||
v-bind="componentProps"
|
||||
v-on="componentListeners"
|
||||
/>
|
||||
</MessageShell>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, nextTick, watch } from 'vue'
|
||||
import MarkdownIt from 'markdown-it'
|
||||
import DOMPurify from 'dompurify'
|
||||
import hljs from 'highlight.js/lib/core'
|
||||
import python from 'highlight.js/lib/languages/python'
|
||||
import javascript from 'highlight.js/lib/languages/javascript'
|
||||
import typescript from 'highlight.js/lib/languages/typescript'
|
||||
import json from 'highlight.js/lib/languages/json'
|
||||
import bash from 'highlight.js/lib/languages/bash'
|
||||
import yaml from 'highlight.js/lib/languages/yaml'
|
||||
import sql from 'highlight.js/lib/languages/sql'
|
||||
import xml from 'highlight.js/lib/languages/xml'
|
||||
import css from 'highlight.js/lib/languages/css'
|
||||
import markdown from 'highlight.js/lib/languages/markdown'
|
||||
|
||||
hljs.registerLanguage('python', python)
|
||||
hljs.registerLanguage('javascript', javascript)
|
||||
hljs.registerLanguage('typescript', typescript)
|
||||
hljs.registerLanguage('json', json)
|
||||
hljs.registerLanguage('bash', bash)
|
||||
hljs.registerLanguage('yaml', yaml)
|
||||
hljs.registerLanguage('sql', sql)
|
||||
hljs.registerLanguage('xml', xml)
|
||||
hljs.registerLanguage('html', xml)
|
||||
hljs.registerLanguage('css', css)
|
||||
hljs.registerLanguage('markdown', markdown)
|
||||
import { Avatar as AAvatar, Tag as ATag, Spin as ASpin, message as antMessage } from 'ant-design-vue'
|
||||
import { RobotOutlined, UserOutlined, ThunderboltOutlined } from '@ant-design/icons-vue'
|
||||
import MessageShell from './messages/MessageShell.vue'
|
||||
import { computed } from 'vue'
|
||||
import { useMessageRenderer } from './helpers/useMessageRenderer'
|
||||
import { useChatStore } from '@/stores/chat'
|
||||
import type { IChatMessage } from '@/api/types'
|
||||
import ToolCallIndicator from './ToolCallIndicator.vue'
|
||||
import ToolCallCard from './ToolCallCard.vue'
|
||||
import ExpertMessage from './ExpertMessage.vue'
|
||||
import ThinkingBlock from './ThinkingBlock.vue'
|
||||
|
||||
const md = new MarkdownIt({
|
||||
html: false,
|
||||
linkify: true,
|
||||
breaks: true,
|
||||
highlight(str: string, lang: string): string {
|
||||
let highlighted: string
|
||||
if (lang && hljs.getLanguage(lang)) {
|
||||
highlighted = hljs.highlight(str, { language: lang, ignoreIllegals: true }).value
|
||||
} else {
|
||||
// Skip highlightAuto for unmarked code blocks — too expensive for large blocks
|
||||
highlighted = MarkdownIt.prototype.utils.escapeHtml(str)
|
||||
}
|
||||
return `<pre class="hljs" data-language="${lang || ''}"><code>${highlighted}</code></pre>`
|
||||
},
|
||||
})
|
||||
|
||||
// Custom image renderer: inline thumbnail with click-to-zoom
|
||||
md.renderer.rules.image = (tokens, idx, _options, _env, _self) => {
|
||||
const token = tokens[idx]
|
||||
const rawSrc = token.attrGet('src') || ''
|
||||
const rawAlt = token.content || ''
|
||||
// Only allow http/https URLs to prevent javascript:/data: URI injection
|
||||
if (!/^https?:\/\//i.test(rawSrc)) return ''
|
||||
// Escape HTML attribute values to prevent attribute injection
|
||||
const src = rawSrc.replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<')
|
||||
const alt = rawAlt.replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<')
|
||||
return `<img src="${src}" alt="${alt}" class="chat-message__inline-image" loading="lazy" />`
|
||||
}
|
||||
|
||||
// Sanitize markdown output to prevent XSS (javascript: links, data: URIs, etc.)
|
||||
function sanitize(html: string): string {
|
||||
return DOMPurify.sanitize(html, {
|
||||
ALLOWED_TAGS: [
|
||||
'p', 'br', 'strong', 'em', 'del', 'code', 'pre', 'a',
|
||||
'ul', 'ol', 'li', 'h1', 'h2', 'h3', 'h4', 'blockquote',
|
||||
'table', 'thead', 'tbody', 'tr', 'th', 'td', 'span',
|
||||
'div', 'img', 'sup', 'sub',
|
||||
],
|
||||
ALLOWED_ATTR: ['href', 'target', 'rel', 'class', 'data-language', 'src', 'alt', 'loading'],
|
||||
ALLOW_DATA_ATTR: false,
|
||||
// Restrict URI schemes to http/https only (blocks javascript:, data:, vbscript:)
|
||||
ALLOWED_URI_REGEXP: /^(?:(?:https?):\/\/[^\s]*)$/i,
|
||||
})
|
||||
}
|
||||
|
||||
interface ToolCall {
|
||||
type: string
|
||||
name: string
|
||||
}
|
||||
|
||||
interface IProps {
|
||||
interface Props {
|
||||
message: IChatMessage
|
||||
}
|
||||
|
||||
const props = defineProps<IProps>()
|
||||
const markdownRef = ref<HTMLElement | null>(null)
|
||||
const props = defineProps<Props>()
|
||||
const spec = useMessageRenderer(props.message)
|
||||
const chatStore = useChatStore()
|
||||
|
||||
const isLoading = computed(() => {
|
||||
return props.message.role === 'assistant' && props.message.status === 'pending' && !props.message.content
|
||||
})
|
||||
const componentProps = computed(() => ({
|
||||
...spec.value.props,
|
||||
...(spec.value.type === 'error' ? { loading: chatStore.isLoading } : {}),
|
||||
}))
|
||||
|
||||
const showRouting = computed(() => {
|
||||
return props.message.role === 'assistant' && props.message.matched_skill
|
||||
})
|
||||
const componentListeners = computed(() =>
|
||||
spec.value.type === 'error' ? { retry: handleRetry } : {}
|
||||
)
|
||||
|
||||
const formattedTime = computed(() => {
|
||||
const date = new Date(props.message.timestamp)
|
||||
return date.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })
|
||||
})
|
||||
|
||||
const renderedContent = computed(() => {
|
||||
if (!props.message.content) return ''
|
||||
return sanitize(md.render(props.message.content))
|
||||
})
|
||||
|
||||
// Add copy buttons to code blocks after rendering
|
||||
watch(renderedContent, () => {
|
||||
nextTick(() => {
|
||||
if (!markdownRef.value) return
|
||||
const pres = markdownRef.value.querySelectorAll('pre.hljs')
|
||||
pres.forEach((pre) => {
|
||||
if (pre.querySelector('.chat-message__code-header')) return // already enhanced
|
||||
const lang = pre.getAttribute('data-language') || ''
|
||||
const code = pre.querySelector('code')
|
||||
if (!code) return
|
||||
|
||||
const header = document.createElement('div')
|
||||
header.className = 'chat-message__code-header'
|
||||
if (lang) {
|
||||
const langLabel = document.createElement('span')
|
||||
langLabel.className = 'chat-message__code-lang'
|
||||
langLabel.textContent = lang
|
||||
header.appendChild(langLabel)
|
||||
}
|
||||
const copyBtn = document.createElement('button')
|
||||
copyBtn.className = 'chat-message__copy-btn'
|
||||
copyBtn.innerHTML = '<span class="anticon"><svg viewBox="0 0 1024 1024" width="14" height="14" fill="currentColor"><path d="M832 64H296c-4.4 0-8 3.6-8 8v56c0 4.4 3.6 8 8 8h496v608c0 4.4 3.6 8 8 8h56c4.4 0 8-3.6 8-8V112c0-26.5-21.5-48-48-48zM704 256H192c-26.5 0-48 21.5-48 48v608c0 26.5 21.5 48 48 48h512c26.5 0 48-21.5 48-48V304c0-26.5-21.5-48-48-48zm-8 608H200V312h496v552z"/></svg></span>'
|
||||
copyBtn.title = '复制代码'
|
||||
copyBtn.addEventListener('click', () => {
|
||||
navigator.clipboard.writeText(code.textContent || '').then(() => {
|
||||
antMessage.success('已复制到剪贴板')
|
||||
}).catch(() => {
|
||||
antMessage.error('复制失败')
|
||||
})
|
||||
})
|
||||
header.appendChild(copyBtn)
|
||||
pre.insertBefore(header, pre.firstChild)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
const toolCalls = computed<ToolCall[]>(() => {
|
||||
const calls: ToolCall[] = []
|
||||
const content = props.message.content || ''
|
||||
const toolPattern = /^\[(Read|Edit|Bash|Write|Search|Grep|Glob)\]/gm
|
||||
let match
|
||||
while ((match = toolPattern.exec(content)) !== null) {
|
||||
const toolName = match[1].toLowerCase()
|
||||
calls.push({ type: toolName, name: match[1] })
|
||||
}
|
||||
return calls
|
||||
})
|
||||
function handleRetry(): void {
|
||||
if (chatStore.isLoading) return
|
||||
chatStore.resendLastUserMessage()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.chat-message {
|
||||
display: flex;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-4) var(--space-5);
|
||||
animation: messageSlideIn 0.3s ease-out;
|
||||
}
|
||||
|
||||
@keyframes messageSlideIn {
|
||||
from { opacity: 0; transform: translateY(8px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
.chat-message--user {
|
||||
flex-direction: row-reverse;
|
||||
}
|
||||
|
||||
.chat-message__avatar {
|
||||
flex-shrink: 0;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.chat-message__avatar--assistant {
|
||||
background: var(--gradient-brand) !important;
|
||||
box-shadow: 0 2px 8px rgba(99, 102, 241, 0.2);
|
||||
}
|
||||
|
||||
.chat-message__avatar--user {
|
||||
background-color: var(--color-success) !important;
|
||||
box-shadow: 0 2px 8px rgba(34, 197, 94, 0.2);
|
||||
}
|
||||
|
||||
.chat-message__body {
|
||||
max-width: 75%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.chat-message--user .chat-message__body {
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.chat-message__tool-cards {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.chat-message__tools {
|
||||
display: flex;
|
||||
gap: var(--space-1);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.chat-message__content {
|
||||
padding: var(--space-3) var(--space-4);
|
||||
border-radius: var(--radius-lg);
|
||||
line-height: var(--leading-normal);
|
||||
font-size: var(--font-base);
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.chat-message__content--user {
|
||||
background: var(--color-primary);
|
||||
color: var(--text-inverse);
|
||||
border-bottom-right-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.chat-message__content--assistant {
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-bottom-left-radius: var(--radius-sm);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.chat-message__markdown {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.chat-message__markdown :deep(p) {
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
.chat-message__markdown :deep(p:last-child) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.chat-message__markdown :deep(pre) {
|
||||
background: var(--code-bg);
|
||||
color: var(--code-fg);
|
||||
padding: 0;
|
||||
border-radius: var(--radius-md);
|
||||
overflow: hidden;
|
||||
margin: var(--space-2) 0;
|
||||
font-size: var(--font-sm);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.chat-message__markdown :deep(pre.hljs) {
|
||||
background: var(--code-bg);
|
||||
}
|
||||
|
||||
.chat-message__markdown :deep(pre code) {
|
||||
display: block;
|
||||
padding: var(--space-3);
|
||||
overflow-x: auto;
|
||||
font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', Menlo, Consolas, monospace;
|
||||
}
|
||||
|
||||
/* Code block header (language label + copy button) */
|
||||
.chat-message__markdown :deep(.chat-message__code-header) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: var(--space-1) var(--space-3);
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.chat-message__markdown :deep(.chat-message__code-lang) {
|
||||
font-size: var(--font-xs);
|
||||
color: var(--code-comment);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.chat-message__markdown :deep(.chat-message__copy-btn) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--code-comment);
|
||||
cursor: pointer;
|
||||
border-radius: var(--radius-sm);
|
||||
transition: all var(--transition-fast);
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.chat-message__markdown :deep(.chat-message__copy-btn:hover) {
|
||||
color: var(--code-fg);
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
/* highlight.js Catppuccin Mocha theme tokens */
|
||||
.chat-message__markdown :deep(.hljs-keyword) { color: var(--code-keyword); }
|
||||
.chat-message__markdown :deep(.hljs-string) { color: var(--code-string); }
|
||||
.chat-message__markdown :deep(.hljs-number) { color: var(--code-number); }
|
||||
.chat-message__markdown :deep(.hljs-comment) { color: var(--code-comment); font-style: italic; }
|
||||
.chat-message__markdown :deep(.hljs-function) { color: var(--code-function); }
|
||||
.chat-message__markdown :deep(.hljs-variable) { color: var(--code-variable); }
|
||||
.chat-message__markdown :deep(.hljs-type) { color: var(--code-type); }
|
||||
.chat-message__markdown :deep(.hljs-built_in) { color: var(--code-type); }
|
||||
.chat-message__markdown :deep(.hljs-attr) { color: var(--code-function); }
|
||||
.chat-message__markdown :deep(.hljs-selector-tag) { color: var(--code-keyword); }
|
||||
.chat-message__markdown :deep(.hljs-selector-class) { color: var(--code-type); }
|
||||
.chat-message__markdown :deep(.hljs-selector-id) { color: var(--code-type); }
|
||||
.chat-message__markdown :deep(.hljs-literal) { color: var(--code-number); }
|
||||
.chat-message__markdown :deep(.hljs-meta) { color: var(--code-comment); }
|
||||
.chat-message__markdown :deep(.hljs-title) { color: var(--code-function); }
|
||||
.chat-message__markdown :deep(.hljs-params) { color: var(--code-fg); }
|
||||
.chat-message__markdown :deep(.hljs-section) { color: var(--code-function); }
|
||||
.chat-message__markdown :deep(.hljs-addition) { background: var(--code-added-bg); }
|
||||
.chat-message__markdown :deep(.hljs-deletion) { background: var(--code-removed-bg); }
|
||||
|
||||
.chat-message__markdown :deep(.chat-message__inline-image) {
|
||||
max-width: 100%;
|
||||
max-height: 200px;
|
||||
object-fit: contain;
|
||||
border-radius: var(--radius-md);
|
||||
cursor: pointer;
|
||||
margin: var(--space-2) 0;
|
||||
transition: opacity var(--transition-fast);
|
||||
}
|
||||
|
||||
.chat-message__markdown :deep(.chat-message__inline-image:hover) {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.chat-message__markdown :deep(code) {
|
||||
font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', Menlo, Consolas, monospace;
|
||||
font-size: var(--font-sm);
|
||||
}
|
||||
|
||||
.chat-message__markdown :deep(:not(pre) > code) {
|
||||
background: var(--color-primary-light);
|
||||
color: var(--color-primary);
|
||||
padding: 2px var(--space-1);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: var(--font-xs);
|
||||
}
|
||||
|
||||
.chat-message__markdown :deep(ul),
|
||||
.chat-message__markdown :deep(ol) {
|
||||
padding-left: var(--space-5);
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
.chat-message__markdown :deep(h1),
|
||||
.chat-message__markdown :deep(h2),
|
||||
.chat-message__markdown :deep(h3) {
|
||||
margin-top: var(--space-3);
|
||||
margin-bottom: var(--space-2);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
}
|
||||
|
||||
.chat-message__loading {
|
||||
margin-left: var(--space-2);
|
||||
}
|
||||
|
||||
.chat-message__routing {
|
||||
display: flex;
|
||||
gap: var(--space-1);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.chat-message__time {
|
||||
font-size: var(--font-xs);
|
||||
color: var(--text-placeholder);
|
||||
padding: 0 var(--space-1);
|
||||
}
|
||||
/* Layout is handled by MessageShell; ChatMessage is now a pure dispatcher. */
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -47,7 +47,7 @@ const emit = defineEmits<{
|
|||
select: [id: string]
|
||||
}>()
|
||||
|
||||
const collapsed = ref(true)
|
||||
const collapsed = ref(false)
|
||||
|
||||
function handleCreate(): void {
|
||||
emit('create')
|
||||
|
|
|
|||
|
|
@ -1,237 +1,153 @@
|
|||
<template>
|
||||
<div v-if="teamStore.isTeamMode" class="expert-team-view">
|
||||
<div class="expert-team-view__status-bar">
|
||||
<div class="expert-team-view__header">
|
||||
<div class="expert-team-view__title">
|
||||
<span class="expert-team-view__label">专家团</span>
|
||||
<span class="expert-team-view__count">{{ completedCount }}/{{ totalCount }} 阶段完成</span>
|
||||
</div>
|
||||
<div class="expert-team-view__experts">
|
||||
<div
|
||||
<span
|
||||
v-for="expert in teamStore.activeExperts"
|
||||
:key="expert.id"
|
||||
class="expert-team-view__expert-chip"
|
||||
:class="{ 'expert-team-view__expert-chip--selected': teamStore.selectedExpertId === expert.id }"
|
||||
:style="{ borderColor: expert.color }"
|
||||
@click="teamStore.selectExpert(teamStore.selectedExpertId === expert.id ? null : expert.id)"
|
||||
class="expert-team-view__expert"
|
||||
:class="{ 'expert-team-view__expert--lead': expert.is_lead }"
|
||||
>
|
||||
<div class="expert-team-view__expert-avatar" :style="{ backgroundColor: expert.color }">
|
||||
{{ expert.name.charAt(0).toUpperCase() }}
|
||||
</div>
|
||||
<span class="expert-team-view__expert-name">{{ expert.name }}</span>
|
||||
<a-tag v-if="expert.is_lead" color="gold" size="small">Lead</a-tag>
|
||||
</div>
|
||||
</div>
|
||||
<div class="expert-team-view__progress">
|
||||
<span>{{ completedCount }}/{{ totalCount }} 阶段完成</span>
|
||||
<a-progress :percent="progressPercent" size="small" :show-info="false" />
|
||||
{{ expert.name }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="showPlan" class="expert-team-view__plan">
|
||||
<div class="expert-team-view__plan-header" @click="planExpanded = !planExpanded">
|
||||
<span>协作计划</span>
|
||||
<UpOutlined v-if="planExpanded" />
|
||||
<DownOutlined v-else />
|
||||
</div>
|
||||
<div v-if="planExpanded" class="expert-team-view__plan-body">
|
||||
<div
|
||||
v-for="phase in phases"
|
||||
:key="phase.id"
|
||||
class="expert-team-view__phase"
|
||||
:class="`expert-team-view__phase--${phase.status}`"
|
||||
>
|
||||
<div class="expert-team-view__phase-header">
|
||||
<span class="expert-team-view__phase-name">{{ phase.name }}</span>
|
||||
<span class="expert-team-view__phase-expert">{{ phase.assigned_expert }}</span>
|
||||
<a-tag :color="statusColor(phase.status)" size="small">{{ statusLabel(phase.status) }}</a-tag>
|
||||
</div>
|
||||
<div v-if="phase.task_description" class="expert-team-view__phase-desc">
|
||||
{{ phase.task_description }}
|
||||
</div>
|
||||
<div v-if="phase.result" class="expert-team-view__phase-result">
|
||||
{{ phase.result }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="phases.length > 0" class="expert-team-view__plan">
|
||||
<div
|
||||
v-for="phase in phases"
|
||||
:key="phase.id"
|
||||
class="expert-team-view__phase"
|
||||
:class="`expert-team-view__phase--${phase.status}`"
|
||||
>
|
||||
<span class="expert-team-view__phase-num">{{ phases.indexOf(phase) + 1 }}</span>
|
||||
<span class="expert-team-view__phase-name">{{ phase.name }}</span>
|
||||
<span class="expert-team-view__phase-expert">{{ phase.assigned_expert }}</span>
|
||||
<span class="expert-team-view__phase-status">{{ statusLabel(phase.status) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { Progress as AProgress, Tag as ATag } from 'ant-design-vue'
|
||||
import { UpOutlined, DownOutlined } from '@ant-design/icons-vue'
|
||||
import { computed } from 'vue'
|
||||
import { useTeamStore } from '@/stores/team'
|
||||
|
||||
const teamStore = useTeamStore()
|
||||
const planExpanded = ref(false)
|
||||
const showPlan = ref(true)
|
||||
|
||||
const phases = computed(() => teamStore.teamState?.plan_phases || [])
|
||||
const completedCount = computed(() => phases.value.filter(p => p.status === 'completed').length)
|
||||
const completedCount = computed(() => phases.value.filter((p) => p.status === 'completed').length)
|
||||
const totalCount = computed(() => phases.value.length)
|
||||
const progressPercent = computed(() => totalCount.value > 0 ? Math.round(completedCount.value / totalCount.value * 100) : 0)
|
||||
|
||||
function statusColor(status: string): string {
|
||||
const colors: Record<string, string> = { pending: 'default', in_progress: 'processing', completed: 'success', failed: 'error' }
|
||||
return colors[status] || 'default'
|
||||
}
|
||||
|
||||
function statusLabel(status: string): string {
|
||||
const labels: Record<string, string> = { pending: '待执行', in_progress: '执行中', completed: '已完成', failed: '失败' }
|
||||
const labels: Record<string, string> = {
|
||||
pending: '待执行',
|
||||
in_progress: '执行中',
|
||||
completed: '已完成',
|
||||
failed: '失败',
|
||||
}
|
||||
return labels[status] || status
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.expert-team-view {
|
||||
margin-bottom: var(--space-3);
|
||||
margin: 0 var(--space-5);
|
||||
padding: var(--space-3) 0;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.expert-team-view__status-bar {
|
||||
.expert-team-view__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: var(--space-2) var(--space-3);
|
||||
background: var(--bg-secondary);
|
||||
border-radius: var(--radius-lg);
|
||||
gap: var(--space-3);
|
||||
margin-bottom: var(--space-3);
|
||||
}
|
||||
|
||||
.expert-team-view__title {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.expert-team-view__label {
|
||||
font-size: var(--font-sm);
|
||||
font-weight: var(--font-weight-medium);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.expert-team-view__count {
|
||||
font-size: var(--font-xs);
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.expert-team-view__experts {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.expert-team-view__expert-chip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
padding: 2px var(--space-2);
|
||||
border: 1px solid;
|
||||
border-radius: var(--radius-full);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.expert-team-view__expert-chip--selected {
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.expert-team-view__expert-avatar {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: var(--radius-full);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-inverse);
|
||||
font-size: 10px;
|
||||
font-weight: var(--font-weight-semibold);
|
||||
}
|
||||
|
||||
.expert-team-view__expert-name {
|
||||
gap: var(--space-3);
|
||||
font-size: var(--font-xs);
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.expert-team-view__progress {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
font-size: var(--font-xs);
|
||||
min-width: 160px;
|
||||
.expert-team-view__expert {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
}
|
||||
|
||||
.expert-team-view__expert--lead {
|
||||
color: var(--accent-team);
|
||||
}
|
||||
|
||||
.expert-team-view__plan {
|
||||
margin-top: var(--space-2);
|
||||
background: var(--bg-secondary);
|
||||
border-radius: var(--radius-lg);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.expert-team-view__plan-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: var(--space-2) var(--space-3);
|
||||
cursor: pointer;
|
||||
font-size: var(--font-sm);
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
|
||||
.expert-team-view__plan-body {
|
||||
padding: 0 var(--space-3) var(--space-2);
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.expert-team-view__phase {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
padding: var(--space-2) 0;
|
||||
font-size: var(--font-xs);
|
||||
border-bottom: 1px solid var(--border-primary);
|
||||
}
|
||||
|
||||
.expert-team-view__phase:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.expert-team-view__phase-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
display: grid;
|
||||
grid-template-columns: 18px 1fr 120px 60px;
|
||||
gap: var(--space-2);
|
||||
align-items: baseline;
|
||||
padding: 4px 0;
|
||||
font-size: var(--font-sm);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.expert-team-view__phase-num {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 10px;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.expert-team-view__phase-name {
|
||||
flex: 1;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.expert-team-view__phase-expert {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.expert-team-view__phase-desc {
|
||||
color: var(--text-secondary);
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: var(--font-xs);
|
||||
padding-left: var(--space-2);
|
||||
border-left: 2px solid var(--border-primary);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.expert-team-view__phase-result {
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.expert-team-view__phase-status {
|
||||
font-size: var(--font-xs);
|
||||
padding-left: var(--space-2);
|
||||
border-left: 2px solid var(--color-success);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
max-height: 80px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
color: var(--text-tertiary);
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.expert-team-view__phase--pending {
|
||||
opacity: 0.6;
|
||||
.expert-team-view__phase--in_progress .expert-team-view__phase-status {
|
||||
color: var(--accent-team);
|
||||
}
|
||||
|
||||
.expert-team-view__phase--in_progress {
|
||||
font-weight: var(--font-weight-medium);
|
||||
background: var(--bg-tertiary);
|
||||
margin: 0 calc(-1 * var(--space-3));
|
||||
padding-left: var(--space-3);
|
||||
padding-right: var(--space-3);
|
||||
border-radius: var(--radius-sm);
|
||||
.expert-team-view__phase--completed .expert-team-view__phase-status {
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.expert-team-view__phase--completed {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.expert-team-view__phase--failed {
|
||||
color: var(--color-error);
|
||||
}
|
||||
|
||||
.expert-team-view__phase--failed .expert-team-view__phase-result {
|
||||
border-left-color: var(--color-error);
|
||||
.expert-team-view__phase--failed .expert-team-view__phase-status {
|
||||
color: #ef4444;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,330 @@
|
|||
<template>
|
||||
<a-modal
|
||||
:open="open"
|
||||
title="组建专家团"
|
||||
:width="640"
|
||||
:ok-text="okText"
|
||||
:ok-button-props="{ disabled: !canSubmit }"
|
||||
:confirm-loading="submitting"
|
||||
cancel-text="取消"
|
||||
@ok="handleSubmit"
|
||||
@cancel="handleCancel"
|
||||
>
|
||||
<div class="team-modal">
|
||||
<!-- 任务描述 -->
|
||||
<div class="team-modal__field">
|
||||
<label class="team-modal__label">
|
||||
任务描述 <span class="team-modal__required">*</span>
|
||||
</label>
|
||||
<a-textarea
|
||||
v-model:value="task"
|
||||
:auto-size="{ minRows: 2, maxRows: 4 }"
|
||||
placeholder="例如:帮我设计一个电商订单模块"
|
||||
:disabled="submitting"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 选择专家 -->
|
||||
<div class="team-modal__field">
|
||||
<label class="team-modal__label">
|
||||
选择专家 <span class="team-modal__required">*</span>
|
||||
<span class="team-modal__hint">(已选 {{ selectedExperts.length }} / {{ MAX_EXPERTS }},首位为 Lead)</span>
|
||||
</label>
|
||||
|
||||
<div v-if="loading" class="team-modal__loading">
|
||||
<a-spin size="small" /> 加载专家列表...
|
||||
</div>
|
||||
|
||||
<div v-else-if="experts.length === 0" class="team-modal__empty">
|
||||
<a-empty description="未找到可用专家模板" :image="simpleImage" />
|
||||
</div>
|
||||
|
||||
<div v-else class="team-modal__experts">
|
||||
<div
|
||||
v-for="expert in experts"
|
||||
:key="expert.name"
|
||||
class="team-modal__expert-card"
|
||||
:class="{
|
||||
'team-modal__expert-card--selected': isSelected(expert.name),
|
||||
'team-modal__expert-card--lead': isLead(expert.name),
|
||||
}"
|
||||
:style="isSelected(expert.name) ? { borderColor: expert.color } : {}"
|
||||
@click="toggleExpert(expert.name)"
|
||||
>
|
||||
<div class="team-modal__expert-header">
|
||||
<span class="team-modal__expert-avatar">{{ expert.avatar }}</span>
|
||||
<span class="team-modal__expert-name">{{ expert.name }}</span>
|
||||
<a-tag v-if="isLead(expert.name)" color="blue" size="small">Lead</a-tag>
|
||||
<a-tag v-else-if="isSelected(expert.name)" color="green" size="small">
|
||||
#{{ selectedOrder(expert.name) }}
|
||||
</a-tag>
|
||||
</div>
|
||||
<div class="team-modal__expert-persona">
|
||||
{{ truncate(expert.persona, 80) }}
|
||||
</div>
|
||||
<div v-if="expert.decision_framework" class="team-modal__expert-framework">
|
||||
<ThunderboltOutlined /> {{ expert.decision_framework }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 预览 -->
|
||||
<div v-if="previewCommand" class="team-modal__preview">
|
||||
<div class="team-modal__preview-label">将发送:</div>
|
||||
<code class="team-modal__preview-code">{{ previewCommand }}</code>
|
||||
</div>
|
||||
</div>
|
||||
</a-modal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import {
|
||||
Modal as AModal,
|
||||
Textarea as ATextarea,
|
||||
Spin as ASpin,
|
||||
Empty as AEmpty,
|
||||
Tag as ATag,
|
||||
} from 'ant-design-vue'
|
||||
import { ThunderboltOutlined } from '@ant-design/icons-vue'
|
||||
import { apiClient } from '@/api/client'
|
||||
import type { IExpertTemplate } from '@/api/types'
|
||||
|
||||
const MAX_EXPERTS = 10
|
||||
|
||||
interface IProps {
|
||||
open: boolean
|
||||
}
|
||||
|
||||
const props = defineProps<IProps>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:open': [value: boolean]
|
||||
submit: [command: string]
|
||||
}>()
|
||||
|
||||
const task = ref('')
|
||||
const selectedExperts = ref<string[]>([])
|
||||
const experts = ref<IExpertTemplate[]>([])
|
||||
const loading = ref(false)
|
||||
const submitting = ref(false)
|
||||
|
||||
const simpleImage = AEmpty.PRESENTED_IMAGE_SIMPLE
|
||||
|
||||
const canSubmit = computed(() => {
|
||||
return task.value.trim().length > 0 && selectedExperts.value.length >= 1
|
||||
})
|
||||
|
||||
const okText = computed(() => {
|
||||
if (!canSubmit.value) return '组建专家团'
|
||||
return `组建专家团(${selectedExperts.value.length} 位专家)`
|
||||
})
|
||||
|
||||
const previewCommand = computed(() => {
|
||||
if (!canSubmit.value) return ''
|
||||
const expertList = selectedExperts.value.join(',')
|
||||
const trimmedTask = task.value.trim()
|
||||
return `@team:${expertList} ${trimmedTask}`
|
||||
})
|
||||
|
||||
async function loadExperts(): Promise<void> {
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await apiClient.getExperts()
|
||||
experts.value = data.experts || []
|
||||
} catch (error) {
|
||||
console.error('Failed to load experts:', error)
|
||||
experts.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function isSelected(name: string): boolean {
|
||||
return selectedExperts.value.includes(name)
|
||||
}
|
||||
|
||||
function isLead(name: string): boolean {
|
||||
return selectedExperts.value[0] === name
|
||||
}
|
||||
|
||||
function selectedOrder(name: string): number {
|
||||
const idx = selectedExperts.value.indexOf(name)
|
||||
return idx + 1
|
||||
}
|
||||
|
||||
function toggleExpert(name: string): void {
|
||||
if (submitting.value) return
|
||||
const idx = selectedExperts.value.indexOf(name)
|
||||
if (idx === -1) {
|
||||
if (selectedExperts.value.length >= MAX_EXPERTS) return
|
||||
selectedExperts.value.push(name)
|
||||
} else {
|
||||
selectedExperts.value.splice(idx, 1)
|
||||
}
|
||||
}
|
||||
|
||||
function truncate(text: string, max: number): string {
|
||||
if (!text) return ''
|
||||
const cleaned = text.replace(/\s+/g, ' ').trim()
|
||||
return cleaned.length > max ? cleaned.slice(0, max) + '...' : cleaned
|
||||
}
|
||||
|
||||
function handleSubmit(): void {
|
||||
if (!canSubmit.value) return
|
||||
submitting.value = true
|
||||
emit('submit', previewCommand.value)
|
||||
setTimeout(() => {
|
||||
submitting.value = false
|
||||
resetForm()
|
||||
emit('update:open', false)
|
||||
}, 50)
|
||||
}
|
||||
|
||||
function handleCancel(): void {
|
||||
resetForm()
|
||||
emit('update:open', false)
|
||||
}
|
||||
|
||||
function resetForm(): void {
|
||||
task.value = ''
|
||||
selectedExperts.value = []
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.open,
|
||||
(newOpen) => {
|
||||
if (newOpen) {
|
||||
loadExperts()
|
||||
}
|
||||
}
|
||||
)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.team-modal {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.team-modal__field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.team-modal__label {
|
||||
font-weight: var(--font-weight-semibold);
|
||||
font-size: var(--font-sm);
|
||||
color: var(--text-primary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.team-modal__required {
|
||||
color: var(--color-error);
|
||||
}
|
||||
|
||||
.team-modal__hint {
|
||||
font-weight: var(--font-weight-normal);
|
||||
font-size: var(--font-xs);
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.team-modal__loading,
|
||||
.team-modal__empty {
|
||||
padding: var(--space-4);
|
||||
text-align: center;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.team-modal__experts {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: var(--space-2);
|
||||
max-height: 280px;
|
||||
overflow-y: auto;
|
||||
padding: var(--space-1);
|
||||
}
|
||||
|
||||
.team-modal__expert-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-md);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
background: var(--bg-primary);
|
||||
}
|
||||
|
||||
.team-modal__expert-card:hover {
|
||||
border-color: var(--color-primary);
|
||||
background: var(--bg-secondary, var(--bg-primary));
|
||||
}
|
||||
|
||||
.team-modal__expert-card--selected {
|
||||
background: rgba(59, 130, 246, 0.04);
|
||||
border-width: 2px;
|
||||
}
|
||||
|
||||
.team-modal__expert-card--lead {
|
||||
background: rgba(59, 130, 246, 0.06);
|
||||
}
|
||||
|
||||
.team-modal__expert-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.team-modal__expert-avatar {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.team-modal__expert-name {
|
||||
font-weight: var(--font-weight-semibold);
|
||||
font-size: var(--font-sm);
|
||||
color: var(--text-primary);
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.team-modal__expert-persona {
|
||||
font-size: var(--font-xs);
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.team-modal__expert-framework {
|
||||
font-size: var(--font-xs);
|
||||
color: var(--text-tertiary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.team-modal__preview {
|
||||
padding: var(--space-2) var(--space-3);
|
||||
background: var(--bg-secondary, var(--bg-primary));
|
||||
border: 1px dashed var(--border-color);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.team-modal__preview-label {
|
||||
font-size: var(--font-xs);
|
||||
color: var(--text-tertiary);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.team-modal__preview-code {
|
||||
font-family: 'SF Mono', Monaco, Consolas, monospace;
|
||||
font-size: var(--font-xs);
|
||||
color: var(--color-primary);
|
||||
word-break: break-all;
|
||||
display: block;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -24,6 +24,14 @@
|
|||
<div v-if="!expanded && toolCall.result" class="tool-call-card__preview">
|
||||
{{ previewText }}
|
||||
</div>
|
||||
<div v-if="toolCall.children && toolCall.children.length > 0 && expanded && props.depth < MAX_DEPTH" class="tool-call-card__children">
|
||||
<ToolCallCard
|
||||
v-for="child in toolCall.children"
|
||||
:key="child.id"
|
||||
:tool-call="child"
|
||||
:depth="props.depth + 1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
|
@ -45,9 +53,15 @@ import {
|
|||
} from '@ant-design/icons-vue'
|
||||
import type { IToolCallData } from '@/api/types'
|
||||
|
||||
const props = defineProps<{
|
||||
toolCall: IToolCallData
|
||||
}>()
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
toolCall: IToolCallData
|
||||
depth?: number
|
||||
}>(),
|
||||
{ depth: 0 }
|
||||
)
|
||||
|
||||
const MAX_DEPTH = 5
|
||||
|
||||
const expanded = ref(false)
|
||||
|
||||
|
|
@ -203,4 +217,13 @@ const previewText = computed(() => {
|
|||
max-height: 40px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.tool-call-card__children {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
padding-left: var(--space-4);
|
||||
border-top: 1px solid var(--border-color-split);
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,198 @@
|
|||
import { computed, type Component } from 'vue'
|
||||
import type { IChatMessage } from '@/api/types'
|
||||
import UserBubble from '@/components/chat/messages/UserBubble.vue'
|
||||
import AssistantText from '@/components/chat/messages/AssistantText.vue'
|
||||
import TeamPlanCard from '@/components/chat/messages/TeamPlanCard.vue'
|
||||
import BoardBannerCard from '@/components/chat/messages/BoardBannerCard.vue'
|
||||
import BoardRoundCard from '@/components/chat/messages/BoardRoundCard.vue'
|
||||
import BoardConclusionCard from '@/components/chat/messages/BoardConclusionCard.vue'
|
||||
import ErrorCard from '@/components/chat/messages/ErrorCard.vue'
|
||||
|
||||
export type MessageViewType =
|
||||
| 'user'
|
||||
| 'assistant'
|
||||
| 'team_plan'
|
||||
| 'board_banner'
|
||||
| 'board_speech'
|
||||
| 'board_summary'
|
||||
| 'board_conclusion'
|
||||
| 'milestone'
|
||||
| 'error'
|
||||
|
||||
export interface MessageShellMeta {
|
||||
name: string
|
||||
meta?: string
|
||||
avatar?: string
|
||||
color?: string
|
||||
}
|
||||
|
||||
export interface MessageRenderSpec {
|
||||
type: MessageViewType
|
||||
shell: MessageShellMeta
|
||||
component: Component
|
||||
props: Record<string, unknown>
|
||||
}
|
||||
|
||||
export function resolveMessageType(message: IChatMessage): MessageViewType {
|
||||
if (message.role === 'user') return 'user'
|
||||
if (message.status === 'error' || message.message_type === 'error') return 'error'
|
||||
|
||||
switch (message.message_type) {
|
||||
case 'plan_update':
|
||||
return 'team_plan'
|
||||
case 'board_started':
|
||||
return 'board_banner'
|
||||
case 'board_speech':
|
||||
return 'board_speech'
|
||||
case 'board_summary':
|
||||
return 'board_summary'
|
||||
case 'board_conclusion':
|
||||
return 'board_conclusion'
|
||||
case 'milestone':
|
||||
return 'milestone'
|
||||
default:
|
||||
return 'assistant'
|
||||
}
|
||||
}
|
||||
|
||||
function formatTime(timestamp: string): string {
|
||||
return new Date(timestamp).toLocaleTimeString('zh-CN', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})
|
||||
}
|
||||
|
||||
export function useMessageRenderer(message: IChatMessage) {
|
||||
return computed<MessageRenderSpec>(() => {
|
||||
const type = resolveMessageType(message)
|
||||
const time = formatTime(message.timestamp)
|
||||
|
||||
switch (type) {
|
||||
case 'user':
|
||||
return {
|
||||
type,
|
||||
shell: { name: '用户', meta: time },
|
||||
component: UserBubble,
|
||||
props: { content: message.content || '' },
|
||||
}
|
||||
|
||||
case 'team_plan': {
|
||||
const phases = message.plan_phases ?? []
|
||||
return {
|
||||
type,
|
||||
shell: {
|
||||
name: message.expert_name || '专家团',
|
||||
meta: time,
|
||||
avatar: message.expert_avatar,
|
||||
color: message.expert_color,
|
||||
},
|
||||
component: TeamPlanCard,
|
||||
props: {
|
||||
phases,
|
||||
leadName: message.expert_name || 'Lead',
|
||||
leadAvatar: message.expert_avatar,
|
||||
leadColor: message.expert_color,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
case 'board_banner': {
|
||||
const data = message.board_started
|
||||
const experts = data?.experts ?? []
|
||||
return {
|
||||
type,
|
||||
shell: { name: '私董会', meta: time },
|
||||
component: BoardBannerCard,
|
||||
props: {
|
||||
topic: data?.topic || message.content || '未命名主题',
|
||||
experts,
|
||||
maxRounds: data?.max_rounds ?? 5,
|
||||
currentRound: message.board_round ?? 1,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
case 'board_speech':
|
||||
return {
|
||||
type,
|
||||
shell: {
|
||||
name: message.expert_name || '专家',
|
||||
meta: message.board_round ? `第 ${message.board_round} 轮${message.board_role === 'moderator' ? ' · 主持' : ''}` : time,
|
||||
avatar: message.expert_avatar,
|
||||
color: message.expert_color,
|
||||
},
|
||||
component: BoardRoundCard,
|
||||
props: {
|
||||
name: message.expert_name || '专家',
|
||||
avatar: message.expert_avatar || '',
|
||||
color: message.expert_color,
|
||||
round: message.board_round,
|
||||
role: message.board_role === 'moderator' ? 'moderator' : 'expert',
|
||||
content: message.content || '',
|
||||
},
|
||||
}
|
||||
|
||||
case 'board_summary':
|
||||
return {
|
||||
type,
|
||||
shell: {
|
||||
name: message.expert_name || '主持人',
|
||||
meta: message.board_round ? `第 ${message.board_round} 轮 · 小结` : time,
|
||||
avatar: message.expert_avatar,
|
||||
color: message.expert_color,
|
||||
},
|
||||
component: BoardRoundCard,
|
||||
props: {
|
||||
name: message.expert_name || '主持人',
|
||||
avatar: message.expert_avatar || '',
|
||||
color: message.expert_color,
|
||||
round: message.board_round,
|
||||
role: 'summary',
|
||||
content: message.content || '',
|
||||
},
|
||||
}
|
||||
|
||||
case 'board_conclusion':
|
||||
return {
|
||||
type,
|
||||
shell: { name: '主持人', meta: time },
|
||||
component: BoardConclusionCard,
|
||||
props: {
|
||||
data: message.board_conclusion ?? {
|
||||
summary: message.content || '',
|
||||
decision_advice: '',
|
||||
total_rounds: message.board_round ?? 0,
|
||||
consensus_points: [],
|
||||
dissent_points: [],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
case 'error':
|
||||
return {
|
||||
type,
|
||||
shell: { name: '系统', meta: time },
|
||||
component: ErrorCard,
|
||||
props: {
|
||||
title: '请求失败',
|
||||
detail: message.error_detail || message.content || '',
|
||||
},
|
||||
}
|
||||
|
||||
case 'milestone':
|
||||
case 'assistant':
|
||||
default:
|
||||
return {
|
||||
type,
|
||||
shell: {
|
||||
name: message.expert_name || 'AI Agent',
|
||||
meta: time,
|
||||
avatar: message.expert_avatar,
|
||||
color: message.expert_color,
|
||||
},
|
||||
component: AssistantText,
|
||||
props: { message },
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
@ -0,0 +1,335 @@
|
|||
<template>
|
||||
<div class="assistant-text">
|
||||
<ThinkingBlock
|
||||
v-if="message.thinking"
|
||||
:content="message.thinking"
|
||||
:is-streaming="isLoading && !message.content"
|
||||
/>
|
||||
|
||||
<div v-if="toolCalls.length > 0" class="assistant-text__tools">
|
||||
<ToolCallCard
|
||||
v-for="tc in toolCalls"
|
||||
:key="tc.id"
|
||||
:tool-call="tc"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="message.content" ref="markdownRef" class="assistant-text__markdown" v-html="renderedContent"></div>
|
||||
|
||||
<div v-else-if="isLoading" class="assistant-text__loading">
|
||||
<a-spin size="small" />
|
||||
</div>
|
||||
|
||||
<div v-if="showRouting" class="assistant-text__routing">
|
||||
<a-tag color="purple">
|
||||
<ThunderboltOutlined /> {{ message.matched_skill }}
|
||||
</a-tag>
|
||||
<a-tag v-if="message.confidence !== undefined" color="green">
|
||||
置信度: {{ (message.confidence * 100).toFixed(1) }}%
|
||||
</a-tag>
|
||||
<a-tag v-if="message.routing_method" color="default">
|
||||
{{ message.routing_method }}
|
||||
</a-tag>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch, nextTick } from 'vue'
|
||||
import MarkdownIt from 'markdown-it'
|
||||
import DOMPurify from 'dompurify'
|
||||
import { Tag as ATag, Spin as ASpin, message as antMessage } from 'ant-design-vue'
|
||||
import { ThunderboltOutlined } from '@ant-design/icons-vue'
|
||||
import hljs from 'highlight.js/lib/core'
|
||||
import python from 'highlight.js/lib/languages/python'
|
||||
import javascript from 'highlight.js/lib/languages/javascript'
|
||||
import typescript from 'highlight.js/lib/languages/typescript'
|
||||
import json from 'highlight.js/lib/languages/json'
|
||||
import bash from 'highlight.js/lib/languages/bash'
|
||||
import yaml from 'highlight.js/lib/languages/yaml'
|
||||
import sql from 'highlight.js/lib/languages/sql'
|
||||
import xml from 'highlight.js/lib/languages/xml'
|
||||
import css from 'highlight.js/lib/languages/css'
|
||||
import markdown from 'highlight.js/lib/languages/markdown'
|
||||
import type { IChatMessage } from '@/api/types'
|
||||
import ToolCallCard from '@/components/chat/ToolCallCard.vue'
|
||||
import ThinkingBlock from '@/components/chat/ThinkingBlock.vue'
|
||||
|
||||
hljs.registerLanguage('python', python)
|
||||
hljs.registerLanguage('javascript', javascript)
|
||||
hljs.registerLanguage('typescript', typescript)
|
||||
hljs.registerLanguage('json', json)
|
||||
hljs.registerLanguage('bash', bash)
|
||||
hljs.registerLanguage('yaml', yaml)
|
||||
hljs.registerLanguage('sql', sql)
|
||||
hljs.registerLanguage('xml', xml)
|
||||
hljs.registerLanguage('html', xml)
|
||||
hljs.registerLanguage('css', css)
|
||||
hljs.registerLanguage('markdown', markdown)
|
||||
|
||||
interface Props {
|
||||
message: IChatMessage
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const markdownRef = ref<HTMLElement | null>(null)
|
||||
|
||||
const md = new MarkdownIt({
|
||||
html: false,
|
||||
linkify: true,
|
||||
breaks: true,
|
||||
highlight(str: string, lang: string): string {
|
||||
let highlighted: string
|
||||
if (lang && hljs.getLanguage(lang)) {
|
||||
highlighted = hljs.highlight(str, { language: lang, ignoreIllegals: true }).value
|
||||
} else {
|
||||
highlighted = MarkdownIt.prototype.utils.escapeHtml(str)
|
||||
}
|
||||
return `<pre class="hljs" data-language="${lang || ''}"><code>${highlighted}</code></pre>`
|
||||
},
|
||||
})
|
||||
|
||||
md.renderer.rules.image = (tokens, idx) => {
|
||||
const token = tokens[idx]
|
||||
const rawSrc = token.attrGet('src') || ''
|
||||
const rawAlt = token.content || ''
|
||||
if (!/^https?:\/\//i.test(rawSrc)) return ''
|
||||
const src = rawSrc.replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<')
|
||||
const alt = rawAlt.replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<')
|
||||
return `<img src="${src}" alt="${alt}" class="assistant-text__inline-image" loading="lazy" />`
|
||||
}
|
||||
|
||||
function sanitize(html: string): string {
|
||||
return DOMPurify.sanitize(html, {
|
||||
ALLOWED_TAGS: [
|
||||
'p', 'br', 'strong', 'em', 'del', 'code', 'pre', 'a',
|
||||
'ul', 'ol', 'li', 'h1', 'h2', 'h3', 'h4', 'blockquote',
|
||||
'table', 'thead', 'tbody', 'tr', 'th', 'td', 'span',
|
||||
'div', 'img', 'sup', 'sub',
|
||||
],
|
||||
ALLOWED_ATTR: ['href', 'target', 'rel', 'class', 'data-language', 'src', 'alt', 'loading'],
|
||||
ALLOW_DATA_ATTR: false,
|
||||
ALLOWED_URI_REGEXP: /^(?:(?:https?):\/\/[^\s]*)$/i,
|
||||
})
|
||||
}
|
||||
|
||||
const isLoading = computed(() => {
|
||||
return props.message.role === 'assistant' && props.message.status === 'pending' && !props.message.content
|
||||
})
|
||||
|
||||
const showRouting = computed(() => {
|
||||
return props.message.role === 'assistant' && props.message.matched_skill
|
||||
})
|
||||
|
||||
const renderedContent = computed(() => {
|
||||
if (!props.message.content) return ''
|
||||
return sanitize(md.render(props.message.content))
|
||||
})
|
||||
|
||||
const toolCalls = computed(() => props.message.tool_calls ?? [])
|
||||
|
||||
watch(renderedContent, () => {
|
||||
nextTick(() => {
|
||||
if (!markdownRef.value) return
|
||||
const pres = markdownRef.value.querySelectorAll('pre.hljs')
|
||||
pres.forEach((pre) => {
|
||||
if (pre.querySelector('.assistant-text__code-header')) return
|
||||
const lang = pre.getAttribute('data-language') || ''
|
||||
const code = pre.querySelector('code')
|
||||
if (!code) return
|
||||
|
||||
const header = document.createElement('div')
|
||||
header.className = 'assistant-text__code-header'
|
||||
if (lang) {
|
||||
const langLabel = document.createElement('span')
|
||||
langLabel.className = 'assistant-text__code-lang'
|
||||
langLabel.textContent = lang
|
||||
header.appendChild(langLabel)
|
||||
}
|
||||
const copyBtn = document.createElement('button')
|
||||
copyBtn.className = 'assistant-text__copy-btn'
|
||||
copyBtn.innerHTML = '<span class="anticon"><svg viewBox="0 0 1024 1024" width="14" height="14" fill="currentColor"><path d="M832 64H296c-4.4 0-8 3.6-8 8v56c0 4.4 3.6 8 8 8h496v608c0 4.4 3.6 8 8 8h56c4.4 0 8-3.6 8-8V112c0-26.5-21.5-48-48-48zM704 256H192c-26.5 0-48 21.5-48 48v608c0 26.5 21.5 48 48 48h512c26.5 0 48-21.5 48-48V304c0-26.5-21.5-48-48-48zm-8 608H200V312h496v552z"/></svg></span>'
|
||||
copyBtn.title = '复制代码'
|
||||
copyBtn.addEventListener('click', () => {
|
||||
navigator.clipboard.writeText(code.textContent || '').then(() => {
|
||||
antMessage.success('已复制到剪贴板')
|
||||
}).catch(() => {
|
||||
antMessage.error('复制失败')
|
||||
})
|
||||
})
|
||||
header.appendChild(copyBtn)
|
||||
pre.insertBefore(header, pre.firstChild)
|
||||
})
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.assistant-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
line-height: var(--leading-message);
|
||||
font-size: var(--font-base);
|
||||
color: var(--text-primary);
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.assistant-text__markdown {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.assistant-text__markdown :deep(p) {
|
||||
margin-bottom: var(--space-3);
|
||||
}
|
||||
|
||||
.assistant-text__markdown :deep(p:last-child) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.assistant-text__markdown :deep(pre) {
|
||||
background: var(--code-bg);
|
||||
color: var(--code-fg);
|
||||
padding: 0;
|
||||
border-radius: var(--radius-md);
|
||||
overflow: hidden;
|
||||
margin: var(--space-3) 0;
|
||||
font-size: var(--font-sm);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.assistant-text__markdown :deep(pre.hljs) {
|
||||
background: var(--code-bg);
|
||||
}
|
||||
|
||||
.assistant-text__markdown :deep(pre code) {
|
||||
display: block;
|
||||
padding: var(--space-3);
|
||||
overflow-x: auto;
|
||||
font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', Menlo, Consolas, monospace;
|
||||
}
|
||||
|
||||
.assistant-text__markdown :deep(.assistant-text__code-header) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: var(--space-1) var(--space-3);
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.assistant-text__markdown :deep(.assistant-text__code-lang) {
|
||||
font-size: var(--font-xs);
|
||||
color: var(--code-comment);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.assistant-text__markdown :deep(.assistant-text__copy-btn) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--code-comment);
|
||||
cursor: pointer;
|
||||
border-radius: var(--radius-sm);
|
||||
transition: all var(--transition-fast);
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.assistant-text__markdown :deep(.assistant-text__copy-btn:hover) {
|
||||
color: var(--code-fg);
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.assistant-text__markdown :deep(.hljs-keyword) { color: var(--code-keyword); }
|
||||
.assistant-text__markdown :deep(.hljs-string) { color: var(--code-string); }
|
||||
.assistant-text__markdown :deep(.hljs-number) { color: var(--code-number); }
|
||||
.assistant-text__markdown :deep(.hljs-comment) { color: var(--code-comment); font-style: italic; }
|
||||
.assistant-text__markdown :deep(.hljs-function) { color: var(--code-function); }
|
||||
.assistant-text__markdown :deep(.hljs-variable) { color: var(--code-variable); }
|
||||
.assistant-text__markdown :deep(.hljs-type) { color: var(--code-type); }
|
||||
.assistant-text__markdown :deep(.hljs-built_in) { color: var(--code-type); }
|
||||
.assistant-text__markdown :deep(.hljs-attr) { color: var(--code-function); }
|
||||
.assistant-text__markdown :deep(.hljs-selector-tag) { color: var(--code-keyword); }
|
||||
.assistant-text__markdown :deep(.hljs-selector-class) { color: var(--code-type); }
|
||||
.assistant-text__markdown :deep(.hljs-selector-id) { color: var(--code-type); }
|
||||
.assistant-text__markdown :deep(.hljs-literal) { color: var(--code-number); }
|
||||
.assistant-text__markdown :deep(.hljs-meta) { color: var(--code-comment); }
|
||||
.assistant-text__markdown :deep(.hljs-title) { color: var(--code-function); }
|
||||
.assistant-text__markdown :deep(.hljs-params) { color: var(--code-fg); }
|
||||
.assistant-text__markdown :deep(.hljs-section) { color: var(--code-function); }
|
||||
.assistant-text__markdown :deep(.hljs-addition) { background: var(--code-added-bg); }
|
||||
.assistant-text__markdown :deep(.hljs-deletion) { background: var(--code-removed-bg); }
|
||||
|
||||
.assistant-text__markdown :deep(.assistant-text__inline-image) {
|
||||
max-width: 100%;
|
||||
max-height: 200px;
|
||||
object-fit: contain;
|
||||
border-radius: var(--radius-md);
|
||||
cursor: pointer;
|
||||
margin: var(--space-2) 0;
|
||||
transition: opacity var(--transition-fast);
|
||||
}
|
||||
|
||||
.assistant-text__markdown :deep(.assistant-text__inline-image:hover) {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.assistant-text__markdown :deep(code) {
|
||||
font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', Menlo, Consolas, monospace;
|
||||
font-size: var(--font-sm);
|
||||
}
|
||||
|
||||
.assistant-text__markdown :deep(:not(pre) > code) {
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-primary);
|
||||
padding: 2px var(--space-1);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: var(--font-xs);
|
||||
}
|
||||
|
||||
.assistant-text__markdown :deep(ul),
|
||||
.assistant-text__markdown :deep(ol) {
|
||||
padding-left: var(--space-5);
|
||||
margin-bottom: var(--space-3);
|
||||
}
|
||||
|
||||
.assistant-text__markdown :deep(h1),
|
||||
.assistant-text__markdown :deep(h2),
|
||||
.assistant-text__markdown :deep(h3) {
|
||||
margin-top: var(--space-4);
|
||||
margin-bottom: var(--space-2);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
}
|
||||
|
||||
.assistant-text__tools {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.assistant-text__loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.assistant-text__routing {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.assistant-text__routing :deep(.ant-tag) {
|
||||
border: none;
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-tertiary);
|
||||
font-size: var(--font-xs);
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,137 @@
|
|||
<template>
|
||||
<div class="board-banner-card">
|
||||
<div class="board-banner-card__bar" />
|
||||
<div class="board-banner-card__body">
|
||||
<div class="board-banner-card__title">
|
||||
<span class="board-banner-card__icon">🏛️</span>
|
||||
<span>私董会 — {{ topic }}</span>
|
||||
</div>
|
||||
<div class="board-banner-card__experts">
|
||||
<span
|
||||
v-for="(expert, idx) in experts"
|
||||
:key="`${expert.name}-${idx}`"
|
||||
class="board-banner-card__chip"
|
||||
:class="{ 'board-banner-card__chip--moderator': expert.is_moderator }"
|
||||
>
|
||||
<span class="board-banner-card__chip-avatar">{{ expert.avatar }}</span>
|
||||
<span>{{ expert.name }}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="board-banner-card__meta">
|
||||
<span>轮次:第 {{ currentRound }} / {{ maxRounds }} 轮</span>
|
||||
<div class="board-banner-card__progress">
|
||||
<div
|
||||
class="board-banner-card__progress-fill"
|
||||
:style="{ width: progressPercent + '%' }"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import type { IBoardExpert } from '@/api/types'
|
||||
|
||||
interface Props {
|
||||
topic: string
|
||||
experts: IBoardExpert[]
|
||||
maxRounds: number
|
||||
currentRound?: number
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
currentRound: 1,
|
||||
})
|
||||
|
||||
const progressPercent = computed(() => {
|
||||
if (props.maxRounds <= 0) return 0
|
||||
return Math.min((props.currentRound / props.maxRounds) * 100, 100)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.board-banner-card {
|
||||
width: 100%;
|
||||
background: var(--bg-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-card);
|
||||
box-shadow: var(--shadow-card);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.board-banner-card__bar {
|
||||
height: 4px;
|
||||
background: var(--accent-board);
|
||||
}
|
||||
|
||||
.board-banner-card__body {
|
||||
padding: var(--space-3) var(--space-4);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.board-banner-card__title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
font-size: var(--font-md);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.board-banner-card__icon {
|
||||
font-size: var(--font-lg);
|
||||
}
|
||||
|
||||
.board-banner-card__experts {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.board-banner-card__chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 2px 8px;
|
||||
background: var(--accent-board-soft);
|
||||
color: var(--accent-board);
|
||||
border-radius: var(--radius-full);
|
||||
font-size: var(--font-xs);
|
||||
}
|
||||
|
||||
.board-banner-card__chip--moderator {
|
||||
background: var(--accent-board);
|
||||
color: var(--text-inverse);
|
||||
}
|
||||
|
||||
.board-banner-card__chip-avatar {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.board-banner-card__meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
font-size: var(--font-xs);
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.board-banner-card__progress {
|
||||
flex: 1;
|
||||
height: 4px;
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.board-banner-card__progress-fill {
|
||||
height: 100%;
|
||||
background: var(--accent-board);
|
||||
border-radius: 2px;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,135 @@
|
|||
<template>
|
||||
<div class="board-conclusion-card">
|
||||
<div class="board-conclusion-card__bar" />
|
||||
<div class="board-conclusion-card__body">
|
||||
<div class="board-conclusion-card__title">私董会结论</div>
|
||||
|
||||
<div v-if="data.summary" class="board-conclusion-card__section">
|
||||
<div class="board-conclusion-card__section-title">总结</div>
|
||||
<div class="board-conclusion-card__text">{{ data.summary }}</div>
|
||||
</div>
|
||||
|
||||
<div v-if="data.consensus_points && data.consensus_points.length > 0" class="board-conclusion-card__section">
|
||||
<div class="board-conclusion-card__section-title">共识</div>
|
||||
<ul class="board-conclusion-card__list">
|
||||
<li v-for="(point, idx) in data.consensus_points" :key="`c-${idx}`">
|
||||
{{ point }}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="data.dissent_points && data.dissent_points.length > 0"
|
||||
class="board-conclusion-card__section board-conclusion-card__section--collapsible"
|
||||
>
|
||||
<div class="board-conclusion-card__section-header" @click="showDissent = !showDissent">
|
||||
<span class="board-conclusion-card__section-title">分歧</span>
|
||||
<DownOutlined :class="{ 'board-conclusion-card__rotate': !showDissent }" />
|
||||
</div>
|
||||
<ul v-show="showDissent" class="board-conclusion-card__list">
|
||||
<li v-for="(point, idx) in data.dissent_points" :key="`d-${idx}`">
|
||||
{{ point }}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="data.decision_advice"
|
||||
class="board-conclusion-card__section board-conclusion-card__section--collapsible"
|
||||
>
|
||||
<div class="board-conclusion-card__section-header" @click="showAdvice = !showAdvice">
|
||||
<span class="board-conclusion-card__section-title">决策建议</span>
|
||||
<DownOutlined :class="{ 'board-conclusion-card__rotate': !showAdvice }" />
|
||||
</div>
|
||||
<div v-show="showAdvice" class="board-conclusion-card__text">{{ data.decision_advice }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { DownOutlined } from '@ant-design/icons-vue'
|
||||
import type { IBoardConcludedData } from '@/api/types'
|
||||
|
||||
interface Props {
|
||||
data: IBoardConcludedData
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
|
||||
const showDissent = ref(false)
|
||||
const showAdvice = ref(false)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.board-conclusion-card {
|
||||
width: 100%;
|
||||
background: var(--bg-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-card);
|
||||
box-shadow: var(--shadow-card);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.board-conclusion-card__bar {
|
||||
height: 4px;
|
||||
background: var(--gradient-board);
|
||||
}
|
||||
|
||||
.board-conclusion-card__body {
|
||||
padding: var(--space-3) var(--space-4);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.board-conclusion-card__title {
|
||||
font-size: var(--font-md);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.board-conclusion-card__section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.board-conclusion-card__section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.board-conclusion-card__section-title {
|
||||
font-size: var(--font-sm);
|
||||
font-weight: var(--font-weight-medium);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.board-conclusion-card__text {
|
||||
font-size: var(--font-sm);
|
||||
color: var(--text-primary);
|
||||
line-height: var(--leading-message);
|
||||
}
|
||||
|
||||
.board-conclusion-card__list {
|
||||
margin: 0;
|
||||
padding-left: var(--space-4);
|
||||
font-size: var(--font-sm);
|
||||
color: var(--text-primary);
|
||||
line-height: var(--leading-message);
|
||||
}
|
||||
|
||||
.board-conclusion-card__list li {
|
||||
margin-bottom: var(--space-1);
|
||||
}
|
||||
|
||||
.board-conclusion-card__rotate {
|
||||
transform: rotate(-90deg);
|
||||
transition: transform var(--transition-fast);
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,126 @@
|
|||
<template>
|
||||
<div class="board-round-card" :class="[`board-round-card--${role}`]">
|
||||
<div class="board-round-card__header">
|
||||
<span
|
||||
class="board-round-card__avatar"
|
||||
:style="avatarStyle"
|
||||
>
|
||||
{{ avatar || name.charAt(0) }}
|
||||
</span>
|
||||
<span class="board-round-card__name">{{ name }}</span>
|
||||
<span v-if="round" class="board-round-card__round">第 {{ round }} 轮</span>
|
||||
<span v-if="roleTag" class="board-round-card__role">{{ roleTag }}</span>
|
||||
</div>
|
||||
<div class="board-round-card__content">
|
||||
<AssistantText :message="textMessage" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import type { IChatMessage } from '@/api/types'
|
||||
import AssistantText from './AssistantText.vue'
|
||||
|
||||
interface Props {
|
||||
name: string
|
||||
avatar?: string
|
||||
color?: string
|
||||
round?: number
|
||||
role?: 'moderator' | 'expert' | 'summary'
|
||||
content: string
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
avatar: '',
|
||||
role: 'expert',
|
||||
})
|
||||
|
||||
const roleTag = computed(() => {
|
||||
const tags: Record<string, string> = {
|
||||
moderator: '主持',
|
||||
expert: '专家',
|
||||
summary: '小结',
|
||||
}
|
||||
return tags[props.role] || ''
|
||||
})
|
||||
|
||||
const avatarStyle = computed(() => {
|
||||
if (props.color) {
|
||||
return { background: props.color }
|
||||
}
|
||||
return { background: 'var(--accent-board)' }
|
||||
})
|
||||
|
||||
const textMessage = computed<IChatMessage>(() => ({
|
||||
id: `board-${props.name}-${props.round || 0}`,
|
||||
role: 'assistant',
|
||||
content: props.content,
|
||||
timestamp: new Date().toISOString(),
|
||||
status: 'completed',
|
||||
}))
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.board-round-card {
|
||||
width: 100%;
|
||||
background: var(--bg-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-left: 3px solid var(--accent-board);
|
||||
border-radius: var(--radius-card);
|
||||
box-shadow: var(--shadow-card);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.board-round-card--summary {
|
||||
background: var(--accent-board-soft);
|
||||
border-left-color: var(--accent-board);
|
||||
}
|
||||
|
||||
.board-round-card__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.board-round-card__avatar {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: var(--radius-full);
|
||||
font-size: 12px;
|
||||
color: var(--text-inverse);
|
||||
}
|
||||
|
||||
.board-round-card__name {
|
||||
font-size: var(--font-sm);
|
||||
font-weight: var(--font-weight-medium);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.board-round-card__round {
|
||||
font-size: var(--font-xs);
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.board-round-card__role {
|
||||
margin-left: auto;
|
||||
font-size: var(--font-xs);
|
||||
padding: 1px 6px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--accent-board-soft);
|
||||
color: var(--accent-board);
|
||||
}
|
||||
|
||||
.board-round-card__content {
|
||||
padding: var(--space-2) var(--space-3);
|
||||
}
|
||||
|
||||
.board-round-card__content :deep(p) {
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,107 @@
|
|||
<template>
|
||||
<div class="error-card">
|
||||
<CloseCircleOutlined class="error-card__icon" />
|
||||
<div class="error-card__body">
|
||||
<div class="error-card__title">{{ title }}</div>
|
||||
<div v-if="detail" class="error-card__detail">{{ detail }}</div>
|
||||
</div>
|
||||
<a-button
|
||||
v-if="!hideRetry"
|
||||
class="error-card__retry"
|
||||
type="primary"
|
||||
size="small"
|
||||
:loading="isRetrying"
|
||||
@click="handleRetry"
|
||||
>
|
||||
<template #icon><RedoOutlined /></template>
|
||||
重试
|
||||
</a-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onUnmounted, ref } from 'vue'
|
||||
import { CloseCircleOutlined, RedoOutlined } from '@ant-design/icons-vue'
|
||||
|
||||
interface Props {
|
||||
title: string
|
||||
detail?: string
|
||||
hideRetry?: boolean
|
||||
loading?: boolean
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
detail: '',
|
||||
hideRetry: false,
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
retry: []
|
||||
}>()
|
||||
|
||||
const internalRetrying = ref(false)
|
||||
const isRetrying = computed(() => props.loading ?? internalRetrying.value)
|
||||
let retryTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
function handleRetry(): void {
|
||||
if (isRetrying.value) return
|
||||
if (props.loading === undefined) {
|
||||
internalRetrying.value = true
|
||||
retryTimer = setTimeout(() => {
|
||||
internalRetrying.value = false
|
||||
retryTimer = null
|
||||
}, 2000)
|
||||
}
|
||||
emit('retry')
|
||||
}
|
||||
|
||||
onUnmounted(() => {
|
||||
if (retryTimer) {
|
||||
clearTimeout(retryTimer)
|
||||
retryTimer = null
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.error-card {
|
||||
display: flex;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-3) var(--space-4);
|
||||
background: var(--color-error-light);
|
||||
border: 1px solid rgba(239, 68, 68, 0.2);
|
||||
border-radius: var(--radius-lg);
|
||||
color: var(--color-error);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.error-card__icon {
|
||||
font-size: var(--font-lg);
|
||||
flex-shrink: 0;
|
||||
margin-top: 1px;
|
||||
}
|
||||
|
||||
.error-card__body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.error-card__title {
|
||||
font-weight: var(--font-weight-medium);
|
||||
font-size: var(--font-sm);
|
||||
}
|
||||
|
||||
.error-card__detail {
|
||||
font-size: var(--font-xs);
|
||||
opacity: 0.85;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.error-card__retry {
|
||||
flex-shrink: 0;
|
||||
align-self: flex-start;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,138 @@
|
|||
<template>
|
||||
<a class="file-attachment" :href="resolvedUrl" target="_blank" rel="noopener noreferrer" download>
|
||||
<div class="file-attachment__icon">
|
||||
<FileOutlined />
|
||||
</div>
|
||||
<div class="file-attachment__info">
|
||||
<div class="file-attachment__name">{{ filename }}</div>
|
||||
<div class="file-attachment__meta">{{ formattedSize }} · {{ contentTypeLabel }}</div>
|
||||
</div>
|
||||
<div class="file-attachment__action">
|
||||
<DownloadOutlined />
|
||||
</div>
|
||||
</a>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { FileOutlined, DownloadOutlined } from '@ant-design/icons-vue'
|
||||
import { getDynamicBaseURL } from '@/api/base'
|
||||
|
||||
interface Props {
|
||||
filename: string
|
||||
url: string
|
||||
size?: number
|
||||
contentType?: string
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const resolvedUrl = computed(() => {
|
||||
const base = getDynamicBaseURL()
|
||||
if (!base || props.url.startsWith('http')) return props.url
|
||||
return `${base}${props.url}`
|
||||
})
|
||||
|
||||
const formattedSize = computed(() => {
|
||||
if (props.size === undefined || props.size === null) return '未知大小'
|
||||
const units = ['B', 'KB', 'MB', 'GB']
|
||||
let value = props.size
|
||||
let unitIdx = 0
|
||||
while (value >= 1024 && unitIdx < units.length - 1) {
|
||||
value /= 1024
|
||||
unitIdx++
|
||||
}
|
||||
return `${value.toFixed(unitIdx === 0 ? 0 : 1)} ${units[unitIdx]}`
|
||||
})
|
||||
|
||||
const contentTypeLabel = computed(() => {
|
||||
if (!props.contentType) return '文件'
|
||||
const map: Record<string, string> = {
|
||||
'image/png': 'PNG 图片',
|
||||
'image/jpeg': 'JPEG 图片',
|
||||
'image/gif': 'GIF 图片',
|
||||
'image/webp': 'WebP 图片',
|
||||
'text/plain': '纯文本',
|
||||
'text/markdown': 'Markdown',
|
||||
'application/pdf': 'PDF',
|
||||
'application/json': 'JSON',
|
||||
'application/zip': 'ZIP',
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': 'Word',
|
||||
'application/msword': 'Word',
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': 'Excel',
|
||||
'application/vnd.ms-excel': 'Excel',
|
||||
}
|
||||
return map[props.contentType] || props.contentType.split('/').pop() || '文件'
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.file-attachment {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
max-width: 100%;
|
||||
padding: var(--space-2) var(--space-3);
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--text-primary);
|
||||
text-decoration: none;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.file-attachment:hover {
|
||||
background: var(--bg-tertiary);
|
||||
border-color: var(--border-hover);
|
||||
}
|
||||
|
||||
.file-attachment__icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-tertiary);
|
||||
font-size: var(--font-lg);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.file-attachment__info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.file-attachment__name {
|
||||
font-size: var(--font-sm);
|
||||
font-weight: var(--font-weight-medium);
|
||||
color: var(--text-primary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.file-attachment__meta {
|
||||
font-size: var(--font-xs);
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.file-attachment__action {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-tertiary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.file-attachment:hover .file-attachment__action {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,148 @@
|
|||
<template>
|
||||
<div class="message-shell" :class="[`message-shell--${role}`]">
|
||||
<div class="message-shell__avatar">
|
||||
<slot name="avatar">
|
||||
<div
|
||||
v-if="avatar || color"
|
||||
class="message-shell__custom-avatar"
|
||||
:style="{ backgroundColor: color || '#1a1a1a' }"
|
||||
>
|
||||
{{ avatar || (name ? name.charAt(0).toUpperCase() : '?') }}
|
||||
</div>
|
||||
<a-avatar v-else-if="role === 'assistant'" :size="28" class="message-shell__avatar--assistant">
|
||||
<template #icon><RobotOutlined /></template>
|
||||
</a-avatar>
|
||||
<a-avatar v-else :size="28" class="message-shell__avatar--user">
|
||||
<template #icon><UserOutlined /></template>
|
||||
</a-avatar>
|
||||
</slot>
|
||||
</div>
|
||||
<div class="message-shell__body">
|
||||
<div class="message-shell__header">
|
||||
<span class="message-shell__name">{{ name }}</span>
|
||||
<span v-if="meta" class="message-shell__meta">{{ meta }}</span>
|
||||
</div>
|
||||
<div class="message-shell__content">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Avatar as AAvatar } from 'ant-design-vue'
|
||||
import { RobotOutlined, UserOutlined } from '@ant-design/icons-vue'
|
||||
|
||||
interface Props {
|
||||
role: 'user' | 'assistant'
|
||||
name?: string
|
||||
meta?: string
|
||||
avatar?: string
|
||||
color?: string
|
||||
}
|
||||
|
||||
withDefaults(defineProps<Props>(), {
|
||||
name: undefined,
|
||||
meta: undefined,
|
||||
avatar: undefined,
|
||||
color: undefined,
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.message-shell {
|
||||
display: flex;
|
||||
gap: var(--space-3);
|
||||
margin-bottom: var(--space-message-gap);
|
||||
animation: shellSlideIn 0.2s ease-out;
|
||||
}
|
||||
|
||||
@keyframes shellSlideIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(4px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.message-shell--user {
|
||||
flex-direction: row-reverse;
|
||||
}
|
||||
|
||||
.message-shell__avatar {
|
||||
flex-shrink: 0;
|
||||
padding-top: 2px;
|
||||
}
|
||||
|
||||
.message-shell__avatar--assistant {
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.message-shell__avatar--user {
|
||||
background: var(--text-primary);
|
||||
color: var(--text-inverse);
|
||||
}
|
||||
|
||||
.message-shell__custom-avatar {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: var(--radius-full);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-inverse);
|
||||
font-size: var(--font-xs);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.message-shell__body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.message-shell--user .message-shell__body {
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.message-shell--assistant .message-shell__body {
|
||||
align-items: flex-start;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.message-shell__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
margin-bottom: var(--space-1);
|
||||
font-size: var(--font-xs);
|
||||
}
|
||||
|
||||
.message-shell__name {
|
||||
color: var(--text-secondary);
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
|
||||
.message-shell__meta {
|
||||
color: var(--text-placeholder);
|
||||
}
|
||||
|
||||
.message-shell__content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.message-shell--user .message-shell__content {
|
||||
align-items: flex-end;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,271 @@
|
|||
<template>
|
||||
<div class="team-plan-card">
|
||||
<div class="team-plan-card__bar" />
|
||||
<div class="team-plan-card__header">
|
||||
<div class="team-plan-card__lead">
|
||||
<span
|
||||
class="team-plan-card__lead-avatar"
|
||||
:style="leadColor ? { background: leadColor } : undefined"
|
||||
>
|
||||
{{ leadAvatar }}
|
||||
</span>
|
||||
<span class="team-plan-card__label">
|
||||
{{ leadName }}<span class="team-plan-card__label-suffix">· 专家团计划</span>
|
||||
</span>
|
||||
</div>
|
||||
<span class="team-plan-card__count">{{ completedCount }}/{{ phases.length }} 阶段完成</span>
|
||||
</div>
|
||||
|
||||
<div v-if="phases.length > 0" class="team-plan-card__body">
|
||||
<div
|
||||
v-for="phase in phases"
|
||||
:key="phase.id"
|
||||
class="team-plan-card__phase"
|
||||
:class="`team-plan-card__phase--${phase.status}`"
|
||||
>
|
||||
<span class="team-plan-card__phase-dot" aria-hidden="true">
|
||||
{{ statusIcon(phase.status) }}
|
||||
</span>
|
||||
<div class="team-plan-card__phase-info">
|
||||
<span class="team-plan-card__phase-name" :title="phase.task_description || phase.name">
|
||||
{{ phase.name }}
|
||||
</span>
|
||||
<span class="team-plan-card__phase-expert">{{ phase.assigned_expert }}</span>
|
||||
</div>
|
||||
<span class="team-plan-card__phase-status">{{ statusLabel(phase.status) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="team-plan-card__footer">
|
||||
<div class="team-plan-card__progress">
|
||||
<div
|
||||
class="team-plan-card__progress-fill"
|
||||
:style="{ width: `${progressPercent}%` }"
|
||||
/>
|
||||
</div>
|
||||
<span class="team-plan-card__progress-text">
|
||||
当前: {{ currentPhaseName || '—' }} / {{ phases.length }} 阶段
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import type { ITeamPlanPhase } from '@/api/types'
|
||||
|
||||
interface Props {
|
||||
phases: ITeamPlanPhase[]
|
||||
leadName?: string
|
||||
leadAvatar?: string
|
||||
leadColor?: string
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
leadName: 'Lead',
|
||||
leadAvatar: '🧑💼',
|
||||
})
|
||||
|
||||
const completedCount = computed(() => props.phases.filter((p) => p.status === 'completed').length)
|
||||
|
||||
const progressPercent = computed(() => {
|
||||
if (props.phases.length === 0) return 0
|
||||
return Math.min((completedCount.value / props.phases.length) * 100, 100)
|
||||
})
|
||||
|
||||
const currentPhaseName = computed(() => {
|
||||
const running = props.phases.find((p) => p.status === 'in_progress')
|
||||
if (running) return running.name
|
||||
const pending = props.phases.find((p) => p.status === 'pending')
|
||||
if (pending) return pending.name
|
||||
return props.phases[props.phases.length - 1]?.name
|
||||
})
|
||||
|
||||
function statusLabel(status: string): string {
|
||||
const labels: Record<string, string> = {
|
||||
pending: '待执行',
|
||||
in_progress: '执行中',
|
||||
completed: '已完成',
|
||||
failed: '失败',
|
||||
}
|
||||
return labels[status] || status
|
||||
}
|
||||
|
||||
function statusIcon(status: string): string {
|
||||
const icons: Record<string, string> = {
|
||||
pending: '○',
|
||||
in_progress: '●',
|
||||
completed: '✓',
|
||||
failed: '✕',
|
||||
}
|
||||
return icons[status] || '○'
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.team-plan-card {
|
||||
width: 100%;
|
||||
background: var(--bg-primary);
|
||||
border: 1px solid var(--accent-team-soft);
|
||||
border-radius: var(--radius-card);
|
||||
box-shadow: var(--shadow-card);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.team-plan-card__bar {
|
||||
height: 4px;
|
||||
background: var(--accent-team);
|
||||
}
|
||||
|
||||
.team-plan-card__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-3) var(--space-4);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.team-plan-card__lead {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.team-plan-card__lead-avatar {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: var(--radius-full);
|
||||
font-size: 12px;
|
||||
background: var(--accent-team-soft);
|
||||
}
|
||||
|
||||
.team-plan-card__label {
|
||||
font-size: var(--font-sm);
|
||||
font-weight: var(--font-weight-medium);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.team-plan-card__label-suffix {
|
||||
margin-left: var(--space-1);
|
||||
color: var(--text-tertiary);
|
||||
font-weight: var(--font-weight-normal);
|
||||
}
|
||||
|
||||
.team-plan-card__count {
|
||||
font-size: var(--font-xs);
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.team-plan-card__body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-3) var(--space-4);
|
||||
}
|
||||
|
||||
.team-plan-card__phase {
|
||||
display: grid;
|
||||
grid-template-columns: 20px 1fr 60px;
|
||||
gap: var(--space-2);
|
||||
align-items: center;
|
||||
font-size: var(--font-sm);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.team-plan-card__phase-dot {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
font-size: 10px;
|
||||
border-radius: var(--radius-full);
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.team-plan-card__phase--in_progress .team-plan-card__phase-dot {
|
||||
color: var(--accent-team);
|
||||
}
|
||||
|
||||
.team-plan-card__phase--completed .team-plan-card__phase-dot {
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
.team-plan-card__phase--failed .team-plan-card__phase-dot {
|
||||
color: var(--color-error);
|
||||
}
|
||||
|
||||
.team-plan-card__phase-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.team-plan-card__phase-name {
|
||||
color: var(--text-primary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.team-plan-card__phase-expert {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--font-xs);
|
||||
color: var(--text-tertiary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.team-plan-card__phase-status {
|
||||
font-size: var(--font-xs);
|
||||
color: var(--text-tertiary);
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.team-plan-card__phase--in_progress .team-plan-card__phase-status {
|
||||
color: var(--accent-team);
|
||||
}
|
||||
|
||||
.team-plan-card__phase--completed .team-plan-card__phase-status {
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
.team-plan-card__phase--failed .team-plan-card__phase-status {
|
||||
color: var(--color-error);
|
||||
}
|
||||
|
||||
.team-plan-card__footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-2) var(--space-4) var(--space-3);
|
||||
border-top: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.team-plan-card__progress {
|
||||
flex: 1;
|
||||
height: 6px;
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: var(--radius-full);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.team-plan-card__progress-fill {
|
||||
height: 100%;
|
||||
background: var(--accent-team);
|
||||
border-radius: var(--radius-full);
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.team-plan-card__progress-text {
|
||||
font-size: var(--font-xs);
|
||||
color: var(--text-tertiary);
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
<template>
|
||||
<div class="user-bubble">
|
||||
<FileAttachment
|
||||
v-if="fileAttachment"
|
||||
:filename="fileAttachment.filename"
|
||||
:url="fileAttachment.url"
|
||||
/>
|
||||
<span v-else>{{ content }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import FileAttachment from './FileAttachment.vue'
|
||||
|
||||
interface Props {
|
||||
content: string
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const FILE_MARKDOWN_RE = /^\[文件\]\s*\[(.+?)\]\((.+?)\)$/s
|
||||
|
||||
const fileAttachment = computed(() => {
|
||||
const match = props.content.match(FILE_MARKDOWN_RE)
|
||||
if (!match) return null
|
||||
return {
|
||||
filename: match[1],
|
||||
url: match[2],
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.user-bubble {
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-primary);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border-radius: var(--radius-lg);
|
||||
font-size: var(--font-base);
|
||||
line-height: var(--leading-normal);
|
||||
max-width: 60%;
|
||||
word-break: break-word;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
export { default as MessageShell } from './MessageShell.vue'
|
||||
export { default as UserBubble } from './UserBubble.vue'
|
||||
export { default as AssistantText } from './AssistantText.vue'
|
||||
export { default as TeamPlanCard } from './TeamPlanCard.vue'
|
||||
export { default as BoardBannerCard } from './BoardBannerCard.vue'
|
||||
export { default as BoardRoundCard } from './BoardRoundCard.vue'
|
||||
export { default as BoardConclusionCard } from './BoardConclusionCard.vue'
|
||||
export { default as ErrorCard } from './ErrorCard.vue'
|
||||
export { default as FileAttachment } from './FileAttachment.vue'
|
||||
|
|
@ -97,7 +97,9 @@ async function handleSearch(): Promise<void> {
|
|||
}
|
||||
|
||||
onMounted(() => {
|
||||
kbStore.fetchSources()
|
||||
if (kbStore.sources.length === 0) {
|
||||
kbStore.fetchSources()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,69 +1,90 @@
|
|||
<template>
|
||||
<div class="agent-layout">
|
||||
<TitleBar />
|
||||
<TopNav :icon-nav-collapsed="iconNavCollapsed" @toggle-icon-nav="iconNavCollapsed = !iconNavCollapsed" />
|
||||
<div class="agent-layout__body">
|
||||
<IconNav
|
||||
:collapsed="iconNavCollapsed"
|
||||
@navigate="handleIconNav"
|
||||
/>
|
||||
<SplitPane
|
||||
direction="horizontal"
|
||||
:default-ratio="0.55"
|
||||
storage-key="agent-h-split"
|
||||
>
|
||||
<template #first>
|
||||
<ChatView />
|
||||
</template>
|
||||
<template #second>
|
||||
<SplitPane
|
||||
direction="vertical"
|
||||
:default-ratio="0.6"
|
||||
storage-key="agent-right-v-split"
|
||||
>
|
||||
<template #first>
|
||||
<QuadrantPanel
|
||||
ref="trPanel"
|
||||
:tabs="topRightTabs"
|
||||
default-tab="code"
|
||||
storage-key="quadrant-tr-tab"
|
||||
>
|
||||
<template #code>
|
||||
<div class="agent-layout__placeholder">
|
||||
<FileTextOutlined style="font-size: 32px; color: var(--text-placeholder)" />
|
||||
<p>代码预览</p>
|
||||
</div>
|
||||
</template>
|
||||
<template #workflow>
|
||||
<WorkflowView />
|
||||
</template>
|
||||
<template #knowledge>
|
||||
<KnowledgeBaseView />
|
||||
</template>
|
||||
</QuadrantPanel>
|
||||
</template>
|
||||
<template #second>
|
||||
<QuadrantPanel
|
||||
ref="brPanel"
|
||||
:tabs="bottomRightTabs"
|
||||
default-tab="monitor"
|
||||
storage-key="quadrant-br-tab"
|
||||
>
|
||||
<template #monitor>
|
||||
<EvolutionView />
|
||||
</template>
|
||||
<template #skills>
|
||||
<SkillsView />
|
||||
</template>
|
||||
<template #settings>
|
||||
<SettingsView />
|
||||
</template>
|
||||
</QuadrantPanel>
|
||||
</template>
|
||||
</SplitPane>
|
||||
</template>
|
||||
</SplitPane>
|
||||
</div>
|
||||
<!-- AI Agent 独立对话布局:/agent/chat 与 /agent/preview -->
|
||||
<template v-if="isChatLayout">
|
||||
<TopNav />
|
||||
<div class="agent-layout__chat-body">
|
||||
<ChatSidebar
|
||||
:conversations="chatStore.conversations"
|
||||
:current-id="chatStore.currentConversationId"
|
||||
@create="chatStore.createConversation"
|
||||
@select="chatStore.selectConversation"
|
||||
/>
|
||||
<div class="agent-layout__chat-main">
|
||||
<router-view />
|
||||
</div>
|
||||
<RightPanel />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 传统工作台布局:代码、监控等 -->
|
||||
<template v-else>
|
||||
<TitleBar />
|
||||
<TopNav :icon-nav-collapsed="iconNavCollapsed" @toggle-icon-nav="iconNavCollapsed = !iconNavCollapsed" />
|
||||
<div class="agent-layout__body">
|
||||
<IconNav
|
||||
:collapsed="iconNavCollapsed"
|
||||
@navigate="handleIconNav"
|
||||
/>
|
||||
<SplitPane
|
||||
direction="horizontal"
|
||||
:default-ratio="0.55"
|
||||
storage-key="agent-h-split"
|
||||
>
|
||||
<template #first>
|
||||
<router-view />
|
||||
</template>
|
||||
<template #second>
|
||||
<SplitPane
|
||||
direction="vertical"
|
||||
:default-ratio="0.6"
|
||||
storage-key="agent-right-v-split"
|
||||
>
|
||||
<template #first>
|
||||
<QuadrantPanel
|
||||
ref="trPanel"
|
||||
:tabs="topRightTabs"
|
||||
default-tab="code"
|
||||
storage-key="quadrant-tr-tab"
|
||||
>
|
||||
<template #code>
|
||||
<div class="agent-layout__placeholder">
|
||||
<FileTextOutlined style="font-size: 32px; color: var(--text-placeholder)" />
|
||||
<p>代码预览</p>
|
||||
</div>
|
||||
</template>
|
||||
<template #workflow>
|
||||
<WorkflowView />
|
||||
</template>
|
||||
<template #knowledge>
|
||||
<KnowledgeBaseView />
|
||||
</template>
|
||||
</QuadrantPanel>
|
||||
</template>
|
||||
<template #second>
|
||||
<QuadrantPanel
|
||||
ref="brPanel"
|
||||
:tabs="bottomRightTabs"
|
||||
default-tab="monitor"
|
||||
storage-key="quadrant-br-tab"
|
||||
>
|
||||
<template #monitor>
|
||||
<EvolutionView />
|
||||
</template>
|
||||
<template #skills>
|
||||
<SkillsView />
|
||||
</template>
|
||||
<template #settings>
|
||||
<SettingsView />
|
||||
</template>
|
||||
</QuadrantPanel>
|
||||
</template>
|
||||
</SplitPane>
|
||||
</template>
|
||||
</SplitPane>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="agent-layout__small-screen">
|
||||
<DesktopOutlined style="font-size: 48px; color: var(--text-placeholder)" />
|
||||
<h2>请使用更大的屏幕</h2>
|
||||
|
|
@ -73,7 +94,7 @@
|
|||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, defineAsyncComponent, type Component } from 'vue'
|
||||
import { ref, watch, defineAsyncComponent, computed, type Component } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import {
|
||||
FileTextOutlined,
|
||||
|
|
@ -84,15 +105,17 @@ import {
|
|||
SettingOutlined,
|
||||
DesktopOutlined,
|
||||
} from '@ant-design/icons-vue'
|
||||
import { useChatStore } from '@/stores/chat'
|
||||
import TopNav from './TopNav.vue'
|
||||
import TitleBar from './TitleBar.vue'
|
||||
import SplitPane from './SplitPane.vue'
|
||||
import QuadrantPanel from './QuadrantPanel.vue'
|
||||
import IconNav from './IconNav.vue'
|
||||
import RightPanel from './RightPanel.vue'
|
||||
import ChatSidebar from '@/components/chat/ChatSidebar.vue'
|
||||
import type { QuadrantTab } from './QuadrantPanel.vue'
|
||||
|
||||
// Lazy-load views to avoid bundling all into initial chunk
|
||||
const ChatView = defineAsyncComponent(() => import('@/views/ChatView.vue'))
|
||||
const WorkflowView = defineAsyncComponent(() => import('@/views/WorkflowView.vue'))
|
||||
const KnowledgeBaseView = defineAsyncComponent(() => import('@/views/KnowledgeBaseView.vue'))
|
||||
const EvolutionView = defineAsyncComponent(() => import('@/views/EvolutionView.vue'))
|
||||
|
|
@ -100,6 +123,11 @@ const SkillsView = defineAsyncComponent(() => import('@/views/SkillsView.vue'))
|
|||
const SettingsView = defineAsyncComponent(() => import('@/views/SettingsView.vue'))
|
||||
|
||||
const route = useRoute()
|
||||
const chatStore = useChatStore()
|
||||
|
||||
const isChatLayout = computed(() => {
|
||||
return route.name === 'agent-chat' || route.name === 'agent-preview'
|
||||
})
|
||||
|
||||
// IconNav state
|
||||
const iconNavCollapsed = ref(localStorage.getItem('icon-nav-collapsed') === 'true')
|
||||
|
|
@ -166,6 +194,22 @@ watch(() => route.meta, (meta) => {
|
|||
gap: 0;
|
||||
}
|
||||
|
||||
.agent-layout__chat-body {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
background: var(--bg-primary);
|
||||
}
|
||||
|
||||
.agent-layout__chat-main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
background: var(--bg-primary);
|
||||
}
|
||||
|
||||
.agent-layout__placeholder {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,81 @@
|
|||
<template>
|
||||
<div
|
||||
:class="['right-panel', { 'right-panel--collapsed': collapsed }]"
|
||||
:style="{ width: collapsed ? '36px' : '320px' }"
|
||||
>
|
||||
<button
|
||||
class="right-panel__toggle"
|
||||
:title="collapsed ? '展开右侧面板' : '收起右侧面板'"
|
||||
@click="toggle"
|
||||
>
|
||||
<LeftOutlined v-if="!collapsed" />
|
||||
<RightOutlined v-else />
|
||||
</button>
|
||||
|
||||
<div v-if="!collapsed" class="right-panel__content">
|
||||
<SystemMonitorPanel />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
import { LeftOutlined, RightOutlined } from '@ant-design/icons-vue'
|
||||
import SystemMonitorPanel from './SystemMonitorPanel.vue'
|
||||
|
||||
const STORAGE_KEY = 'right-panel-collapsed'
|
||||
const collapsed = ref(localStorage.getItem(STORAGE_KEY) === 'true')
|
||||
|
||||
watch(collapsed, (val) => {
|
||||
localStorage.setItem(STORAGE_KEY, String(val))
|
||||
})
|
||||
|
||||
function toggle(): void {
|
||||
collapsed.value = !collapsed.value
|
||||
}
|
||||
|
||||
defineExpose({ toggle, collapsed })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.right-panel {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
height: 100%;
|
||||
background: var(--bg-secondary);
|
||||
border-left: 1px solid var(--border-color);
|
||||
overflow: hidden;
|
||||
transition: width var(--transition-normal);
|
||||
}
|
||||
|
||||
.right-panel__toggle {
|
||||
width: 36px;
|
||||
min-width: 36px;
|
||||
height: 36px;
|
||||
margin: 6px 0 0 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text-tertiary);
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
border-radius: var(--radius-md);
|
||||
transition: color var(--transition-fast), background var(--transition-fast);
|
||||
}
|
||||
|
||||
.right-panel__toggle:hover {
|
||||
color: var(--text-primary);
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.right-panel__content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,427 @@
|
|||
<template>
|
||||
<div class="system-monitor">
|
||||
<div class="system-monitor__tabs" role="tablist">
|
||||
<button
|
||||
v-for="tab in tabs"
|
||||
:key="tab.key"
|
||||
type="button"
|
||||
class="system-monitor__tab"
|
||||
role="tab"
|
||||
:aria-selected="activeTab === tab.key"
|
||||
:class="{ 'system-monitor__tab--active': activeTab === tab.key }"
|
||||
@click="activeTab = tab.key"
|
||||
>
|
||||
<component :is="tab.icon" class="system-monitor__tab-icon" />
|
||||
{{ tab.label }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="activeTab === 'monitor'">
|
||||
<div v-if="loading" class="system-monitor__loading">
|
||||
<a-spin size="small" />
|
||||
<span>加载监控数据中...</span>
|
||||
</div>
|
||||
|
||||
<div v-else-if="error" class="system-monitor__error">
|
||||
<WarningOutlined />
|
||||
<span>{{ error }}</span>
|
||||
</div>
|
||||
|
||||
<div v-else class="system-monitor__body">
|
||||
<div class="system-monitor__section-title">系统概览</div>
|
||||
<div class="system-monitor__metrics">
|
||||
<div class="system-monitor__metric">
|
||||
<div class="system-monitor__metric-label">服务状态</div>
|
||||
<div class="system-monitor__metric-value" :class="`status-${health.status}`">
|
||||
{{ statusText }}
|
||||
</div>
|
||||
<div class="system-monitor__metric-delta">
|
||||
v{{ health.version || '—' }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="system-monitor__metric">
|
||||
<div class="system-monitor__metric-label">任务总数</div>
|
||||
<div class="system-monitor__metric-value">{{ metrics.tasks.total_tasks }}</div>
|
||||
<div class="system-monitor__metric-delta">
|
||||
完成 {{ metrics.tasks.completed_tasks }} / 失败 {{ metrics.tasks.failed_tasks }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="system-monitor__metric">
|
||||
<div class="system-monitor__metric-label">在线 Agent</div>
|
||||
<div class="system-monitor__metric-value">{{ metrics.agents.total_agents }}</div>
|
||||
<div class="system-monitor__metric-delta">已注册技能 {{ metrics.skills.total_skills }}</div>
|
||||
</div>
|
||||
<div class="system-monitor__metric">
|
||||
<div class="system-monitor__metric-label">待处理任务</div>
|
||||
<div class="system-monitor__metric-value">{{ metrics.tasks.pending_tasks }}</div>
|
||||
<div class="system-monitor__metric-delta" :class="metrics.tasks.pending_tasks > 0 ? 'down' : 'up'">
|
||||
{{ metrics.tasks.pending_tasks > 0 ? '队列中有任务' : '队列空闲' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="system-monitor__split">
|
||||
<div class="system-monitor__column">
|
||||
<div class="system-monitor__section-title">服务状态</div>
|
||||
<div class="system-monitor__services">
|
||||
<div
|
||||
v-for="s in serviceList"
|
||||
:key="s.name"
|
||||
class="system-monitor__service"
|
||||
>
|
||||
<span class="system-monitor__service-dot" :class="s.ok ? 'ok' : 'error'" />
|
||||
<span class="system-monitor__service-name">{{ s.name }}</span>
|
||||
<span class="system-monitor__service-status">{{ s.ok ? '正常' : '异常' }}</span>
|
||||
<span class="system-monitor__service-time">{{ s.detail }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="system-monitor__column">
|
||||
<div class="system-monitor__section-title">错误趋势(7天)</div>
|
||||
<div class="system-monitor__chart-placeholder">
|
||||
<LineChartOutlined />
|
||||
<span>历史趋势图表需接入日志/指标存储</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SkillsTab v-else-if="activeTab === 'skills'" />
|
||||
<SystemTab v-else-if="activeTab === 'system'" />
|
||||
<KnowledgeTab v-else-if="activeTab === 'knowledge'" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import type { Component } from 'vue'
|
||||
import { Spin as ASpin } from 'ant-design-vue'
|
||||
import {
|
||||
DashboardOutlined,
|
||||
AppstoreOutlined,
|
||||
SettingOutlined,
|
||||
BookOutlined,
|
||||
WarningOutlined,
|
||||
LineChartOutlined,
|
||||
} from '@ant-design/icons-vue'
|
||||
import {
|
||||
getHealth,
|
||||
getMetrics,
|
||||
type IHealthCheck,
|
||||
type IMetricsData,
|
||||
} from '@/api/system'
|
||||
import SkillsTab from './tabs/SkillsTab.vue'
|
||||
import SystemTab from './tabs/SystemTab.vue'
|
||||
import KnowledgeTab from './tabs/KnowledgeTab.vue'
|
||||
|
||||
interface Tab {
|
||||
key: string
|
||||
label: string
|
||||
icon: Component
|
||||
}
|
||||
|
||||
const tabs: Tab[] = [
|
||||
{ key: 'monitor', label: '监控', icon: DashboardOutlined as Component },
|
||||
{ key: 'skills', label: '技能', icon: AppstoreOutlined as Component },
|
||||
{ key: 'system', label: '系统', icon: SettingOutlined as Component },
|
||||
{ key: 'knowledge', label: '知识库', icon: BookOutlined as Component },
|
||||
]
|
||||
|
||||
const activeTab = ref('monitor')
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
const health = ref<IHealthCheck>({ status: 'unknown', version: '', checks: {} })
|
||||
const metrics = ref<IMetricsData>({
|
||||
tasks: { total_tasks: 0, completed_tasks: 0, failed_tasks: 0, pending_tasks: 0 },
|
||||
agents: { total_agents: 0 },
|
||||
skills: { total_skills: 0 },
|
||||
version: '',
|
||||
})
|
||||
const statusText = computed(() => {
|
||||
const map: Record<string, string> = {
|
||||
healthy: '健康',
|
||||
degraded: '降级',
|
||||
unhealthy: '异常',
|
||||
}
|
||||
return map[health.value.status] || health.value.status || '未知'
|
||||
})
|
||||
|
||||
const serviceList = computed(() => {
|
||||
const checks = health.value.checks || {}
|
||||
const services: { name: string; ok: boolean; detail: string }[] = []
|
||||
|
||||
const redis = checks.redis
|
||||
services.push({
|
||||
name: 'Redis',
|
||||
ok: redis === 'available',
|
||||
detail: typeof redis === 'string' ? redis : '—',
|
||||
})
|
||||
|
||||
const llm = checks.llm_gateway
|
||||
services.push({
|
||||
name: 'LLM 网关',
|
||||
ok: llm === 'available',
|
||||
detail: typeof llm === 'string' ? llm : '—',
|
||||
})
|
||||
|
||||
const pool = checks.agent_pool as { status?: string; size?: number } | undefined
|
||||
services.push({
|
||||
name: 'Agent 池',
|
||||
ok: pool?.status === 'available',
|
||||
detail: pool?.size !== undefined ? `${pool.size} 个` : '—',
|
||||
})
|
||||
|
||||
const skill = checks.skill_registry as { status?: string; count?: number } | undefined
|
||||
services.push({
|
||||
name: '技能注册表',
|
||||
ok: skill?.status === 'available',
|
||||
detail: skill?.count !== undefined ? `${skill.count} 个` : '—',
|
||||
})
|
||||
|
||||
return services
|
||||
})
|
||||
|
||||
let refreshTimer: number | null = null
|
||||
|
||||
async function refreshData(): Promise<void> {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
const [h, m] = await Promise.all([
|
||||
getHealth().catch((e: unknown) => {
|
||||
const msg = e instanceof Error ? e.message : '未知错误'
|
||||
throw new Error(`健康检查失败: ${msg}`)
|
||||
}),
|
||||
getMetrics().catch((e: unknown) => {
|
||||
const msg = e instanceof Error ? e.message : '未知错误'
|
||||
throw new Error(`指标获取失败: ${msg}`)
|
||||
}),
|
||||
])
|
||||
health.value = h
|
||||
metrics.value = m
|
||||
} catch (e: unknown) {
|
||||
error.value = e instanceof Error ? e.message : '加载失败'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
refreshData()
|
||||
refreshTimer = window.setInterval(refreshData, 10000)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (refreshTimer !== null) {
|
||||
clearInterval(refreshTimer)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.system-monitor {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
background: var(--bg-primary);
|
||||
border-left: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.system-monitor__tabs {
|
||||
display: flex;
|
||||
gap: var(--space-4);
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
font-size: var(--font-sm);
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.system-monitor__tab {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
cursor: pointer;
|
||||
padding: 4px 0;
|
||||
border: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
background: transparent;
|
||||
color: var(--text-tertiary);
|
||||
font-size: inherit;
|
||||
font-family: inherit;
|
||||
transition: color 0.15s;
|
||||
}
|
||||
|
||||
.system-monitor__tab:hover {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.system-monitor__tab--active {
|
||||
color: var(--accent-team);
|
||||
font-weight: var(--font-weight-medium);
|
||||
border-bottom-color: var(--accent-team);
|
||||
}
|
||||
|
||||
.system-monitor__tab-icon {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.system-monitor__loading,
|
||||
.system-monitor__error {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--space-2);
|
||||
color: var(--text-tertiary);
|
||||
font-size: var(--font-sm);
|
||||
}
|
||||
|
||||
.system-monitor__error {
|
||||
color: var(--color-error);
|
||||
}
|
||||
|
||||
.system-monitor__body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.system-monitor__section-title {
|
||||
font-size: var(--font-sm);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
color: var(--text-primary);
|
||||
margin: 16px 0 10px;
|
||||
}
|
||||
|
||||
.system-monitor__section-title:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.system-monitor__metrics {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.system-monitor__metric {
|
||||
padding: 12px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.system-monitor__metric-label {
|
||||
font-size: 11px;
|
||||
color: var(--text-tertiary);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.system-monitor__metric-value {
|
||||
font-size: 18px;
|
||||
font-weight: var(--font-weight-semibold);
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.system-monitor__metric-value.status-healthy {
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
.system-monitor__metric-value.status-degraded {
|
||||
color: var(--color-warning);
|
||||
}
|
||||
|
||||
.system-monitor__metric-value.status-unhealthy,
|
||||
.system-monitor__metric-value.status-unknown {
|
||||
color: var(--color-error);
|
||||
}
|
||||
|
||||
.system-monitor__metric-delta {
|
||||
font-size: 10px;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.system-monitor__metric-delta.up {
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
.system-monitor__metric-delta.down {
|
||||
color: var(--accent-team);
|
||||
}
|
||||
|
||||
.system-monitor__split {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.system-monitor__services {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.system-monitor__service {
|
||||
display: grid;
|
||||
grid-template-columns: 10px minmax(0, 1fr) auto auto;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 5px 0;
|
||||
font-size: var(--font-sm);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.system-monitor__service-name,
|
||||
.system-monitor__service-status,
|
||||
.system-monitor__service-time {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.system-monitor__service-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--text-placeholder);
|
||||
}
|
||||
|
||||
.system-monitor__service-dot.ok {
|
||||
background: var(--color-success);
|
||||
}
|
||||
|
||||
.system-monitor__service-dot.error {
|
||||
background: var(--color-error);
|
||||
}
|
||||
|
||||
.system-monitor__service-name {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.system-monitor__service-status {
|
||||
color: var(--color-success);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.system-monitor__service-time {
|
||||
color: var(--text-tertiary);
|
||||
font-size: 11px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.system-monitor__chart-placeholder {
|
||||
height: 80px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--space-2);
|
||||
color: var(--text-tertiary);
|
||||
font-size: var(--font-xs);
|
||||
background: var(--bg-secondary);
|
||||
border-radius: var(--radius-md);
|
||||
margin: 8px 0;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,7 +1,12 @@
|
|||
<template>
|
||||
<header class="top-nav">
|
||||
<div class="top-nav__left">
|
||||
<button class="top-nav__icon-btn" @click="emit('toggleIconNav')" title="切换导航">
|
||||
<button
|
||||
v-if="props.iconNavCollapsed !== undefined"
|
||||
class="top-nav__icon-btn"
|
||||
@click="emit('toggleIconNav')"
|
||||
title="切换导航"
|
||||
>
|
||||
<MenuFoldOutlined v-if="!iconNavCollapsed" />
|
||||
<MenuUnfoldOutlined v-else />
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,148 @@
|
|||
<template>
|
||||
<div class="knowledge-tab">
|
||||
<div class="knowledge-tab__section">
|
||||
<div class="knowledge-tab__section-title">知识库源</div>
|
||||
|
||||
<div v-if="kbStore.isLoading" class="knowledge-tab__loading">
|
||||
<a-spin size="small" />
|
||||
<span>加载信息源...</span>
|
||||
</div>
|
||||
|
||||
<div v-else-if="kbStore.error" class="knowledge-tab__error">
|
||||
<WarningOutlined />
|
||||
<span>{{ kbStore.error }}</span>
|
||||
</div>
|
||||
|
||||
<div v-else-if="kbStore.sources.length === 0" class="knowledge-tab__empty">
|
||||
<BookOutlined />
|
||||
<span>暂无信息源</span>
|
||||
</div>
|
||||
|
||||
<div v-else class="knowledge-tab__sources">
|
||||
<div
|
||||
v-for="source in kbStore.sources"
|
||||
:key="source.id"
|
||||
class="knowledge-tab__source"
|
||||
>
|
||||
<div class="knowledge-tab__source-header">
|
||||
<BookOutlined class="knowledge-tab__source-icon" />
|
||||
<span class="knowledge-tab__source-name">{{ source.name }}</span>
|
||||
<a-badge
|
||||
class="knowledge-tab__source-status"
|
||||
:status="source.status === 'active' ? 'success' : 'default'"
|
||||
:text="source.status === 'active' ? '正常' : source.status"
|
||||
/>
|
||||
</div>
|
||||
<div class="knowledge-tab__source-meta">
|
||||
<a-tag size="small">{{ source.type }}</a-tag>
|
||||
<span>{{ source.document_count }} 文档</span>
|
||||
<span v-if="source.last_synced">· 同步 {{ formatTime(source.last_synced) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="knowledge-tab__section">
|
||||
<div class="knowledge-tab__section-title">检索测试</div>
|
||||
<SearchTest />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted } from 'vue'
|
||||
import { BookOutlined, WarningOutlined } from '@ant-design/icons-vue'
|
||||
import { useKnowledgeStore } from '@/stores/knowledge'
|
||||
import SearchTest from '@/components/kb/SearchTest.vue'
|
||||
|
||||
const kbStore = useKnowledgeStore()
|
||||
|
||||
function formatTime(iso: string): string {
|
||||
try {
|
||||
return new Date(iso).toLocaleString()
|
||||
} catch {
|
||||
return iso
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
kbStore.fetchSources()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.knowledge-tab {
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
padding: var(--space-3) var(--space-4);
|
||||
}
|
||||
|
||||
.knowledge-tab__section {
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
|
||||
.knowledge-tab__section-title {
|
||||
font-size: var(--font-sm);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
color: var(--text-primary);
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
.knowledge-tab__loading,
|
||||
.knowledge-tab__error,
|
||||
.knowledge-tab__empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-6) 0;
|
||||
color: var(--text-tertiary);
|
||||
font-size: var(--font-sm);
|
||||
}
|
||||
|
||||
.knowledge-tab__error {
|
||||
color: var(--color-error);
|
||||
}
|
||||
|
||||
.knowledge-tab__sources {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.knowledge-tab__source {
|
||||
padding: var(--space-3);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.knowledge-tab__source-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
margin-bottom: var(--space-1);
|
||||
}
|
||||
|
||||
.knowledge-tab__source-icon {
|
||||
color: var(--accent-team);
|
||||
font-size: var(--font-sm);
|
||||
}
|
||||
|
||||
.knowledge-tab__source-name {
|
||||
font-weight: var(--font-weight-medium);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.knowledge-tab__source-status {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.knowledge-tab__source-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
font-size: var(--font-xs);
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,164 @@
|
|||
<template>
|
||||
<div class="skills-tab">
|
||||
<div v-if="skillsStore.isLoading" class="skills-tab__loading">
|
||||
<a-spin size="small" />
|
||||
<span>加载技能列表...</span>
|
||||
</div>
|
||||
|
||||
<div v-else-if="skillsStore.error" class="skills-tab__error">
|
||||
<WarningOutlined />
|
||||
<span>{{ skillsStore.error }}</span>
|
||||
</div>
|
||||
|
||||
<div v-else-if="skillsStore.skills.length === 0" class="skills-tab__empty">
|
||||
<AppstoreOutlined />
|
||||
<span>暂无已注册技能</span>
|
||||
</div>
|
||||
|
||||
<div v-else class="skills-tab__list">
|
||||
<div
|
||||
v-for="skill in skillsStore.skills"
|
||||
:key="skill.name"
|
||||
class="skills-tab__item"
|
||||
@click="openSkill(skill.name)"
|
||||
>
|
||||
<div class="skills-tab__item-header">
|
||||
<AppstoreOutlined class="skills-tab__item-icon" />
|
||||
<span class="skills-tab__item-name">{{ skill.name }}</span>
|
||||
<a-badge
|
||||
class="skills-tab__item-status"
|
||||
:status="skill.status === 'active' ? 'success' : 'default'"
|
||||
:text="skill.status === 'active' ? '正常' : skill.status"
|
||||
/>
|
||||
</div>
|
||||
<p class="skills-tab__item-desc">{{ skill.description || '暂无描述' }}</p>
|
||||
<div class="skills-tab__item-tags">
|
||||
<a-tag v-for="cap in skill.capabilities.slice(0, 3)" :key="cap" size="small">
|
||||
{{ cap }}
|
||||
</a-tag>
|
||||
<span v-if="skill.capabilities.length > 3" class="skills-tab__item-more">
|
||||
+{{ skill.capabilities.length - 3 }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SkillDetail
|
||||
:visible="detailVisible"
|
||||
:skill="skillsStore.selectedSkill"
|
||||
@close="closeDetail"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { AppstoreOutlined, WarningOutlined } from '@ant-design/icons-vue'
|
||||
import { useSkillsStore } from '@/stores/skills'
|
||||
import SkillDetail from '@/components/skills/SkillDetail.vue'
|
||||
|
||||
const skillsStore = useSkillsStore()
|
||||
const detailVisible = ref(false)
|
||||
|
||||
async function openSkill(name: string): Promise<void> {
|
||||
await skillsStore.fetchSkillDetail(name)
|
||||
detailVisible.value = true
|
||||
}
|
||||
|
||||
function closeDetail(): void {
|
||||
detailVisible.value = false
|
||||
skillsStore.clearSelectedSkill()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
skillsStore.fetchSkills()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.skills-tab {
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
padding: var(--space-3) var(--space-4);
|
||||
}
|
||||
|
||||
.skills-tab__loading,
|
||||
.skills-tab__error,
|
||||
.skills-tab__empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-8) 0;
|
||||
color: var(--text-tertiary);
|
||||
font-size: var(--font-sm);
|
||||
}
|
||||
|
||||
.skills-tab__error {
|
||||
color: var(--color-error);
|
||||
}
|
||||
|
||||
.skills-tab__list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.skills-tab__item {
|
||||
padding: var(--space-3);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-md);
|
||||
cursor: pointer;
|
||||
transition: background var(--transition-fast), border-color var(--transition-fast);
|
||||
}
|
||||
|
||||
.skills-tab__item:hover {
|
||||
background: var(--bg-tertiary);
|
||||
border-color: var(--border-color-hover);
|
||||
}
|
||||
|
||||
.skills-tab__item-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
margin-bottom: var(--space-1);
|
||||
}
|
||||
|
||||
.skills-tab__item-icon {
|
||||
color: var(--accent-team);
|
||||
font-size: var(--font-sm);
|
||||
}
|
||||
|
||||
.skills-tab__item-name {
|
||||
font-weight: var(--font-weight-medium);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.skills-tab__item-status {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.skills-tab__item-desc {
|
||||
font-size: var(--font-sm);
|
||||
color: var(--text-secondary);
|
||||
margin: 0 0 var(--space-2);
|
||||
line-height: var(--leading-normal);
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.skills-tab__item-tags {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.skills-tab__item-more {
|
||||
font-size: var(--font-xs);
|
||||
color: var(--text-placeholder);
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,236 @@
|
|||
<template>
|
||||
<div class="system-tab">
|
||||
<div v-if="loading" class="system-tab__loading">
|
||||
<a-spin size="small" />
|
||||
<span>加载系统资源...</span>
|
||||
</div>
|
||||
|
||||
<div v-else-if="error" class="system-tab__error">
|
||||
<WarningOutlined />
|
||||
<span>{{ error }}</span>
|
||||
</div>
|
||||
|
||||
<div v-else class="system-tab__body">
|
||||
<div class="system-tab__card">
|
||||
<div class="system-tab__card-title">CPU</div>
|
||||
<div class="system-tab__metric-row">
|
||||
<span class="system-tab__metric-value">{{ resources.cpu.count }} 核</span>
|
||||
<span class="system-tab__metric-label">负载</span>
|
||||
</div>
|
||||
<div class="system-tab__bar">
|
||||
<div
|
||||
class="system-tab__bar-fill"
|
||||
:style="{ width: cpuLoadPercent + '%' }"
|
||||
/>
|
||||
</div>
|
||||
<div class="system-tab__metric-detail">
|
||||
{{ cpuLoadAverage }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="system-tab__card">
|
||||
<div class="system-tab__card-title">内存</div>
|
||||
<div class="system-tab__metric-row">
|
||||
<span class="system-tab__metric-value">{{ memoryPercent }}%</span>
|
||||
<span class="system-tab__metric-label">已用</span>
|
||||
</div>
|
||||
<div class="system-tab__bar">
|
||||
<div
|
||||
class="system-tab__bar-fill"
|
||||
:class="{ 'system-tab__bar-fill--warning': memoryPercent > 80 }"
|
||||
:style="{ width: memoryPercent + '%' }"
|
||||
/>
|
||||
</div>
|
||||
<div class="system-tab__metric-detail">
|
||||
{{ formatBytes(resources.memory.used_bytes) }} / {{ formatBytes(resources.memory.total_bytes) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="system-tab__card">
|
||||
<div class="system-tab__card-title">磁盘</div>
|
||||
<div class="system-tab__metric-row">
|
||||
<span class="system-tab__metric-value">{{ diskPercent }}%</span>
|
||||
<span class="system-tab__metric-label">已用</span>
|
||||
</div>
|
||||
<div class="system-tab__bar">
|
||||
<div
|
||||
class="system-tab__bar-fill"
|
||||
:class="{ 'system-tab__bar-fill--warning': diskPercent > 85 }"
|
||||
:style="{ width: diskPercent + '%' }"
|
||||
/>
|
||||
</div>
|
||||
<div class="system-tab__metric-detail">
|
||||
{{ formatBytes(resources.disk.used_bytes) }} / {{ formatBytes(resources.disk.total_bytes) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="system-tab__updated">
|
||||
更新时间:{{ updatedAt }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { WarningOutlined } from '@ant-design/icons-vue'
|
||||
import { getResources, type ISystemResources } from '@/api/system'
|
||||
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
const resources = ref<ISystemResources>({
|
||||
cpu: { count: 1, load_average: null },
|
||||
memory: { total_bytes: 0, used_bytes: 0, free_bytes: 0, percent: null },
|
||||
disk: { total_bytes: 0, used_bytes: 0, free_bytes: 0, percent: null },
|
||||
timestamp: 0,
|
||||
})
|
||||
|
||||
const cpuLoadPercent = computed(() => {
|
||||
const load = resources.value.cpu.load_average?.[0]
|
||||
if (!load || resources.value.cpu.count <= 0) return 0
|
||||
return Math.min((load / resources.value.cpu.count) * 100, 100)
|
||||
})
|
||||
|
||||
const cpuLoadAverage = computed(() => {
|
||||
const load = resources.value.cpu.load_average
|
||||
if (!load || load.length === 0) return '—'
|
||||
return `1m: ${load[0].toFixed(2)}${load[1] !== undefined ? ` / 5m: ${load[1].toFixed(2)}` : ''}`
|
||||
})
|
||||
|
||||
const memoryPercent = computed(() => resources.value.memory.percent ?? 0)
|
||||
const diskPercent = computed(() => resources.value.disk.percent ?? 0)
|
||||
|
||||
const updatedAt = computed(() => {
|
||||
if (!resources.value.timestamp) return '—'
|
||||
return new Date(resources.value.timestamp * 1000).toLocaleString()
|
||||
})
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes === 0) return '—'
|
||||
const units = ['B', 'KB', 'MB', 'GB', 'TB']
|
||||
let idx = 0
|
||||
let value = bytes
|
||||
while (value >= 1024 && idx < units.length - 1) {
|
||||
value /= 1024
|
||||
idx++
|
||||
}
|
||||
return `${value.toFixed(1)} ${units[idx]}`
|
||||
}
|
||||
|
||||
async function refresh(): Promise<void> {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
resources.value = await getResources()
|
||||
} catch (e: unknown) {
|
||||
error.value = e instanceof Error ? e.message : '加载失败'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
let refreshTimer: number | null = null
|
||||
|
||||
onMounted(() => {
|
||||
refresh()
|
||||
refreshTimer = window.setInterval(refresh, 10000)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (refreshTimer !== null) {
|
||||
clearInterval(refreshTimer)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.system-tab {
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
padding: var(--space-3) var(--space-4);
|
||||
}
|
||||
|
||||
.system-tab__loading,
|
||||
.system-tab__error {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-8) 0;
|
||||
color: var(--text-tertiary);
|
||||
font-size: var(--font-sm);
|
||||
}
|
||||
|
||||
.system-tab__error {
|
||||
color: var(--color-error);
|
||||
}
|
||||
|
||||
.system-tab__body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.system-tab__card {
|
||||
padding: var(--space-3);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--bg-primary);
|
||||
}
|
||||
|
||||
.system-tab__card-title {
|
||||
font-size: var(--font-sm);
|
||||
font-weight: var(--font-weight-medium);
|
||||
color: var(--text-primary);
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
.system-tab__metric-row {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: var(--space-1);
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
.system-tab__metric-value {
|
||||
font-size: 20px;
|
||||
font-weight: var(--font-weight-semibold);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.system-tab__metric-label {
|
||||
font-size: var(--font-sm);
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.system-tab__bar {
|
||||
height: 6px;
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
.system-tab__bar-fill {
|
||||
height: 100%;
|
||||
background: var(--accent-team);
|
||||
border-radius: 3px;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.system-tab__bar-fill--warning {
|
||||
background: var(--color-warning);
|
||||
}
|
||||
|
||||
.system-tab__metric-detail {
|
||||
font-size: var(--font-xs);
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.system-tab__updated {
|
||||
font-size: var(--font-xs);
|
||||
color: var(--text-placeholder);
|
||||
text-align: right;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,178 @@
|
|||
<template>
|
||||
<div class="chat-preview">
|
||||
<div class="chat-preview__body">
|
||||
<div ref="mainRef" class="chat-preview__main">
|
||||
<div class="chat-preview__demo-badge">
|
||||
<span class="chat-preview__demo-dot" />
|
||||
样例预览 · 组件展示
|
||||
</div>
|
||||
<component :is="scenes[activeScene].component" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="chat-preview__scenes">
|
||||
<span class="chat-preview__scenes-label">场景</span>
|
||||
<button
|
||||
v-for="(scene, idx) in scenes"
|
||||
:key="scene.id"
|
||||
class="chat-preview__scene-btn"
|
||||
:class="{ 'chat-preview__scene-btn--active': activeScene === idx }"
|
||||
@click="setScene(idx)"
|
||||
>
|
||||
{{ idx + 1 }}. {{ scene.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, markRaw, onMounted, onUnmounted, type Component } from 'vue'
|
||||
import Scene1Welcome from './scenes/Scene1Welcome.vue'
|
||||
import Scene2UserAssistant from './scenes/Scene2UserAssistant.vue'
|
||||
import Scene3TeamPlan from './scenes/Scene3TeamPlan.vue'
|
||||
import Scene4BoardDiscussion from './scenes/Scene4BoardDiscussion.vue'
|
||||
import Scene5ToolCall from './scenes/Scene5ToolCall.vue'
|
||||
import Scene6Error from './scenes/Scene6Error.vue'
|
||||
|
||||
const activeScene = ref(0)
|
||||
const mainRef = ref<HTMLDivElement | null>(null)
|
||||
|
||||
function setScene(index: number): void {
|
||||
activeScene.value = Math.max(0, Math.min(scenes.length - 1, index))
|
||||
if (mainRef.value) {
|
||||
mainRef.value.scrollTop = 0
|
||||
}
|
||||
}
|
||||
|
||||
function isTypingTarget(target: EventTarget | null): boolean {
|
||||
if (!(target instanceof HTMLElement)) return false
|
||||
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA') return true
|
||||
if (target.isContentEditable) return true
|
||||
return target.closest('input, textarea, [contenteditable="true"]') !== null
|
||||
}
|
||||
|
||||
function onKeyDown(event: KeyboardEvent): void {
|
||||
if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) return
|
||||
if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') return
|
||||
if (isTypingTarget(event.target)) return
|
||||
|
||||
event.preventDefault()
|
||||
if (event.key === 'ArrowLeft') {
|
||||
setScene(activeScene.value - 1)
|
||||
} else {
|
||||
setScene(activeScene.value + 1)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('keydown', onKeyDown)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('keydown', onKeyDown)
|
||||
})
|
||||
|
||||
interface Scene {
|
||||
id: string
|
||||
label: string
|
||||
component: Component
|
||||
}
|
||||
|
||||
const scenes: Scene[] = [
|
||||
{ id: 'welcome', label: '欢迎', component: markRaw(Scene1Welcome) },
|
||||
{ id: 'user-assistant', label: '对话', component: markRaw(Scene2UserAssistant) },
|
||||
{ id: 'team-plan', label: '@team', component: markRaw(Scene3TeamPlan) },
|
||||
{ id: 'board', label: '@board', component: markRaw(Scene4BoardDiscussion) },
|
||||
{ id: 'tool-call', label: '工具', component: markRaw(Scene5ToolCall) },
|
||||
{ id: 'error', label: '错误', component: markRaw(Scene6Error) },
|
||||
]
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.chat-preview {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
background: var(--bg-primary);
|
||||
font-family: var(--font-sans, system-ui);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.chat-preview__body {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.chat-preview__main {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
background: var(--bg-primary);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.chat-preview__demo-badge {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
right: 16px;
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 10px;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-full);
|
||||
font-size: var(--font-xs);
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.chat-preview__demo-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-warning, #f59e0b);
|
||||
}
|
||||
|
||||
.chat-preview__scenes {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-1);
|
||||
padding: var(--space-2) var(--space-4);
|
||||
border-top: 1px solid var(--border-color);
|
||||
background: var(--bg-secondary);
|
||||
font-size: var(--font-xs);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.chat-preview__scenes-label {
|
||||
color: var(--text-tertiary);
|
||||
margin-right: var(--space-2);
|
||||
font-size: var(--font-xs);
|
||||
}
|
||||
|
||||
.chat-preview__scene-btn {
|
||||
padding: 4px 10px;
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
cursor: pointer;
|
||||
font-size: var(--font-xs);
|
||||
color: var(--text-tertiary);
|
||||
font-family: inherit;
|
||||
border-radius: var(--radius-md);
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.chat-preview__scene-btn:hover {
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.chat-preview__scene-btn--active {
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
border-color: var(--border-color);
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,175 @@
|
|||
<template>
|
||||
<div class="s1">
|
||||
<div class="s1__hero">
|
||||
<div class="s1__logo">
|
||||
<RobotOutlined />
|
||||
</div>
|
||||
<h1 class="s1__title">Fischer AgentKit</h1>
|
||||
<p class="s1__subtitle">企业级 AI 智能体平台</p>
|
||||
<div class="s1__hints">
|
||||
<div v-for="hint in hints" :key="hint" class="s1__hint">
|
||||
<ThunderboltOutlined class="s1__hint-icon" />
|
||||
<span>{{ hint }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="s1__messages">
|
||||
<MessageShell role="user" name="用户" meta="10:23">
|
||||
<UserBubble content="你好!" />
|
||||
</MessageShell>
|
||||
<MessageShell role="user" name="用户" meta="10:23">
|
||||
<UserBubble content="在吗?" />
|
||||
</MessageShell>
|
||||
<MessageShell role="assistant" name="AI Agent" meta="10:24">
|
||||
<AssistantText :message="welcomeMessage" />
|
||||
</MessageShell>
|
||||
</div>
|
||||
|
||||
<div class="s1__input">
|
||||
<span class="s1__input-field">@team:tech_lead,frontend_engineer,backend_engineer 设计一个用户认证系统</span>
|
||||
<button class="s1__input-send">
|
||||
<SendOutlined />
|
||||
</button>
|
||||
</div>
|
||||
<p class="s1__input-hint">Enter 发送,Shift + Enter 换行</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { RobotOutlined, ThunderboltOutlined, SendOutlined } from '@ant-design/icons-vue'
|
||||
import { MessageShell, UserBubble, AssistantText } from '@/components/chat/messages'
|
||||
import type { IChatMessage } from '@/api/types'
|
||||
|
||||
const hints = [
|
||||
'智能路由:自动识别任务类型',
|
||||
'专家团协作:@team 调用多智能体',
|
||||
'私董会决策:@board 深度讨论',
|
||||
'工具调用:代码、搜索、文件处理',
|
||||
]
|
||||
|
||||
const welcomeMessage: IChatMessage = {
|
||||
id: 's1-welcome',
|
||||
role: 'assistant',
|
||||
content: '在的。我是 Fischer AgentKit 的 AI Agent,可以帮助你完成代码开发、数据分析、文档写作等复杂任务。\n\n你可以直接输入问题,或使用:\n- `@team:任务描述` 启动专家团协作\n- `@board:话题` 开启私董会讨论',
|
||||
timestamp: new Date().toISOString(),
|
||||
status: 'completed',
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.s1 {
|
||||
max-width: var(--max-chat-width);
|
||||
margin: 0 auto;
|
||||
padding: 48px 0 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 32px;
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
.s1__hero {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
gap: var(--space-2);
|
||||
padding: 40px 0;
|
||||
}
|
||||
|
||||
.s1__logo {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: var(--radius-xl);
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 28px;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
.s1__title {
|
||||
font-size: var(--font-xl);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
color: var(--text-primary);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.s1__subtitle {
|
||||
font-size: var(--font-md);
|
||||
color: var(--text-tertiary);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.s1__hints {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
gap: var(--space-3);
|
||||
margin-top: var(--space-4);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.s1__hint {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-lg);
|
||||
font-size: var(--font-sm);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.s1__hint-icon {
|
||||
color: var(--text-tertiary);
|
||||
font-size: var(--font-sm);
|
||||
}
|
||||
|
||||
.s1__messages {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.s1__input {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--bg-secondary);
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.s1__input-field {
|
||||
flex: 1;
|
||||
font-size: 14px;
|
||||
color: var(--text-placeholder);
|
||||
}
|
||||
|
||||
.s1__input-send {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 6px;
|
||||
background: var(--text-primary);
|
||||
color: var(--text-inverse);
|
||||
border: 1px solid var(--text-primary);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.s1__input-hint {
|
||||
font-size: 11px;
|
||||
color: var(--text-placeholder);
|
||||
margin: -24px 0 0 0;
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,91 @@
|
|||
<template>
|
||||
<div class="s2">
|
||||
<div class="s2__messages">
|
||||
<MessageShell role="user" name="用户" meta="10:30">
|
||||
<UserBubble content="帮我写一个 Python 函数,把 JSON 转换成 CSV。" />
|
||||
</MessageShell>
|
||||
|
||||
<MessageShell role="assistant" name="AI Agent" meta="10:31">
|
||||
<AssistantText :message="assistantMessage" />
|
||||
</MessageShell>
|
||||
</div>
|
||||
|
||||
<div class="s2__input">
|
||||
<span class="s2__input-placeholder">继续输入...</span>
|
||||
<button class="s2__input-send">
|
||||
<SendOutlined />
|
||||
</button>
|
||||
</div>
|
||||
<p class="s2__input-hint">Enter 发送,Shift + Enter 换行</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { SendOutlined } from '@ant-design/icons-vue'
|
||||
import { MessageShell, UserBubble, AssistantText } from '@/components/chat/messages'
|
||||
import type { IChatMessage } from '@/api/types'
|
||||
|
||||
const assistantMessage: IChatMessage = {
|
||||
id: 's2-reply',
|
||||
role: 'assistant',
|
||||
content: '好的,这里提供一个使用标准库 `json` 和 `csv` 的实现:\n\n```python\nimport json\nimport csv\n\ndef json_to_csv(json_path: str, csv_path: str) -> None:\n with open(json_path, encoding="utf-8") as f:\n data = json.load(f)\n\n if not isinstance(data, list) or len(data) == 0:\n raise ValueError("JSON 数据应为非空对象数组")\n\n headers = data[0].keys()\n\n with open(csv_path, "w", newline="", encoding="utf-8") as f:\n writer = csv.DictWriter(f, fieldnames=headers)\n writer.writeheader()\n writer.writerows(data)\n```\n\n如果 JSON 嵌套层级较深,可以先把它拍平成对象列表,再调用此函数。',
|
||||
timestamp: new Date().toISOString(),
|
||||
status: 'completed',
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.s2 {
|
||||
max-width: var(--max-chat-width);
|
||||
margin: 0 auto;
|
||||
padding: 48px var(--space-4) 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
.s2__messages {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.s2__input {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--bg-secondary);
|
||||
margin-top: 32px;
|
||||
}
|
||||
|
||||
.s2__input-placeholder {
|
||||
flex: 1;
|
||||
font-size: 14px;
|
||||
color: var(--text-placeholder);
|
||||
}
|
||||
|
||||
.s2__input-send {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 6px;
|
||||
background: var(--text-primary);
|
||||
color: var(--text-inverse);
|
||||
border: 1px solid var(--text-primary);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.s2__input-hint {
|
||||
font-size: 11px;
|
||||
color: var(--text-placeholder);
|
||||
margin-top: 4px;
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,93 @@
|
|||
<template>
|
||||
<div class="s3">
|
||||
<div class="s3__messages">
|
||||
<MessageShell role="user" name="用户" meta="10:40">
|
||||
<UserBubble content="@team 帮我设计一个电商订单模块" />
|
||||
</MessageShell>
|
||||
|
||||
<MessageShell role="assistant" name="AI Agent" meta="10:40">
|
||||
<AssistantText :message="planIntroMessage" />
|
||||
<TeamPlanCard
|
||||
:phases="phases"
|
||||
lead-name="tech_lead"
|
||||
lead-avatar="T"
|
||||
lead-color="#3b82f6"
|
||||
/>
|
||||
</MessageShell>
|
||||
|
||||
<MessageShell role="assistant" name="tech_lead" meta="10:41" avatar="T" color="#3b82f6">
|
||||
<AssistantText :message="expertMessage" />
|
||||
</MessageShell>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { MessageShell, UserBubble, AssistantText, TeamPlanCard } from '@/components/chat/messages'
|
||||
import type { IChatMessage, ITeamPlanPhase } from '@/api/types'
|
||||
|
||||
const planIntroMessage: IChatMessage = {
|
||||
id: 's3-plan-intro',
|
||||
role: 'assistant',
|
||||
content: '已组建开发专家团队,正在分解任务:',
|
||||
timestamp: new Date().toISOString(),
|
||||
status: 'completed',
|
||||
}
|
||||
|
||||
const phases: ITeamPlanPhase[] = [
|
||||
{
|
||||
id: 'p1',
|
||||
name: '需求拆解与表设计',
|
||||
assigned_expert: 'tech_lead',
|
||||
status: 'completed',
|
||||
depends_on: [],
|
||||
},
|
||||
{
|
||||
id: 'p2',
|
||||
name: '后端 API 实现',
|
||||
assigned_expert: 'backend_engineer',
|
||||
status: 'in_progress',
|
||||
depends_on: ['p1'],
|
||||
},
|
||||
{
|
||||
id: 'p3',
|
||||
name: '前端订单页面',
|
||||
assigned_expert: 'frontend_engineer',
|
||||
status: 'pending',
|
||||
depends_on: ['p2'],
|
||||
},
|
||||
{
|
||||
id: 'p4',
|
||||
name: '接口测试与 Code Review',
|
||||
assigned_expert: 'qa_engineer',
|
||||
status: 'pending',
|
||||
depends_on: ['p3'],
|
||||
},
|
||||
]
|
||||
|
||||
const expertMessage: IChatMessage = {
|
||||
id: 's3-expert',
|
||||
role: 'assistant',
|
||||
content: '我建议订单表至少包含:`order_id`、`user_id`、`status`、`total_amount`、`created_at`。同时引入状态机管理订单生命周期,避免直接修改状态字段。',
|
||||
timestamp: new Date().toISOString(),
|
||||
status: 'completed',
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.s3 {
|
||||
max-width: var(--max-chat-width);
|
||||
margin: 0 auto;
|
||||
padding: 48px var(--space-4) 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
.s3__messages {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
flex: 1;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,128 @@
|
|||
<template>
|
||||
<div class="s4">
|
||||
<div class="s4__messages">
|
||||
<MessageShell role="user" name="用户" meta="11:00">
|
||||
<UserBubble content="@board 我们应该把订单服务拆成独立微服务吗?" />
|
||||
</MessageShell>
|
||||
|
||||
<MessageShell role="assistant" name="私董会" meta="11:00">
|
||||
<BoardBannerCard
|
||||
topic="是否将订单服务拆分为独立微服务"
|
||||
:experts="boardExperts"
|
||||
:max-rounds="3"
|
||||
:current-round="1"
|
||||
/>
|
||||
</MessageShell>
|
||||
|
||||
<MessageShell role="assistant" name="架构师老张" meta="第 1 轮" avatar="张" color="var(--accent-board)">
|
||||
<BoardRoundCard
|
||||
name="架构师老张"
|
||||
avatar="张"
|
||||
color="var(--accent-board)"
|
||||
:round="1"
|
||||
role="expert"
|
||||
:content="speech1.content"
|
||||
/>
|
||||
</MessageShell>
|
||||
|
||||
<MessageShell role="assistant" name="后端负责人小李" meta="第 1 轮" avatar="李" color="var(--accent-board)">
|
||||
<BoardRoundCard
|
||||
name="后端负责人小李"
|
||||
avatar="李"
|
||||
color="var(--accent-board)"
|
||||
:round="1"
|
||||
role="expert"
|
||||
:content="speech2.content"
|
||||
/>
|
||||
</MessageShell>
|
||||
|
||||
<MessageShell role="assistant" name="主持人" meta="第 1 轮 · 小结" avatar="主" color="var(--accent-board)">
|
||||
<BoardRoundCard
|
||||
name="主持人"
|
||||
avatar="主"
|
||||
color="var(--accent-board)"
|
||||
:round="1"
|
||||
role="summary"
|
||||
:content="summaryMessage.content"
|
||||
/>
|
||||
</MessageShell>
|
||||
|
||||
<MessageShell role="assistant" name="主持人" meta="11:05">
|
||||
<BoardConclusionCard :data="conclusionData" />
|
||||
</MessageShell>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
MessageShell,
|
||||
UserBubble,
|
||||
BoardBannerCard,
|
||||
BoardRoundCard,
|
||||
BoardConclusionCard,
|
||||
} from '@/components/chat/messages'
|
||||
import type { IChatMessage, IBoardConcludedData, IBoardExpert } from '@/api/types'
|
||||
|
||||
const boardExperts: IBoardExpert[] = [
|
||||
{ name: '架构师老张', avatar: '张', color: 'var(--accent-board)', is_moderator: false, persona: '架构' },
|
||||
{ name: '后端负责人小李', avatar: '李', color: 'var(--accent-board)', is_moderator: false, persona: '后端' },
|
||||
{ name: '主持人', avatar: '主', color: 'var(--accent-board)', is_moderator: true, persona: '主持' },
|
||||
]
|
||||
|
||||
const speech1: IChatMessage = {
|
||||
id: 's4-speech-1',
|
||||
role: 'assistant',
|
||||
content: '我支持拆分。订单服务具有明确的业务边界,独立部署后可以降低发布耦合。当前单体应用里,订单模块的变更经常影响库存和支付,风险较高。',
|
||||
timestamp: new Date().toISOString(),
|
||||
status: 'completed',
|
||||
}
|
||||
|
||||
const speech2: IChatMessage = {
|
||||
id: 's4-speech-2',
|
||||
role: 'assistant',
|
||||
content: '我担心拆分带来的分布式事务复杂度。订单与库存、支付的强一致性要求很高,如果引入 Saga 或 TCC,团队学习和维护成本会显著增加。建议先梳理核心流程再决定。',
|
||||
timestamp: new Date().toISOString(),
|
||||
status: 'completed',
|
||||
}
|
||||
|
||||
const summaryMessage: IChatMessage = {
|
||||
id: 's4-summary',
|
||||
role: 'assistant',
|
||||
content: '**第 1 轮小结**:\n- 老张主张拆分,理由是边界清晰、降低发布风险。\n- 小李提醒分布式事务成本,建议先梳理流程。\n\n下一轮可继续讨论:拆分后的数据一致性与回滚策略。',
|
||||
timestamp: new Date().toISOString(),
|
||||
status: 'completed',
|
||||
}
|
||||
|
||||
const conclusionData: IBoardConcludedData = {
|
||||
summary: '经过 1 轮讨论,专家认为订单服务拆分具备业务合理性,但需要先解决分布式事务与数据一致性问题。',
|
||||
decision_advice: '建议先绘制订单-库存-支付的领域边界图,并在沙箱环境中验证 Saga 编排方案,再决定是否拆分。',
|
||||
total_rounds: 1,
|
||||
consensus_points: [
|
||||
'订单服务具有清晰业务边界',
|
||||
'拆分前应评估分布式事务成本',
|
||||
],
|
||||
dissent_points: [
|
||||
'老张认为风险主要来自发布耦合,拆分收益高',
|
||||
'小李认为团队驾驭分布式事务的能力是主要瓶颈',
|
||||
],
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.s4 {
|
||||
max-width: var(--max-chat-width);
|
||||
margin: 0 auto;
|
||||
padding: 48px var(--space-4) 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
.s4__messages {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
flex: 1;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,74 @@
|
|||
<template>
|
||||
<div class="s5">
|
||||
<div class="s5__messages">
|
||||
<MessageShell role="user" name="用户" meta="11:10">
|
||||
<UserBubble content="查看一下 src/services 目录下有哪些文件" />
|
||||
</MessageShell>
|
||||
|
||||
<MessageShell role="assistant" name="AI Agent" meta="11:10">
|
||||
<AssistantText :message="toolMessage" />
|
||||
</MessageShell>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { MessageShell, UserBubble, AssistantText } from '@/components/chat/messages'
|
||||
import type { IChatMessage, IToolCallData } from '@/api/types'
|
||||
|
||||
const toolCalls: IToolCallData[] = [
|
||||
{
|
||||
id: 'tc-1',
|
||||
name: 'Glob',
|
||||
status: 'completed',
|
||||
params: 'pattern: "src/services/**/*.py"',
|
||||
result: 'src/services/__init__.py\nsrc/services/order.py\nsrc/services/payment.py\nsrc/services/inventory.py',
|
||||
duration: 80,
|
||||
children: [
|
||||
{
|
||||
id: 'tc-1-1',
|
||||
name: 'Read',
|
||||
status: 'completed',
|
||||
params: 'path: "src/services/order.py"',
|
||||
result: '# order service implementation...',
|
||||
duration: 45,
|
||||
},
|
||||
{
|
||||
id: 'tc-1-2',
|
||||
name: 'Read',
|
||||
status: 'error',
|
||||
params: 'path: "src/services/inventory.py"',
|
||||
error: 'File not found: src/services/inventory.py',
|
||||
duration: 20,
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const toolMessage: IChatMessage = {
|
||||
id: 's5-tool',
|
||||
role: 'assistant',
|
||||
content: '正在扫描 `src/services` 目录并读取关键文件:\n\n- 成功读取 `order.py`\n- 读取 `inventory.py` 失败,文件不存在',
|
||||
timestamp: new Date().toISOString(),
|
||||
status: 'completed',
|
||||
tool_calls: toolCalls,
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.s5 {
|
||||
max-width: var(--max-chat-width);
|
||||
margin: 0 auto;
|
||||
padding: 48px var(--space-4) 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
.s5__messages {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
flex: 1;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,57 @@
|
|||
<template>
|
||||
<div class="s6">
|
||||
<div class="s6__messages">
|
||||
<MessageShell role="user" name="用户" meta="11:15">
|
||||
<UserBubble content="调用一个会失败的工具看看" />
|
||||
</MessageShell>
|
||||
|
||||
<MessageShell role="assistant" name="系统" meta="11:15">
|
||||
<ErrorCard
|
||||
title="工具调用失败"
|
||||
:detail="errorDetail"
|
||||
@retry="handleRetry"
|
||||
/>
|
||||
</MessageShell>
|
||||
|
||||
<MessageShell v-if="retried" role="assistant" name="AI Agent" meta="11:16">
|
||||
<div class="s6__retry-hint">
|
||||
已重新发送请求,正在等待响应…
|
||||
</div>
|
||||
</MessageShell>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { MessageShell, UserBubble, ErrorCard } from '@/components/chat/messages'
|
||||
|
||||
const errorDetail = ref('Connection refused: 无法连接到本地 MCP 服务器(localhost:8080)。请检查服务是否已启动。')
|
||||
const retried = ref(false)
|
||||
|
||||
function handleRetry(): void {
|
||||
retried.value = true
|
||||
errorDetail.value = '正在重试…'
|
||||
setTimeout(() => {
|
||||
errorDetail.value = 'Connection refused: 无法连接到本地 MCP 服务器(localhost:8080)。请检查服务是否已启动。'
|
||||
}, 1500)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.s6 {
|
||||
max-width: var(--max-chat-width);
|
||||
margin: 0 auto;
|
||||
padding: 48px var(--space-4) 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
.s6__messages {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
flex: 1;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,214 @@
|
|||
<template>
|
||||
<div class="terminal-panel">
|
||||
<!-- Mode switcher tabs -->
|
||||
<div class="terminal-panel__header">
|
||||
<div class="terminal-panel__tabs">
|
||||
<button
|
||||
:class="[
|
||||
'terminal-panel__tab',
|
||||
{ 'terminal-panel__tab--active': terminalStore.terminalMode === 'local' },
|
||||
]"
|
||||
@click="terminalStore.switchMode('local')"
|
||||
>
|
||||
<DesktopOutlined />
|
||||
<span>本地终端</span>
|
||||
</button>
|
||||
<button
|
||||
v-if="authStore.canUseServerTerminal()"
|
||||
:class="[
|
||||
'terminal-panel__tab',
|
||||
{ 'terminal-panel__tab--active': terminalStore.terminalMode === 'server' },
|
||||
]"
|
||||
@click="terminalStore.switchMode('server')"
|
||||
>
|
||||
<CloudServerOutlined />
|
||||
<span>服务端终端</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="terminal-panel__status">
|
||||
<span
|
||||
:class="[
|
||||
'terminal-panel__indicator',
|
||||
{ 'terminal-panel__indicator--connected': terminalStore.isWsConnected },
|
||||
]"
|
||||
>
|
||||
{{ terminalStore.isWsConnected ? '已连接' : '未连接' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Terminal emulator -->
|
||||
<div class="terminal-panel__body">
|
||||
<TerminalEmulator />
|
||||
</div>
|
||||
|
||||
<!-- Server terminal approval waiting overlay -->
|
||||
<div v-if="terminalStore.isWaitingForApproval" class="terminal-panel__approval-overlay">
|
||||
<div class="terminal-panel__approval-card">
|
||||
<div class="terminal-panel__approval-icon">
|
||||
<ClockCircleOutlined />
|
||||
</div>
|
||||
<div class="terminal-panel__approval-title">等待管理员审批</div>
|
||||
<div class="terminal-panel__approval-command">
|
||||
{{ terminalStore.pendingApproval?.command }}
|
||||
</div>
|
||||
<div class="terminal-panel__approval-reason">
|
||||
{{ terminalStore.pendingApproval?.reason }}
|
||||
</div>
|
||||
<div class="terminal-panel__approval-timer">
|
||||
超时倒计时: {{ terminalStore.pendingApproval?.expires_in }}s
|
||||
</div>
|
||||
<a-button size="small" @click="terminalStore.cancelApproval()">
|
||||
取消等待
|
||||
</a-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { DesktopOutlined, CloudServerOutlined, ClockCircleOutlined } from '@ant-design/icons-vue'
|
||||
import { Button as AButton } from 'ant-design-vue'
|
||||
import { useTerminalStore } from '@/stores/terminal'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import TerminalEmulator from './TerminalEmulator.vue'
|
||||
|
||||
const terminalStore = useTerminalStore()
|
||||
const authStore = useAuthStore()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.terminal-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.terminal-panel__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: var(--space-1) var(--space-2);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
background: var(--bg-secondary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.terminal-panel__tabs {
|
||||
display: flex;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.terminal-panel__tab {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
padding: var(--space-1) var(--space-3);
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: var(--font-sm);
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.terminal-panel__tab:hover {
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.terminal-panel__tab--active {
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-primary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.terminal-panel__status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.terminal-panel__indicator {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
font-size: var(--font-xs);
|
||||
color: var(--text-tertiary);
|
||||
padding: 2px var(--space-2);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.terminal-panel__indicator--connected {
|
||||
color: var(--success-color, #52c41a);
|
||||
}
|
||||
|
||||
.terminal-panel__body {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* ── Approval overlay ────────────────────────────────────────────── */
|
||||
|
||||
.terminal-panel__approval-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
backdrop-filter: blur(4px);
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.terminal-panel__approval-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-5) var(--space-6);
|
||||
background: var(--bg-primary);
|
||||
border-radius: var(--radius-lg);
|
||||
border: 1px solid var(--border-color);
|
||||
max-width: 400px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.terminal-panel__approval-icon {
|
||||
font-size: 32px;
|
||||
color: var(--warning-color, #faad14);
|
||||
}
|
||||
|
||||
.terminal-panel__approval-title {
|
||||
font-size: var(--font-md);
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.terminal-panel__approval-command {
|
||||
font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', Menlo, Consolas, monospace;
|
||||
font-size: var(--font-sm);
|
||||
color: var(--text-primary);
|
||||
background: var(--bg-tertiary);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border-radius: var(--radius-md);
|
||||
word-break: break-all;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.terminal-panel__approval-reason {
|
||||
font-size: var(--font-sm);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.terminal-panel__approval-timer {
|
||||
font-size: var(--font-xs);
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,447 @@
|
|||
<template>
|
||||
<div class="whitelist-manager">
|
||||
<a-tabs v-model:activeKey="activeTab">
|
||||
<!-- User whitelist -->
|
||||
<a-tab-pane key="user" tab="我的白名单">
|
||||
<div class="whitelist-manager__section">
|
||||
<div class="whitelist-manager__add-form">
|
||||
<a-input
|
||||
v-model:value="newUserPattern"
|
||||
placeholder="输入命令模式,如: git, npm, ls"
|
||||
@press-enter="addUserEntry"
|
||||
:disabled="userLoading"
|
||||
/>
|
||||
<a-button type="primary" @click="addUserEntry" :loading="userLoading">
|
||||
添加
|
||||
</a-button>
|
||||
</div>
|
||||
<a-table
|
||||
:data-source="userWhitelist"
|
||||
:columns="whitelistColumns"
|
||||
:loading="userLoading"
|
||||
:pagination="{ pageSize: 10 }"
|
||||
row-key="id"
|
||||
size="small"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'action'">
|
||||
<a-button
|
||||
type="link"
|
||||
danger
|
||||
size="small"
|
||||
@click="deleteUserEntry(record.id)"
|
||||
>
|
||||
删除
|
||||
</a-button>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</div>
|
||||
</a-tab-pane>
|
||||
|
||||
<!-- Global whitelist (admin only) -->
|
||||
<a-tab-pane
|
||||
v-if="authStore.isAdmin()"
|
||||
key="global"
|
||||
tab="全局白名单"
|
||||
>
|
||||
<div class="whitelist-manager__section">
|
||||
<a-alert
|
||||
message="全局白名单对所有用户生效,由管理员维护"
|
||||
type="info"
|
||||
show-icon
|
||||
style="margin-bottom: 12px"
|
||||
/>
|
||||
<div class="whitelist-manager__add-form">
|
||||
<a-input
|
||||
v-model:value="newGlobalPattern"
|
||||
placeholder="输入命令模式,如: docker, kubectl"
|
||||
@press-enter="addGlobalEntry"
|
||||
:disabled="globalLoading"
|
||||
/>
|
||||
<a-button type="primary" @click="addGlobalEntry" :loading="globalLoading">
|
||||
添加
|
||||
</a-button>
|
||||
</div>
|
||||
<a-table
|
||||
:data-source="globalWhitelist"
|
||||
:columns="whitelistColumns"
|
||||
:loading="globalLoading"
|
||||
:pagination="{ pageSize: 10 }"
|
||||
row-key="id"
|
||||
size="small"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'action'">
|
||||
<a-button
|
||||
type="link"
|
||||
danger
|
||||
size="small"
|
||||
@click="deleteGlobalEntry(record.id)"
|
||||
>
|
||||
删除
|
||||
</a-button>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</div>
|
||||
</a-tab-pane>
|
||||
|
||||
<!-- Blocklist (admin only) -->
|
||||
<a-tab-pane
|
||||
v-if="authStore.isAdmin()"
|
||||
key="blocklist"
|
||||
tab="黑名单"
|
||||
>
|
||||
<div class="whitelist-manager__section">
|
||||
<a-alert
|
||||
message="黑名单中的命令将被完全禁止执行,优先级最高"
|
||||
type="warning"
|
||||
show-icon
|
||||
style="margin-bottom: 12px"
|
||||
/>
|
||||
<div class="whitelist-manager__add-form">
|
||||
<a-input
|
||||
v-model:value="newBlockPattern"
|
||||
placeholder="输入要禁止的命令模式"
|
||||
style="flex: 2"
|
||||
@press-enter="addBlockEntry"
|
||||
:disabled="blockLoading"
|
||||
/>
|
||||
<a-input
|
||||
v-model:value="newBlockReason"
|
||||
placeholder="禁止原因"
|
||||
style="flex: 3"
|
||||
@press-enter="addBlockEntry"
|
||||
:disabled="blockLoading"
|
||||
/>
|
||||
<a-button type="primary" danger @click="addBlockEntry" :loading="blockLoading">
|
||||
添加
|
||||
</a-button>
|
||||
</div>
|
||||
<a-table
|
||||
:data-source="blocklist"
|
||||
:columns="blocklistColumns"
|
||||
:loading="blockLoading"
|
||||
:pagination="{ pageSize: 10 }"
|
||||
row-key="id"
|
||||
size="small"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'action'">
|
||||
<a-button
|
||||
type="link"
|
||||
danger
|
||||
size="small"
|
||||
@click="deleteBlockEntry(record.id)"
|
||||
>
|
||||
删除
|
||||
</a-button>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</div>
|
||||
</a-tab-pane>
|
||||
|
||||
<!-- Audit logs (admin only) -->
|
||||
<a-tab-pane
|
||||
v-if="authStore.isAdmin()"
|
||||
key="audit"
|
||||
tab="审计日志"
|
||||
>
|
||||
<div class="whitelist-manager__section">
|
||||
<div class="whitelist-manager__filters">
|
||||
<a-select
|
||||
v-model:value="auditFilter.terminal_mode"
|
||||
placeholder="终端模式"
|
||||
allow-clear
|
||||
style="width: 150px"
|
||||
@change="loadAuditLogs"
|
||||
>
|
||||
<a-select-option value="local">本地终端</a-select-option>
|
||||
<a-select-option value="server">服务端终端</a-select-option>
|
||||
</a-select>
|
||||
<a-input
|
||||
v-model:value="auditFilter.session_id"
|
||||
placeholder="会话ID筛选"
|
||||
allow-clear
|
||||
style="width: 250px"
|
||||
@press-enter="loadAuditLogs"
|
||||
/>
|
||||
<a-button @click="loadAuditLogs">刷新</a-button>
|
||||
</div>
|
||||
<a-table
|
||||
:data-source="auditLogs"
|
||||
:columns="auditColumns"
|
||||
:loading="auditLoading"
|
||||
:pagination="{ pageSize: 20 }"
|
||||
row-key="id"
|
||||
size="small"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'decision'">
|
||||
<a-tag :color="decisionColor(record.decision)">
|
||||
{{ record.decision }}
|
||||
</a-tag>
|
||||
</template>
|
||||
<template v-if="column.key === 'terminal_mode'">
|
||||
<a-tag :color="record.terminal_mode === 'server' ? 'purple' : 'blue'">
|
||||
{{ record.terminal_mode === 'server' ? '服务端' : '本地' }}
|
||||
</a-tag>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</div>
|
||||
</a-tab-pane>
|
||||
</a-tabs>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import {
|
||||
Tabs as ATabs,
|
||||
TabPane as ATabPane,
|
||||
Input as AInput,
|
||||
Button as AButton,
|
||||
Table as ATable,
|
||||
Alert as AAlert,
|
||||
Select as ASelect,
|
||||
SelectOption as ASelectOption,
|
||||
Tag as ATag,
|
||||
} from 'ant-design-vue'
|
||||
import { terminalApi } from '@/api/terminal'
|
||||
import type {
|
||||
IWhitelistEntry,
|
||||
IBlocklistEntry,
|
||||
IAuditLogEntry,
|
||||
} from '@/api/terminal'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const activeTab = ref('user')
|
||||
|
||||
// ── User whitelist ──────────────────────────────────────────────────
|
||||
const userWhitelist = ref<IWhitelistEntry[]>([])
|
||||
const newUserPattern = ref('')
|
||||
const userLoading = ref(false)
|
||||
|
||||
async function loadUserWhitelist(): Promise<void> {
|
||||
userLoading.value = true
|
||||
try {
|
||||
const resp = await terminalApi.listUserWhitelist()
|
||||
userWhitelist.value = resp.entries
|
||||
} catch (err) {
|
||||
message.error('加载用户白名单失败')
|
||||
} finally {
|
||||
userLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function addUserEntry(): Promise<void> {
|
||||
const pattern = newUserPattern.value.trim()
|
||||
if (!pattern) return
|
||||
userLoading.value = true
|
||||
try {
|
||||
await terminalApi.addUserWhitelist(pattern)
|
||||
newUserPattern.value = ''
|
||||
await loadUserWhitelist()
|
||||
message.success('已添加到白名单')
|
||||
} catch {
|
||||
message.error('添加失败')
|
||||
} finally {
|
||||
userLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteUserEntry(id: string): Promise<void> {
|
||||
try {
|
||||
await terminalApi.deleteUserWhitelist(id)
|
||||
await loadUserWhitelist()
|
||||
message.success('已删除')
|
||||
} catch {
|
||||
message.error('删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
// ── Global whitelist ────────────────────────────────────────────────
|
||||
const globalWhitelist = ref<IWhitelistEntry[]>([])
|
||||
const newGlobalPattern = ref('')
|
||||
const globalLoading = ref(false)
|
||||
|
||||
async function loadGlobalWhitelist(): Promise<void> {
|
||||
globalLoading.value = true
|
||||
try {
|
||||
const resp = await terminalApi.listGlobalWhitelist()
|
||||
globalWhitelist.value = resp.entries
|
||||
} catch {
|
||||
message.error('加载全局白名单失败')
|
||||
} finally {
|
||||
globalLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function addGlobalEntry(): Promise<void> {
|
||||
const pattern = newGlobalPattern.value.trim()
|
||||
if (!pattern) return
|
||||
globalLoading.value = true
|
||||
try {
|
||||
await terminalApi.addGlobalWhitelist(pattern)
|
||||
newGlobalPattern.value = ''
|
||||
await loadGlobalWhitelist()
|
||||
message.success('已添加到全局白名单')
|
||||
} catch {
|
||||
message.error('添加失败')
|
||||
} finally {
|
||||
globalLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteGlobalEntry(id: string): Promise<void> {
|
||||
try {
|
||||
await terminalApi.deleteGlobalWhitelist(id)
|
||||
await loadGlobalWhitelist()
|
||||
message.success('已删除')
|
||||
} catch {
|
||||
message.error('删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
// ── Blocklist ───────────────────────────────────────────────────────
|
||||
const blocklist = ref<IBlocklistEntry[]>([])
|
||||
const newBlockPattern = ref('')
|
||||
const newBlockReason = ref('')
|
||||
const blockLoading = ref(false)
|
||||
|
||||
async function loadBlocklist(): Promise<void> {
|
||||
blockLoading.value = true
|
||||
try {
|
||||
const resp = await terminalApi.listBlocklist()
|
||||
blocklist.value = resp.entries
|
||||
} catch {
|
||||
message.error('加载黑名单失败')
|
||||
} finally {
|
||||
blockLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function addBlockEntry(): Promise<void> {
|
||||
const pattern = newBlockPattern.value.trim()
|
||||
if (!pattern) return
|
||||
blockLoading.value = true
|
||||
try {
|
||||
await terminalApi.addBlocklist(pattern, newBlockReason.value.trim() || '管理员禁止')
|
||||
newBlockPattern.value = ''
|
||||
newBlockReason.value = ''
|
||||
await loadBlocklist()
|
||||
message.success('已添加到黑名单')
|
||||
} catch {
|
||||
message.error('添加失败')
|
||||
} finally {
|
||||
blockLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteBlockEntry(id: string): Promise<void> {
|
||||
try {
|
||||
await terminalApi.deleteBlocklist(id)
|
||||
await loadBlocklist()
|
||||
message.success('已删除')
|
||||
} catch {
|
||||
message.error('删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
// ── Audit logs ──────────────────────────────────────────────────────
|
||||
const auditLogs = ref<IAuditLogEntry[]>([])
|
||||
const auditLoading = ref(false)
|
||||
const auditFilter = ref<{ terminal_mode?: string; session_id?: string }>({})
|
||||
|
||||
async function loadAuditLogs(): Promise<void> {
|
||||
auditLoading.value = true
|
||||
try {
|
||||
const resp = await terminalApi.listAuditLogs({
|
||||
terminal_mode: auditFilter.value.terminal_mode || undefined,
|
||||
session_id: auditFilter.value.session_id || undefined,
|
||||
limit: 100,
|
||||
})
|
||||
auditLogs.value = resp.entries
|
||||
} catch {
|
||||
message.error('加载审计日志失败')
|
||||
} finally {
|
||||
auditLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ── Table columns ───────────────────────────────────────────────────
|
||||
const whitelistColumns = [
|
||||
{ title: '命令模式', dataIndex: 'command_pattern', key: 'command_pattern' },
|
||||
{ title: '创建时间', dataIndex: 'created_at', key: 'created_at', width: 200 },
|
||||
{ title: '操作', key: 'action', width: 80 },
|
||||
]
|
||||
|
||||
const blocklistColumns = [
|
||||
{ title: '命令模式', dataIndex: 'command_pattern', key: 'command_pattern' },
|
||||
{ title: '原因', dataIndex: 'reason', key: 'reason' },
|
||||
{ title: '创建时间', dataIndex: 'created_at', key: 'created_at', width: 200 },
|
||||
{ title: '操作', key: 'action', width: 80 },
|
||||
]
|
||||
|
||||
const auditColumns = [
|
||||
{ title: '时间', dataIndex: 'created_at', key: 'created_at', width: 200 },
|
||||
{ title: '用户', dataIndex: 'user_id', key: 'user_id', width: 120 },
|
||||
{ title: '命令', dataIndex: 'command', key: 'command', ellipsis: true },
|
||||
{ title: '决策', key: 'decision', width: 100 },
|
||||
{ title: '模式', key: 'terminal_mode', width: 80 },
|
||||
{ title: '退出码', dataIndex: 'exit_code', key: 'exit_code', width: 80 },
|
||||
]
|
||||
|
||||
function decisionColor(decision: string): string {
|
||||
const colors: Record<string, string> = {
|
||||
executed: 'green',
|
||||
confirmed: 'blue',
|
||||
rejected: 'orange',
|
||||
blocked: 'red',
|
||||
denied: 'red',
|
||||
expired: 'default',
|
||||
}
|
||||
return colors[decision] || 'default'
|
||||
}
|
||||
|
||||
// ── Init ────────────────────────────────────────────────────────────
|
||||
onMounted(() => {
|
||||
loadUserWhitelist()
|
||||
if (authStore.isAdmin()) {
|
||||
loadGlobalWhitelist()
|
||||
loadBlocklist()
|
||||
loadAuditLogs()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.whitelist-manager {
|
||||
padding: var(--space-3);
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.whitelist-manager__section {
|
||||
padding-top: var(--space-3);
|
||||
}
|
||||
|
||||
.whitelist-manager__add-form {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
margin-bottom: var(--space-3);
|
||||
}
|
||||
|
||||
.whitelist-manager__filters {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
margin-bottom: var(--space-3);
|
||||
align-items: center;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,7 +1,16 @@
|
|||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import type { RouteRecordRaw } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const routes: RouteRecordRaw[] = [
|
||||
// Login (public)
|
||||
{
|
||||
path: '/login',
|
||||
name: 'login',
|
||||
component: () => import('@/views/LoginView.vue'),
|
||||
meta: { title: '登录', public: true },
|
||||
},
|
||||
|
||||
// Agent-First 左对话+右双栏布局
|
||||
{
|
||||
path: '/agent',
|
||||
|
|
@ -31,6 +40,12 @@ const routes: RouteRecordRaw[] = [
|
|||
meta: { title: '监控', panel: 'br', tab: 'monitor' },
|
||||
component: () => import('@/views/EvolutionView.vue'),
|
||||
},
|
||||
{
|
||||
path: 'preview',
|
||||
name: 'agent-preview',
|
||||
meta: { title: 'VI Mockup Preview', panel: 'left' },
|
||||
component: () => import('@/views/ChatPreviewView.vue'),
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
|
|
@ -136,12 +151,41 @@ const router = createRouter({
|
|||
routes,
|
||||
})
|
||||
|
||||
/**
|
||||
* Global route guard.
|
||||
*
|
||||
* - Public routes (meta.public === true) are always allowed.
|
||||
* - Non-public routes require an authenticated user; unauthenticated users
|
||||
* are redirected to /login with a ``redirect`` query param preserving
|
||||
* the original target.
|
||||
*
|
||||
* Note: the guard reads from the auth store, which hydrates from
|
||||
* localStorage on construction — so a page reload with a valid token
|
||||
* does not force a re-login.
|
||||
*/
|
||||
router.beforeEach((to, _from, next) => {
|
||||
const title = to.meta.title as string | undefined
|
||||
if (title) {
|
||||
document.title = `${title} - Fischer AgentKit`
|
||||
}
|
||||
next()
|
||||
|
||||
const isPublic = to.meta.public === true
|
||||
if (isPublic) {
|
||||
next()
|
||||
return
|
||||
}
|
||||
|
||||
const authStore = useAuthStore()
|
||||
if (authStore.isAuthenticated) {
|
||||
next()
|
||||
return
|
||||
}
|
||||
|
||||
// Preserve the original target so we can redirect after login
|
||||
next({
|
||||
name: 'login',
|
||||
query: { redirect: to.fullPath },
|
||||
})
|
||||
})
|
||||
|
||||
export default router
|
||||
|
|
|
|||
|
|
@ -0,0 +1,257 @@
|
|||
/**
|
||||
* Auth store — manages JWT, current user, and permission checks.
|
||||
*
|
||||
* Responsibilities:
|
||||
* - Persist access/refresh tokens in localStorage (browser) so they survive
|
||||
* page reloads. In Tauri mode, localStorage is still available.
|
||||
* - Expose ``getAccessToken()`` for the API client to attach as a Bearer
|
||||
* header, and ``getRefreshToken()`` for the refresh-on-401 flow.
|
||||
* - Provide ``hasPermission()`` / ``canUseTerminal()`` helpers used by route
|
||||
* guards and component visibility flags.
|
||||
*
|
||||
* Token lifecycle:
|
||||
* - ``login()`` stores both tokens + user.
|
||||
* - On 401 from the API, ``BaseApiClient`` calls ``refreshIfPossible()``;
|
||||
* if refresh succeeds, the original request is retried; if refresh fails,
|
||||
* ``logoutLocal()`` clears state and the router redirects to /login.
|
||||
* - ``logout()`` calls the server to revoke the refresh token, then clears
|
||||
* local state regardless of the server response.
|
||||
*/
|
||||
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import { authApi, type IAuthUser, type ITokenPair } from '@/api/auth'
|
||||
|
||||
const ACCESS_TOKEN_KEY = 'agentkit.access_token'
|
||||
const REFRESH_TOKEN_KEY = 'agentkit.refresh_token'
|
||||
const USER_KEY = 'agentkit.user'
|
||||
|
||||
/** Permission bits — must mirror server-side ``Permission`` enum (U5). */
|
||||
export type Permission =
|
||||
| 'CHAT'
|
||||
| 'KB_QUERY'
|
||||
| 'KB_WRITE'
|
||||
| 'WORKFLOW_EXECUTE'
|
||||
| 'TERMINAL_LOCAL_USE'
|
||||
| 'TERMINAL_SERVER_USE'
|
||||
| 'TERMINAL_WHITELIST_MANAGE'
|
||||
| 'USER_MANAGE'
|
||||
| 'SYSTEM_CONFIG'
|
||||
|
||||
/** Role → permission mapping (mirrors server-side ROLE_PERMISSIONS). */
|
||||
const ROLE_PERMISSIONS: Record<string, Permission[]> = {
|
||||
member: ['CHAT', 'KB_QUERY', 'WORKFLOW_EXECUTE'],
|
||||
operator: [
|
||||
'CHAT',
|
||||
'KB_QUERY',
|
||||
'KB_WRITE',
|
||||
'WORKFLOW_EXECUTE',
|
||||
'TERMINAL_LOCAL_USE',
|
||||
'TERMINAL_WHITELIST_MANAGE',
|
||||
],
|
||||
admin: [
|
||||
'CHAT',
|
||||
'KB_QUERY',
|
||||
'KB_WRITE',
|
||||
'WORKFLOW_EXECUTE',
|
||||
'TERMINAL_LOCAL_USE',
|
||||
'TERMINAL_SERVER_USE',
|
||||
'TERMINAL_WHITELIST_MANAGE',
|
||||
'USER_MANAGE',
|
||||
'SYSTEM_CONFIG',
|
||||
],
|
||||
}
|
||||
|
||||
function readStored(key: string): string | null {
|
||||
try {
|
||||
return localStorage.getItem(key)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function writeStored(key: string, value: string): void {
|
||||
try {
|
||||
localStorage.setItem(key, value)
|
||||
} catch {
|
||||
/* localStorage may be unavailable in some sandboxed contexts */
|
||||
}
|
||||
}
|
||||
|
||||
function removeStored(key: string): void {
|
||||
try {
|
||||
localStorage.removeItem(key)
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
function readStoredUser(): IAuthUser | null {
|
||||
const raw = readStored(USER_KEY)
|
||||
if (!raw) return null
|
||||
try {
|
||||
return JSON.parse(raw) as IAuthUser
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export const useAuthStore = defineStore('auth', () => {
|
||||
// --- State ---
|
||||
const accessToken = ref<string | null>(readStored(ACCESS_TOKEN_KEY))
|
||||
const refreshToken = ref<string | null>(readStored(REFRESH_TOKEN_KEY))
|
||||
const user = ref<IAuthUser | null>(readStoredUser())
|
||||
/** True while a login or refresh request is in-flight. */
|
||||
const isLoading = ref(false)
|
||||
/** Last error message from login/refresh (cleared on success). */
|
||||
const error = ref<string | null>(null)
|
||||
/**
|
||||
* Set to true when a refresh attempt fails — prevents infinite retry
|
||||
* loops where every concurrent 401 triggers another refresh.
|
||||
*/
|
||||
let _refreshFailed = false
|
||||
|
||||
// --- Getters ---
|
||||
const isAuthenticated = computed(() => !!accessToken.value && !!user.value)
|
||||
const role = computed<string | null>(() => user.value?.role ?? null)
|
||||
|
||||
/** Permissions for the current role (empty when not authenticated). */
|
||||
const permissions = computed<Permission[]>(() => {
|
||||
if (!role.value) return []
|
||||
return ROLE_PERMISSIONS[role.value] ?? []
|
||||
})
|
||||
|
||||
// --- Mutators ---
|
||||
function _persist(tokens: ITokenPair): void {
|
||||
accessToken.value = tokens.access_token
|
||||
refreshToken.value = tokens.refresh_token
|
||||
user.value = tokens.user
|
||||
writeStored(ACCESS_TOKEN_KEY, tokens.access_token)
|
||||
writeStored(REFRESH_TOKEN_KEY, tokens.refresh_token)
|
||||
writeStored(USER_KEY, JSON.stringify(tokens.user))
|
||||
_refreshFailed = false
|
||||
}
|
||||
|
||||
function _clear(): void {
|
||||
accessToken.value = null
|
||||
refreshToken.value = null
|
||||
user.value = null
|
||||
removeStored(ACCESS_TOKEN_KEY)
|
||||
removeStored(REFRESH_TOKEN_KEY)
|
||||
removeStored(USER_KEY)
|
||||
_refreshFailed = false
|
||||
}
|
||||
|
||||
// --- Actions ---
|
||||
async function login(username: string, password: string): Promise<void> {
|
||||
isLoading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const tokens = await authApi.login(username, password)
|
||||
_persist(tokens)
|
||||
} catch (err) {
|
||||
const msg = _extractErrorMessage(err, '登录失败')
|
||||
error.value = msg
|
||||
throw err
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt to refresh the access token using the stored refresh token.
|
||||
* Returns the new access token on success, or ``null`` if no refresh
|
||||
* token is available or a previous refresh already failed this session.
|
||||
*
|
||||
* The API client calls this from its 401 handler. Concurrent callers
|
||||
* will all see the same outcome because ``_refreshFailed`` is sticky
|
||||
* until the next successful login.
|
||||
*/
|
||||
async function refreshIfPossible(): Promise<string | null> {
|
||||
if (_refreshFailed) return null
|
||||
if (!refreshToken.value) return null
|
||||
try {
|
||||
const tokens = await authApi.refresh(refreshToken.value)
|
||||
_persist(tokens)
|
||||
return tokens.access_token
|
||||
} catch (err) {
|
||||
_refreshFailed = true
|
||||
error.value = _extractErrorMessage(err, '会话已过期,请重新登录')
|
||||
_clear()
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Server-side logout: revoke the refresh token, then clear local state.
|
||||
* Safe to call even if the server is unreachable — local state is always
|
||||
* cleared.
|
||||
*/
|
||||
async function logout(): Promise<void> {
|
||||
const token = refreshToken.value
|
||||
if (token) {
|
||||
try {
|
||||
await authApi.logout(token)
|
||||
} catch {
|
||||
/* server may be unreachable; still clear local state */
|
||||
}
|
||||
}
|
||||
_clear()
|
||||
}
|
||||
|
||||
/** Clear local auth state without calling the server (used on 401 fallback). */
|
||||
function logoutLocal(): void {
|
||||
_clear()
|
||||
}
|
||||
|
||||
// --- Permission helpers ---
|
||||
function hasPermission(perm: Permission): boolean {
|
||||
return permissions.value.includes(perm)
|
||||
}
|
||||
|
||||
function canUseLocalTerminal(): boolean {
|
||||
return !!user.value?.is_terminal_authorized && hasPermission('TERMINAL_LOCAL_USE')
|
||||
}
|
||||
|
||||
function canUseServerTerminal(): boolean {
|
||||
return (
|
||||
!!user.value?.is_server_terminal_authorized &&
|
||||
hasPermission('TERMINAL_SERVER_USE')
|
||||
)
|
||||
}
|
||||
|
||||
function isAdmin(): boolean {
|
||||
return role.value === 'admin'
|
||||
}
|
||||
|
||||
return {
|
||||
// state
|
||||
accessToken,
|
||||
refreshToken,
|
||||
user,
|
||||
isLoading,
|
||||
error,
|
||||
// getters
|
||||
isAuthenticated,
|
||||
role,
|
||||
permissions,
|
||||
// actions
|
||||
login,
|
||||
refreshIfPossible,
|
||||
logout,
|
||||
logoutLocal,
|
||||
// permission helpers
|
||||
hasPermission,
|
||||
canUseLocalTerminal,
|
||||
canUseServerTerminal,
|
||||
isAdmin,
|
||||
}
|
||||
})
|
||||
|
||||
function _extractErrorMessage(err: unknown, fallback: string): string {
|
||||
if (err && typeof err === 'object' && 'message' in err) {
|
||||
const msg = (err as { message?: unknown }).message
|
||||
if (typeof msg === 'string' && msg) return msg
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
|
@ -31,7 +31,7 @@ export const useChatStore = defineStore('chat', () => {
|
|||
// Board Meeting state (transient, only active during a board discussion)
|
||||
const boardState = ref<{
|
||||
topic: string
|
||||
experts: Array<{ name: string; avatar: string; color: string; is_moderator: boolean }>
|
||||
experts: Array<{ name: string; avatar: string; color: string; is_moderator: boolean; persona: string }>
|
||||
max_rounds: number
|
||||
current_round: number
|
||||
status: 'discussing' | 'concluding' | 'completed' | 'dissolved'
|
||||
|
|
@ -56,7 +56,7 @@ export const useChatStore = defineStore('chat', () => {
|
|||
const data = await apiClient.getConversations()
|
||||
// Normalize server response: backend returns {id, created_at, updated_at, message_count}
|
||||
// but frontend IConversation expects {id, title, messages, created_at, updated_at}
|
||||
conversations.value = data.map((conv: any) => ({
|
||||
conversations.value = data.map((conv: IConversation) => ({
|
||||
id: conv.id,
|
||||
title: conv.title || '对话',
|
||||
messages: Array.isArray(conv.messages) ? conv.messages : [],
|
||||
|
|
@ -276,7 +276,7 @@ export const useChatStore = defineStore('chat', () => {
|
|||
|
||||
socket.onmessage = (event: MessageEvent) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data as string) as Record<string, any>
|
||||
const data = JSON.parse(event.data as string) as Record<string, unknown>
|
||||
console.log('[Chat WS] Received:', data.type, data)
|
||||
handleWsMessage(data)
|
||||
} catch (error) {
|
||||
|
|
@ -403,6 +403,8 @@ export const useChatStore = defineStore('chat', () => {
|
|||
return _teamStore
|
||||
}
|
||||
|
||||
// TODO: refactor to WsServerMessage union to eliminate `any`.
|
||||
// This function predates the current VI redesign and touches many legacy branches.
|
||||
function handleWsMessage(data: Record<string, any>): void {
|
||||
// Backend sends nested data: {type, data: {...}}
|
||||
// Flatten for easier access
|
||||
|
|
@ -558,9 +560,22 @@ export const useChatStore = defineStore('chat', () => {
|
|||
.find((m) => m.role === 'assistant')
|
||||
if (lastAssistantMsg) {
|
||||
updateMessage(conversationId, lastAssistantMsg.id, {
|
||||
content: `错误: ${payload.message || '未知错误'}`,
|
||||
status: 'completed',
|
||||
message_type: 'error',
|
||||
status: 'error',
|
||||
error_detail: payload.message || '未知错误',
|
||||
content: lastAssistantMsg.content || '',
|
||||
})
|
||||
} else {
|
||||
const errorMsg: IChatMessage = {
|
||||
id: generateId(),
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
timestamp: new Date().toISOString(),
|
||||
status: 'error',
|
||||
message_type: 'error',
|
||||
error_detail: payload.message || '未知错误',
|
||||
}
|
||||
appendMessage(conversationId, errorMsg)
|
||||
}
|
||||
isLoading.value = false
|
||||
streamingSteps.value = []
|
||||
|
|
@ -632,6 +647,29 @@ export const useChatStore = defineStore('chat', () => {
|
|||
if (teamStore) {
|
||||
teamStore.updatePhases(payload.plan_phases)
|
||||
}
|
||||
const conversationId = currentConversationId.value
|
||||
if (!conversationId) break
|
||||
const conv = conversations.value.find((c) => c.id === conversationId)
|
||||
if (!conv) break
|
||||
const existingPlanMsg = [...conv.messages]
|
||||
.reverse()
|
||||
.find((m) => m.message_type === 'plan_update')
|
||||
if (existingPlanMsg) {
|
||||
updateMessage(conversationId, existingPlanMsg.id, {
|
||||
plan_phases: payload.plan_phases,
|
||||
})
|
||||
} else {
|
||||
const planMsg: IChatMessage = {
|
||||
id: generateId(),
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
timestamp: new Date().toISOString(),
|
||||
status: 'completed',
|
||||
message_type: 'plan_update',
|
||||
plan_phases: payload.plan_phases,
|
||||
}
|
||||
appendMessage(conversationId, planMsg)
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
|
|
@ -700,6 +738,7 @@ export const useChatStore = defineStore('chat', () => {
|
|||
avatar: e.avatar,
|
||||
color: e.color,
|
||||
is_moderator: e.is_moderator,
|
||||
persona: e.persona,
|
||||
})),
|
||||
max_rounds: data.max_rounds,
|
||||
current_round: 0,
|
||||
|
|
@ -708,18 +747,18 @@ export const useChatStore = defineStore('chat', () => {
|
|||
streamingSteps.value.push(
|
||||
`私董会已开启: 主题「${data.topic}」, ${data.experts.length} 位专家, 最多 ${data.max_rounds} 轮`
|
||||
)
|
||||
// Push a system-style message to indicate board start
|
||||
// Push a structured banner message so the renderer can show BoardBannerCard
|
||||
const conversationId = currentConversationId.value
|
||||
if (conversationId) {
|
||||
const startMsg: IChatMessage = {
|
||||
id: generateId(),
|
||||
role: 'assistant',
|
||||
content: `🏛️ **私董会开始**\n\n**主题**: ${data.topic}\n**专家**: ${data.experts
|
||||
.map((e) => `${e.avatar} ${e.name}${e.is_moderator ? ' (主持人)' : ''}`)
|
||||
.join(', ')}\n**最大轮次**: ${data.max_rounds}`,
|
||||
content: `🏛️ 私董会开始:${data.topic}`,
|
||||
timestamp: new Date().toISOString(),
|
||||
status: 'completed',
|
||||
message_type: 'milestone',
|
||||
message_type: 'board_started',
|
||||
board_started: data,
|
||||
board_round: 0,
|
||||
}
|
||||
appendMessage(conversationId, startMsg)
|
||||
}
|
||||
|
|
@ -789,9 +828,21 @@ export const useChatStore = defineStore('chat', () => {
|
|||
streamingSteps.value.push(
|
||||
`私董会结束: ${data.total_rounds} 轮讨论${data.error ? ' (异常)' : ''}`
|
||||
)
|
||||
// The final_answer event will carry the formatted conclusion,
|
||||
// so we don't need to add a separate message here.
|
||||
// The conclusion is already persisted by the backend.
|
||||
// Push a structured conclusion message so the renderer can show BoardConclusionCard
|
||||
const conversationId = currentConversationId.value
|
||||
if (conversationId) {
|
||||
const conclusionMsg: IChatMessage = {
|
||||
id: generateId(),
|
||||
role: 'assistant',
|
||||
content: data.summary || '私董会已结束',
|
||||
timestamp: new Date().toISOString(),
|
||||
status: 'completed',
|
||||
message_type: 'board_conclusion',
|
||||
board_conclusion: data,
|
||||
board_round: data.total_rounds,
|
||||
}
|
||||
appendMessage(conversationId, conclusionMsg)
|
||||
}
|
||||
// Clear board state after a short delay to allow UI to update
|
||||
setTimeout(() => {
|
||||
boardState.value = null
|
||||
|
|
@ -825,6 +876,22 @@ export const useChatStore = defineStore('chat', () => {
|
|||
}
|
||||
}
|
||||
|
||||
/** Resend the last user message in the current conversation */
|
||||
async function resendLastUserMessage(): Promise<void> {
|
||||
const conversationId = currentConversationId.value
|
||||
if (!conversationId) return
|
||||
if (isLoading.value) return
|
||||
const conv = conversations.value.find((c) => c.id === conversationId)
|
||||
if (!conv) return
|
||||
const lastUserMsg = [...conv.messages]
|
||||
.reverse()
|
||||
.find((m) => m.role === 'user')
|
||||
if (!lastUserMsg) return
|
||||
const content = lastUserMsg.content.trim()
|
||||
if (!content) return
|
||||
await sendWsMessage(content)
|
||||
}
|
||||
|
||||
return {
|
||||
// State
|
||||
conversations,
|
||||
|
|
@ -843,6 +910,7 @@ export const useChatStore = defineStore('chat', () => {
|
|||
createConversation,
|
||||
sendMessage,
|
||||
sendWsMessage,
|
||||
resendLastUserMessage,
|
||||
connectWebSocket,
|
||||
disconnectWebSocket,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,8 +9,18 @@ export interface IConfirmationRequest {
|
|||
reason: string
|
||||
}
|
||||
|
||||
export interface IApprovalRequest {
|
||||
approval_id: string
|
||||
command: string
|
||||
reason: string
|
||||
expires_in: number
|
||||
}
|
||||
|
||||
export type TerminalMode = 'local' | 'server'
|
||||
|
||||
export const useTerminalStore = defineStore('terminal', () => {
|
||||
// --- State ---
|
||||
const terminalMode = ref<TerminalMode>('local')
|
||||
const sessionId = ref<string | null>(null)
|
||||
const cwd = ref<string>('~')
|
||||
const output = ref<string[]>([])
|
||||
|
|
@ -20,6 +30,7 @@ export const useTerminalStore = defineStore('terminal', () => {
|
|||
const ws = ref<WebSocket | null>(null)
|
||||
const error = ref<string | null>(null)
|
||||
const pendingConfirmation = ref<IConfirmationRequest | null>(null)
|
||||
const pendingApproval = ref<IApprovalRequest | null>(null)
|
||||
let wsIntentionallyClosed = false
|
||||
let wsReconnectTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let _pingTimer: ReturnType<typeof setInterval> | null = null
|
||||
|
|
@ -29,6 +40,9 @@ export const useTerminalStore = defineStore('terminal', () => {
|
|||
return history.value.slice(-50)
|
||||
})
|
||||
|
||||
/** True when waiting for admin approval (server mode only). */
|
||||
const isWaitingForApproval = computed(() => pendingApproval.value !== null)
|
||||
|
||||
// --- Actions ---
|
||||
function setSessionId(id: string): void {
|
||||
sessionId.value = id
|
||||
|
|
@ -61,18 +75,41 @@ export const useTerminalStore = defineStore('terminal', () => {
|
|||
history.value = []
|
||||
}
|
||||
|
||||
/**
|
||||
* Switch terminal mode (local ↔ server).
|
||||
*
|
||||
* Disconnects the current WebSocket and reconnects to the appropriate
|
||||
* endpoint. In server mode, the JWT is included as a query parameter.
|
||||
*/
|
||||
function switchMode(mode: TerminalMode): void {
|
||||
if (terminalMode.value === mode) return
|
||||
terminalMode.value = mode
|
||||
pendingConfirmation.value = null
|
||||
pendingApproval.value = null
|
||||
disconnectWebSocket()
|
||||
// Clear output when switching modes for a clean slate
|
||||
output.value = []
|
||||
sessionId.value = null
|
||||
cwd.value = mode === 'server' ? '~' : '~'
|
||||
connectWebSocket()
|
||||
}
|
||||
|
||||
function connectWebSocket(): void {
|
||||
if (ws.value && ws.value.readyState === WebSocket.OPEN) {
|
||||
return
|
||||
}
|
||||
|
||||
wsIntentionallyClosed = false
|
||||
const url = terminalApi.createTerminalWsUrl(sessionId.value || undefined)
|
||||
const url =
|
||||
terminalMode.value === 'server'
|
||||
? terminalApi.createServerTerminalWsUrl(sessionId.value || undefined)
|
||||
: terminalApi.createTerminalWsUrl(sessionId.value || undefined)
|
||||
const socket = new WebSocket(url)
|
||||
|
||||
socket.onopen = () => {
|
||||
isWsConnected.value = true
|
||||
appendOutput('\x1b[32m已连接到终端服务\x1b[0m')
|
||||
const modeLabel = terminalMode.value === 'server' ? '服务端终端' : '本地终端'
|
||||
appendOutput(`\x1b[32m已连接到${modeLabel}\x1b[0m`)
|
||||
}
|
||||
|
||||
socket.onmessage = (event: MessageEvent) => {
|
||||
|
|
@ -149,6 +186,22 @@ export const useTerminalStore = defineStore('terminal', () => {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel a pending approval (server mode only).
|
||||
*
|
||||
* Notifies the server that the user no longer wants to wait for
|
||||
* admin approval.
|
||||
*/
|
||||
function cancelApproval(): void {
|
||||
if (pendingApproval.value && ws.value && ws.value.readyState === WebSocket.OPEN) {
|
||||
ws.value.send(JSON.stringify({
|
||||
type: 'cancel_approval',
|
||||
approval_id: pendingApproval.value.approval_id,
|
||||
}))
|
||||
pendingApproval.value = null
|
||||
}
|
||||
}
|
||||
|
||||
function sendInput(data: string): void {
|
||||
if (ws.value && ws.value.readyState === WebSocket.OPEN) {
|
||||
ws.value.send(JSON.stringify({ type: 'input', data }))
|
||||
|
|
@ -188,6 +241,7 @@ export const useTerminalStore = defineStore('terminal', () => {
|
|||
}
|
||||
isExecuting.value = false
|
||||
break
|
||||
// ── Local terminal: user confirmation ──────────────────────
|
||||
case 'confirmation_required':
|
||||
pendingConfirmation.value = {
|
||||
confirmation_id: data.confirmation_id as string,
|
||||
|
|
@ -197,6 +251,31 @@ export const useTerminalStore = defineStore('terminal', () => {
|
|||
appendOutput(`\x1b[33m⚠ 需要确认: ${data.command}\x1b[0m`)
|
||||
appendOutput(`\x1b[33m 原因: ${data.reason}\x1b[0m`)
|
||||
break
|
||||
// ── Server terminal: admin approval ────────────────────────
|
||||
case 'approval_required':
|
||||
pendingApproval.value = {
|
||||
approval_id: data.approval_id as string,
|
||||
command: data.command as string,
|
||||
reason: (data.reason as string) || '需要管理员审批',
|
||||
expires_in: (data.expires_in as number) || 300,
|
||||
}
|
||||
appendOutput(`\x1b[33m⏳ 等待管理员审批: ${data.command}\x1b[0m`)
|
||||
appendOutput(`\x1b[33m 原因: ${data.reason}\x1b[0m`)
|
||||
break
|
||||
case 'approval_approved':
|
||||
appendOutput(`\x1b[32m✓ 审批已通过,正在执行...\x1b[0m`)
|
||||
pendingApproval.value = null
|
||||
break
|
||||
case 'approval_rejected':
|
||||
appendOutput(`\x1b[31m✗ 审批被拒绝: ${data.reason || ''}\x1b[0m`)
|
||||
pendingApproval.value = null
|
||||
isExecuting.value = false
|
||||
break
|
||||
case 'approval_expired':
|
||||
appendOutput(`\x1b[31m✗ 审批超时未响应\x1b[0m`)
|
||||
pendingApproval.value = null
|
||||
isExecuting.value = false
|
||||
break
|
||||
case 'error':
|
||||
appendOutput(`\x1b[31m错误: ${data.message}\x1b[0m`)
|
||||
isExecuting.value = false
|
||||
|
|
@ -206,6 +285,7 @@ export const useTerminalStore = defineStore('terminal', () => {
|
|||
|
||||
return {
|
||||
// State
|
||||
terminalMode,
|
||||
sessionId,
|
||||
cwd,
|
||||
output,
|
||||
|
|
@ -215,8 +295,10 @@ export const useTerminalStore = defineStore('terminal', () => {
|
|||
ws,
|
||||
error,
|
||||
pendingConfirmation,
|
||||
pendingApproval,
|
||||
// Getters
|
||||
recentCommands,
|
||||
isWaitingForApproval,
|
||||
// Actions
|
||||
setSessionId,
|
||||
setCwd,
|
||||
|
|
@ -224,10 +306,12 @@ export const useTerminalStore = defineStore('terminal', () => {
|
|||
clearOutput,
|
||||
addHistory,
|
||||
clearHistory,
|
||||
switchMode,
|
||||
connectWebSocket,
|
||||
disconnectWebSocket,
|
||||
sendCommand,
|
||||
confirmCommand,
|
||||
cancelApproval,
|
||||
sendInput,
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -16,9 +16,10 @@ function readToken(varName: string, fallback: string): string {
|
|||
|
||||
export const themeConfig: ThemeConfig = {
|
||||
token: {
|
||||
// Brand — read from CSS variables
|
||||
colorPrimary: readToken('--color-primary', '#6366f1'),
|
||||
colorInfo: readToken('--color-primary', '#6366f1'),
|
||||
// 2026-06-18 VI 重梳: 主色改为近黑(#1a1a1a),accent 仅做语义信号
|
||||
colorPrimary: readToken('--color-primary', '#1a1a1a'),
|
||||
colorInfo: readToken('--accent-team', '#3b82f6'),
|
||||
colorLink: readToken('--accent-team', '#3b82f6'),
|
||||
|
||||
// Semantic
|
||||
colorSuccess: readToken('--color-success', '#22c55e'),
|
||||
|
|
@ -46,7 +47,7 @@ export const themeConfig: ThemeConfig = {
|
|||
fontSizeLG: 16,
|
||||
fontSizeXL: 20,
|
||||
|
||||
// Radius — Notion-style slightly larger
|
||||
// Radius — 略大,Notion 风格
|
||||
borderRadius: 8,
|
||||
borderRadiusSM: 6,
|
||||
borderRadiusLG: 12,
|
||||
|
|
@ -59,7 +60,7 @@ export const themeConfig: ThemeConfig = {
|
|||
marginLG: 24,
|
||||
marginXL: 32,
|
||||
|
||||
// Shadow — Notion-style softer shadows
|
||||
// Shadow — Notion 风格
|
||||
boxShadow: '0 1px 2px rgba(0, 0, 0, 0.04)',
|
||||
boxShadowSecondary: '0 2px 8px rgba(0, 0, 0, 0.06), 0 1px 2px rgba(0, 0, 0, 0.04)',
|
||||
|
||||
|
|
@ -70,32 +71,39 @@ export const themeConfig: ThemeConfig = {
|
|||
},
|
||||
components: {
|
||||
Menu: {
|
||||
itemSelectedBg: readToken('--color-primary-light', '#eef2ff'),
|
||||
itemSelectedColor: readToken('--color-primary', '#6366f1'),
|
||||
itemHoverBg: '#f5f3ff',
|
||||
itemHoverColor: readToken('--color-primary', '#6366f1'),
|
||||
itemSelectedBg: readToken('--color-primary-light', '#f3f4f6'),
|
||||
itemSelectedColor: readToken('--text-primary', '#1a1a1a'),
|
||||
itemHoverBg: readToken('--bg-tertiary', '#f7f7f5'),
|
||||
itemHoverColor: readToken('--text-primary', '#1a1a1a'),
|
||||
itemColor: readToken('--text-secondary', '#4a4a4a'),
|
||||
} as Record<string, unknown>,
|
||||
Tabs: {
|
||||
itemSelectedColor: readToken('--color-primary', '#6366f1'),
|
||||
itemHoverColor: readToken('--color-primary-hover', '#4f46e5'),
|
||||
itemSelectedColor: readToken('--accent-team', '#3b82f6'),
|
||||
itemHoverColor: readToken('--accent-team-hover', '#2563eb'),
|
||||
} as Record<string, unknown>,
|
||||
Select: {
|
||||
colorPrimary: readToken('--color-primary', '#6366f1'),
|
||||
colorPrimaryHover: readToken('--color-primary-hover', '#4f46e5'),
|
||||
colorPrimary: readToken('--accent-team', '#3b82f6'),
|
||||
colorPrimaryHover: readToken('--accent-team-hover', '#2563eb'),
|
||||
} as Record<string, unknown>,
|
||||
Button: {
|
||||
borderRadius: 8,
|
||||
controlHeight: 32,
|
||||
// 2026-06-18: primary button 改为近黑,更克制
|
||||
defaultBg: readToken('--bg-primary', '#ffffff'),
|
||||
defaultColor: readToken('--text-primary', '#1a1a1a'),
|
||||
} as Record<string, unknown>,
|
||||
Card: {
|
||||
borderRadiusLG: 10,
|
||||
borderRadiusLG: Number.parseInt(readToken('--radius-card', '12'), 10),
|
||||
borderRadius: Number.parseInt(readToken('--radius-card', '12'), 10),
|
||||
} as Record<string, unknown>,
|
||||
Input: {
|
||||
borderRadius: 8,
|
||||
borderRadius: Number.parseInt(readToken('--radius-md', '6'), 10),
|
||||
} as Record<string, unknown>,
|
||||
Modal: {
|
||||
borderRadiusLG: 12,
|
||||
borderRadiusLG: Number.parseInt(readToken('--radius-card', '12'), 10),
|
||||
} as Record<string, unknown>,
|
||||
Tag: {
|
||||
borderRadiusSM: 4,
|
||||
} as Record<string, unknown>,
|
||||
},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,14 +14,29 @@
|
|||
|
||||
:root {
|
||||
/* ── Brand Colors ── */
|
||||
--color-primary: #6366f1;
|
||||
--color-primary-hover: #4f46e5;
|
||||
--color-primary-active: #4338ca;
|
||||
--color-primary-light: #eef2ff;
|
||||
--color-primary-bg: #f5f3ff;
|
||||
/* 2026-06-18 VI 重梳: 主色由 indigo 改为近黑 + 中性灰,accent 仅做语义信号 */
|
||||
--color-primary: #1a1a1a;
|
||||
--color-primary-hover: #2f2f2f;
|
||||
--color-primary-active: #000000;
|
||||
--color-primary-light: #f3f4f6;
|
||||
--color-primary-bg: #fbfbfa;
|
||||
|
||||
/* ── Accent: @team 专家团 (中性蓝) ── */
|
||||
--accent-team: #3b82f6;
|
||||
--accent-team-hover: #2563eb;
|
||||
--accent-team-soft: #dbeafe;
|
||||
--accent-team-bg: #eff6ff;
|
||||
|
||||
/* ── Accent: @board 私董会 (琥珀/紫) ── */
|
||||
--accent-board: #a855f7;
|
||||
--accent-board-hover: #9333ea;
|
||||
--accent-board-soft: #f3e8ff;
|
||||
--accent-board-bg: #faf5ff;
|
||||
|
||||
/* ── Gradient ── */
|
||||
--gradient-brand: linear-gradient(135deg, #6366f1 0%, #312e81 100%);
|
||||
--gradient-brand: linear-gradient(135deg, #1a1a1a 0%, #4a4a4a 100%);
|
||||
--gradient-team: linear-gradient(135deg, #3b82f6 0%, #1e40af 100%);
|
||||
--gradient-board: linear-gradient(135deg, #a855f7 0%, #7e22ce 100%);
|
||||
|
||||
/* ── Semantic Colors ── */
|
||||
--color-success: #22c55e;
|
||||
|
|
@ -82,6 +97,7 @@
|
|||
--radius-md: 6px;
|
||||
--radius-lg: 10px;
|
||||
--radius-xl: 14px;
|
||||
--radius-card: 12px;
|
||||
--radius-full: 9999px;
|
||||
|
||||
/* ── Font Size ── */
|
||||
|
|
@ -98,6 +114,9 @@
|
|||
--font-weight-semibold: 600;
|
||||
--font-weight-bold: 700;
|
||||
|
||||
/* ── Monospace Font ── */
|
||||
--font-mono: 'SF Mono', 'Fira Code', Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;
|
||||
|
||||
/* ── Line Height ── */
|
||||
--leading-tight: 1.25;
|
||||
--leading-normal: 1.5;
|
||||
|
|
@ -108,6 +127,12 @@
|
|||
--shadow-md: 0 2px 8px rgba(0, 0, 0, 0.06), 0 1px 2px rgba(0, 0, 0, 0.04);
|
||||
--shadow-lg: 0 4px 16px rgba(0, 0, 0, 0.06), 0 2px 4px rgba(0, 0, 0, 0.04);
|
||||
--shadow-xl: 0 8px 32px rgba(0, 0, 0, 0.08), 0 2px 8px rgba(0, 0, 0, 0.04);
|
||||
--shadow-card: 0 1px 3px rgba(0, 0, 0, 0.04), 0 0 0 1px rgba(0, 0, 0, 0.04);
|
||||
|
||||
/* ── Message Layout ── */
|
||||
--max-chat-width: 860px;
|
||||
--space-message-gap: 24px;
|
||||
--leading-message: 1.65;
|
||||
|
||||
/* ── Transition ── */
|
||||
--transition-fast: 150ms ease;
|
||||
|
|
@ -145,14 +170,28 @@
|
|||
/* ── Dark Theme ── */
|
||||
[data-theme="dark"] {
|
||||
/* ── Brand Colors ── */
|
||||
--color-primary: #818cf8;
|
||||
--color-primary-hover: #6366f1;
|
||||
--color-primary-active: #4f46e5;
|
||||
--color-primary-light: #1e1b4b;
|
||||
--color-primary-bg: #1e1b4b;
|
||||
--color-primary: #fbfbfa;
|
||||
--color-primary-hover: #ededec;
|
||||
--color-primary-active: #ffffff;
|
||||
--color-primary-light: #2f2f2f;
|
||||
--color-primary-bg: #1a1a1a;
|
||||
|
||||
/* ── Accent: @team (亮蓝) ── */
|
||||
--accent-team: #60a5fa;
|
||||
--accent-team-hover: #93c5fd;
|
||||
--accent-team-soft: rgba(59, 130, 246, 0.18);
|
||||
--accent-team-bg: rgba(59, 130, 246, 0.10);
|
||||
|
||||
/* ── Accent: @board (亮紫) ── */
|
||||
--accent-board: #c084fc;
|
||||
--accent-board-hover: #d8b4fe;
|
||||
--accent-board-soft: rgba(168, 85, 247, 0.18);
|
||||
--accent-board-bg: rgba(168, 85, 247, 0.10);
|
||||
|
||||
/* ── Gradient ── */
|
||||
--gradient-brand: linear-gradient(135deg, #818cf8 0%, #4338ca 100%);
|
||||
--gradient-brand: linear-gradient(135deg, #fbfbfa 0%, #9b9b9a 100%);
|
||||
--gradient-team: linear-gradient(135deg, #60a5fa 0%, #2563eb 100%);
|
||||
--gradient-board: linear-gradient(135deg, #c084fc 0%, #9333ea 100%);
|
||||
|
||||
/* ── Semantic Colors ── */
|
||||
--color-success: #4ade80;
|
||||
|
|
@ -203,6 +242,12 @@
|
|||
--shadow-lg: 0 4px 16px rgba(0, 0, 0, 0.3), 0 2px 4px rgba(0, 0, 0, 0.15);
|
||||
--shadow-xl: 0 8px 32px rgba(0, 0, 0, 0.35), 0 2px 8px rgba(0, 0, 0, 0.15);
|
||||
|
||||
/* ── Border Radius ── */
|
||||
--radius-card: 12px;
|
||||
|
||||
/* ── Monospace Font ── */
|
||||
--font-mono: 'SF Mono', 'Fira Code', Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;
|
||||
|
||||
/* ── Code Theme (Catppuccin Mocha — unchanged, already dark) ── */
|
||||
--code-bg: #11111b;
|
||||
--code-fg: #cdd6f4;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,14 @@
|
|||
<template>
|
||||
<ChatPreviewShell />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import ChatPreviewShell from '@/components/preview/ChatPreviewShell.vue'
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Preview view fills the layout panel area */
|
||||
:deep(.chat-preview) {
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
<template>
|
||||
<div class="chat-view">
|
||||
<ChatSidebar
|
||||
v-if="showSidebar"
|
||||
:conversations="chatStore.conversations"
|
||||
:current-id="chatStore.currentConversationId"
|
||||
@create="chatStore.createConversation"
|
||||
|
|
@ -18,49 +19,56 @@
|
|||
<template v-else>
|
||||
<ExpertTeamView />
|
||||
<BoardStatusView />
|
||||
<div class="chat-view__messages" ref="messagesContainer">
|
||||
<div v-if="chatStore.currentMessages.length === 0" class="chat-view__welcome">
|
||||
<div class="chat-view__welcome-inner">
|
||||
<div class="chat-view__welcome-logo">
|
||||
<RobotOutlined class="chat-view__welcome-icon" />
|
||||
</div>
|
||||
<h2 class="chat-view__welcome-title">Fischer AgentKit</h2>
|
||||
<p class="chat-view__welcome-subtitle">企业级 AI 智能体平台</p>
|
||||
<div class="chat-view__welcome-hints">
|
||||
<div class="chat-view__hint" v-for="hint in welcomeHints" :key="hint">
|
||||
<ThunderboltOutlined class="chat-view__hint-icon" />
|
||||
<span>{{ hint }}</span>
|
||||
<div class="chat-view__content" ref="messagesContainer">
|
||||
<div class="chat-view__content-inner">
|
||||
<div v-if="chatStore.currentMessages.length === 0" class="chat-view__welcome">
|
||||
<div class="chat-view__welcome-inner">
|
||||
<div class="chat-view__welcome-logo">
|
||||
<RobotOutlined class="chat-view__welcome-icon" />
|
||||
</div>
|
||||
<h2 class="chat-view__welcome-title">Fischer AgentKit</h2>
|
||||
<p class="chat-view__welcome-subtitle">企业级 AI 智能体平台</p>
|
||||
<div class="chat-view__welcome-hints">
|
||||
<div class="chat-view__hint" v-for="hint in welcomeHints" :key="hint">
|
||||
<ThunderboltOutlined class="chat-view__hint-icon" />
|
||||
<span>{{ hint }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ChatMessage
|
||||
v-for="msg in chatStore.currentMessages"
|
||||
:key="msg.id"
|
||||
:message="msg"
|
||||
/>
|
||||
<!-- Streaming steps -->
|
||||
<div v-if="chatStore.streamingSteps.length > 0" class="chat-view__steps">
|
||||
<a-typography-text type="secondary">
|
||||
<LoadingOutlined /> 处理中...
|
||||
</a-typography-text>
|
||||
<div v-for="(step, idx) in chatStore.streamingSteps" :key="idx" class="chat-view__step">
|
||||
<RightOutlined class="chat-view__step-icon" />
|
||||
<span>{{ step }}</span>
|
||||
<ChatMessage
|
||||
v-for="msg in chatStore.currentMessages"
|
||||
:key="msg.id"
|
||||
:message="msg"
|
||||
/>
|
||||
<!-- Streaming steps -->
|
||||
<div v-if="chatStore.streamingSteps.length > 0" class="chat-view__steps">
|
||||
<a-typography-text type="secondary">
|
||||
<LoadingOutlined /> 处理中...
|
||||
</a-typography-text>
|
||||
<div v-for="(step, idx) in chatStore.streamingSteps" :key="idx" class="chat-view__step">
|
||||
<RightOutlined class="chat-view__step-icon" />
|
||||
<span>{{ step }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ChatInput
|
||||
:disabled="chatStore.isLoading"
|
||||
@send="handleSend"
|
||||
/>
|
||||
<div class="chat-view__input-wrap">
|
||||
<div class="chat-view__input-inner">
|
||||
<ChatInput
|
||||
:disabled="chatStore.isLoading"
|
||||
@send="handleSend"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, nextTick, onMounted, onUnmounted } from 'vue'
|
||||
import { ref, watch, nextTick, onMounted, onUnmounted, computed } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { Empty as AEmpty, Button as AButton, Typography as ATypography } from 'ant-design-vue'
|
||||
import {
|
||||
PlusOutlined,
|
||||
|
|
@ -78,14 +86,19 @@ import BoardStatusView from '@/components/chat/BoardStatusView.vue'
|
|||
|
||||
const ATypographyText = ATypography.Text
|
||||
|
||||
const route = useRoute()
|
||||
const chatStore = useChatStore()
|
||||
|
||||
const showSidebar = computed(() => {
|
||||
return !(route.name === 'agent-chat' || route.name === 'agent-preview')
|
||||
})
|
||||
const messagesContainer = ref<HTMLElement | null>(null)
|
||||
|
||||
const welcomeHints = [
|
||||
'智能路由 — 自动匹配最优技能',
|
||||
'工具调用 — 读写文件、执行命令',
|
||||
'流式响应 — 实时查看推理过程',
|
||||
'私董会 — 输入 @board 召集专家团讨论',
|
||||
'私董会 — 点击输入框旁「私董会」按钮召集专家团讨论',
|
||||
]
|
||||
|
||||
onMounted(async () => {
|
||||
|
|
@ -147,7 +160,7 @@ function handleSend(message: string, model?: string): void {
|
|||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
background: var(--bg-secondary);
|
||||
background: var(--bg-primary);
|
||||
}
|
||||
|
||||
.chat-view__empty {
|
||||
|
|
@ -157,79 +170,64 @@ function handleSend(message: string, model?: string): void {
|
|||
justify-content: center;
|
||||
}
|
||||
|
||||
.chat-view__messages {
|
||||
.chat-view__content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: var(--space-4) 0;
|
||||
padding: var(--space-4);
|
||||
}
|
||||
|
||||
.chat-view__content-inner {
|
||||
max-width: var(--max-chat-width);
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
min-height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.chat-view__welcome {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
align-items: flex-start;
|
||||
justify-content: flex-start;
|
||||
padding: 120px 0 48px;
|
||||
flex: 1;
|
||||
color: var(--text-placeholder);
|
||||
}
|
||||
|
||||
.chat-view__welcome-inner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
animation: welcomeFadeIn 0.6s ease-out;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-2);
|
||||
animation: welcomeFadeIn 0.4s ease-out;
|
||||
}
|
||||
|
||||
@keyframes welcomeFadeIn {
|
||||
from { opacity: 0; transform: translateY(12px); }
|
||||
from { opacity: 0; transform: translateY(8px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
.chat-view__welcome-logo {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--gradient-brand);
|
||||
border-radius: var(--radius-xl);
|
||||
box-shadow: 0 4px 20px rgba(99, 102, 241, 0.25);
|
||||
animation: welcomeLogoIn 0.5s ease-out;
|
||||
}
|
||||
|
||||
@keyframes welcomeLogoIn {
|
||||
from { opacity: 0; transform: scale(0.8); }
|
||||
to { opacity: 1; transform: scale(1); }
|
||||
}
|
||||
|
||||
.chat-view__welcome-icon {
|
||||
font-size: 32px;
|
||||
color: var(--text-inverse) !important;
|
||||
-webkit-background-clip: unset;
|
||||
-webkit-text-fill-color: var(--text-inverse) !important;
|
||||
background-clip: unset;
|
||||
}
|
||||
|
||||
.chat-view__welcome-title {
|
||||
color: var(--text-primary);
|
||||
font-size: var(--font-xl);
|
||||
font-weight: var(--font-weight-bold);
|
||||
font-size: 24px;
|
||||
font-weight: var(--font-weight-semibold);
|
||||
margin: 0;
|
||||
animation: welcomeFadeIn 0.6s ease-out 0.1s both;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.chat-view__welcome-subtitle {
|
||||
font-size: var(--font-base);
|
||||
color: var(--text-tertiary);
|
||||
margin: 0;
|
||||
animation: welcomeFadeIn 0.6s ease-out 0.2s both;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.chat-view__welcome-hints {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
margin-top: var(--space-4);
|
||||
gap: var(--space-1);
|
||||
margin-top: var(--space-5);
|
||||
}
|
||||
|
||||
.chat-view__hint {
|
||||
|
|
@ -237,33 +235,37 @@ function handleSend(message: string, model?: string): void {
|
|||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
font-size: var(--font-sm);
|
||||
color: var(--text-tertiary);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
background: var(--bg-primary);
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--border-color);
|
||||
animation: welcomeFadeIn 0.5s ease-out both;
|
||||
color: var(--text-secondary);
|
||||
padding: 6px 0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.chat-view__hint:nth-child(1) { animation-delay: 0.3s; }
|
||||
.chat-view__hint:nth-child(2) { animation-delay: 0.4s; }
|
||||
.chat-view__hint:nth-child(3) { animation-delay: 0.5s; }
|
||||
.chat-view__hint:nth-child(4) { animation-delay: 0.6s; }
|
||||
|
||||
.chat-view__hint-icon {
|
||||
font-size: 14px;
|
||||
color: var(--color-primary);
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.chat-view__steps {
|
||||
padding: var(--space-3) var(--space-4);
|
||||
margin: 0 var(--space-4);
|
||||
margin: var(--space-2) 0 0;
|
||||
background: var(--bg-primary);
|
||||
border-radius: var(--radius-lg);
|
||||
border: 1px solid var(--border-color);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.chat-view__input-wrap {
|
||||
padding: var(--space-3) var(--space-4) var(--space-4);
|
||||
background: var(--bg-primary);
|
||||
border-top: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.chat-view__input-inner {
|
||||
max-width: var(--max-chat-width);
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.chat-view__step {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,188 @@
|
|||
<template>
|
||||
<div class="login-view">
|
||||
<div class="login-card">
|
||||
<div class="login-header">
|
||||
<div class="login-logo">Fischer AgentKit</div>
|
||||
<div class="login-subtitle">企业级 AI Agent 工作台</div>
|
||||
</div>
|
||||
|
||||
<a-form
|
||||
layout="vertical"
|
||||
:model="form"
|
||||
@finish="handleSubmit"
|
||||
autocomplete="off"
|
||||
>
|
||||
<a-form-item
|
||||
label="用户名"
|
||||
name="username"
|
||||
:rules="[{ required: true, message: '请输入用户名' }]"
|
||||
>
|
||||
<a-input
|
||||
v-model:value="form.username"
|
||||
size="large"
|
||||
placeholder="请输入用户名"
|
||||
:disabled="authStore.isLoading"
|
||||
@pressEnter="handleSubmit"
|
||||
>
|
||||
<template #prefix>
|
||||
<UserOutlined />
|
||||
</template>
|
||||
</a-input>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item
|
||||
label="密码"
|
||||
name="password"
|
||||
:rules="[{ required: true, message: '请输入密码' }]"
|
||||
>
|
||||
<a-input-password
|
||||
v-model:value="form.password"
|
||||
size="large"
|
||||
placeholder="请输入密码"
|
||||
:disabled="authStore.isLoading"
|
||||
@pressEnter="handleSubmit"
|
||||
>
|
||||
<template #prefix>
|
||||
<LockOutlined />
|
||||
</template>
|
||||
</a-input-password>
|
||||
</a-form-item>
|
||||
|
||||
<a-alert
|
||||
v-if="authStore.error"
|
||||
:message="authStore.error"
|
||||
type="error"
|
||||
show-icon
|
||||
class="login-error"
|
||||
closable
|
||||
@close="authStore.error = null"
|
||||
/>
|
||||
|
||||
<a-button
|
||||
type="primary"
|
||||
html-type="submit"
|
||||
size="large"
|
||||
block
|
||||
:loading="authStore.isLoading"
|
||||
class="login-submit"
|
||||
>
|
||||
登录
|
||||
</a-button>
|
||||
</a-form>
|
||||
|
||||
<div class="login-footer">
|
||||
<span class="login-hint">
|
||||
首次部署?请通过 <code>AGENTKIT_ADMIN_USERNAME</code> /
|
||||
<code>AGENTKIT_ADMIN_PASSWORD</code> 环境变量创建管理员账号。
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { reactive, onMounted } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { UserOutlined, LockOutlined } from '@ant-design/icons-vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const form = reactive({
|
||||
username: '',
|
||||
password: '',
|
||||
})
|
||||
|
||||
/** Redirect target after successful login (defaults to /agent). */
|
||||
const redirectTarget = (): string => {
|
||||
const redirect = route.query.redirect
|
||||
return typeof redirect === 'string' && redirect.startsWith('/')
|
||||
? redirect
|
||||
: '/agent'
|
||||
}
|
||||
|
||||
async function handleSubmit(): Promise<void> {
|
||||
if (!form.username || !form.password) return
|
||||
try {
|
||||
await authStore.login(form.username, form.password)
|
||||
router.replace(redirectTarget())
|
||||
} catch {
|
||||
/* error already in authStore.error */
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// If already authenticated (e.g. page reload with valid token), skip login
|
||||
if (authStore.isAuthenticated) {
|
||||
router.replace(redirectTarget())
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.login-view {
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--bg-secondary, #fbfbfa);
|
||||
}
|
||||
|
||||
.login-card {
|
||||
width: 380px;
|
||||
padding: 40px 32px 32px;
|
||||
background: var(--bg-elevated, #ffffff);
|
||||
border: 1px solid var(--border-color, #ededec);
|
||||
border-radius: var(--radius-xl, 14px);
|
||||
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.04), 0 1px 2px rgba(0, 0, 0, 0.02);
|
||||
}
|
||||
|
||||
.login-header {
|
||||
text-align: center;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.login-logo {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary, #1a1a1a);
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.login-subtitle {
|
||||
margin-top: 6px;
|
||||
font-size: 13px;
|
||||
color: var(--text-tertiary, #6b6b6a);
|
||||
}
|
||||
|
||||
.login-error {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.login-submit {
|
||||
margin-top: 4px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.login-footer {
|
||||
margin-top: 24px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.login-hint {
|
||||
font-size: 12px;
|
||||
color: var(--text-quaternary, #9b9b9a);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.login-hint code {
|
||||
padding: 1px 5px;
|
||||
background: var(--bg-tertiary, #f7f7f5);
|
||||
border-radius: 3px;
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary, #4a4a4a);
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
<template>
|
||||
<div class="terminal-view">
|
||||
<div class="terminal-view__main">
|
||||
<TerminalEmulator />
|
||||
<TerminalPanel />
|
||||
</div>
|
||||
<div :class="['terminal-view__sidebar', { 'terminal-view__sidebar--collapsed': sidebarCollapsed }]">
|
||||
<button class="terminal-view__sidebar-toggle" @click="sidebarCollapsed = !sidebarCollapsed">
|
||||
|
|
@ -11,7 +11,13 @@
|
|||
<CommandHistory @select="handleHistorySelect" />
|
||||
</div>
|
||||
</div>
|
||||
<!-- Confirmation dialog using Ant Design Modal -->
|
||||
|
||||
<!-- Whitelist management button -->
|
||||
<button class="terminal-view__whitelist-btn" @click="showWhitelist = true">
|
||||
<SafetyOutlined />
|
||||
</button>
|
||||
|
||||
<!-- Confirmation dialog (local terminal mode) -->
|
||||
<a-modal
|
||||
v-model:open="showConfirmation"
|
||||
title="命令确认"
|
||||
|
|
@ -33,20 +39,36 @@
|
|||
</a-checkbox>
|
||||
</div>
|
||||
</a-modal>
|
||||
|
||||
<!-- Whitelist management drawer -->
|
||||
<a-drawer
|
||||
v-model:open="showWhitelist"
|
||||
title="终端白名单管理"
|
||||
placement="right"
|
||||
:width="640"
|
||||
>
|
||||
<WhitelistManager />
|
||||
</a-drawer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onUnmounted } from 'vue'
|
||||
import { Modal as AModal, Checkbox as ACheckbox } from 'ant-design-vue'
|
||||
import { HistoryOutlined } from '@ant-design/icons-vue'
|
||||
import {
|
||||
Modal as AModal,
|
||||
Checkbox as ACheckbox,
|
||||
Drawer as ADrawer,
|
||||
} from 'ant-design-vue'
|
||||
import { HistoryOutlined, SafetyOutlined } from '@ant-design/icons-vue'
|
||||
import { useTerminalStore } from '@/stores/terminal'
|
||||
import TerminalEmulator from '@/components/terminal/TerminalEmulator.vue'
|
||||
import TerminalPanel from '@/components/terminal/TerminalPanel.vue'
|
||||
import CommandHistory from '@/components/terminal/CommandHistory.vue'
|
||||
import WhitelistManager from '@/components/terminal/WhitelistManager.vue'
|
||||
|
||||
const terminalStore = useTerminalStore()
|
||||
const addToWhitelist = ref(false)
|
||||
const sidebarCollapsed = ref(true)
|
||||
const showWhitelist = ref(false)
|
||||
|
||||
const showConfirmation = computed({
|
||||
get: () => !!terminalStore.pendingConfirmation,
|
||||
|
|
@ -83,6 +105,7 @@ function rejectConfirmation(): void {
|
|||
display: flex;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.terminal-view__main {
|
||||
|
|
@ -133,6 +156,29 @@ function rejectConfirmation(): void {
|
|||
min-width: 0;
|
||||
}
|
||||
|
||||
.terminal-view__whitelist-btn {
|
||||
position: absolute;
|
||||
top: var(--space-2);
|
||||
right: var(--space-4);
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: 1px solid var(--border-color);
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
border-radius: var(--radius-md);
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.terminal-view__whitelist-btn:hover {
|
||||
color: var(--text-primary);
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.terminal-view__modal-command {
|
||||
font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', Menlo, Consolas, monospace;
|
||||
font-size: var(--font-sm);
|
||||
|
|
|
|||
|
|
@ -1,5 +1,39 @@
|
|||
"""Server route modules"""
|
||||
|
||||
from agentkit.server.routes import agents, tasks, skills, llm, health, metrics, ws, evolution, memory, portal, evolution_dashboard, kb_management, skill_management, workflows, terminal
|
||||
from agentkit.server.routes import (
|
||||
agents,
|
||||
tasks,
|
||||
skills,
|
||||
llm,
|
||||
health,
|
||||
metrics,
|
||||
ws,
|
||||
evolution,
|
||||
memory,
|
||||
portal,
|
||||
evolution_dashboard,
|
||||
kb_management,
|
||||
skill_management,
|
||||
workflows,
|
||||
terminal,
|
||||
experts,
|
||||
)
|
||||
|
||||
__all__ = ["agents", "tasks", "skills", "llm", "health", "metrics", "ws", "evolution", "memory", "portal", "evolution_dashboard", "kb_management", "skill_management", "workflows", "terminal"]
|
||||
__all__ = [
|
||||
"agents",
|
||||
"tasks",
|
||||
"skills",
|
||||
"llm",
|
||||
"health",
|
||||
"metrics",
|
||||
"ws",
|
||||
"evolution",
|
||||
"memory",
|
||||
"portal",
|
||||
"evolution_dashboard",
|
||||
"kb_management",
|
||||
"skill_management",
|
||||
"workflows",
|
||||
"terminal",
|
||||
"experts",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -0,0 +1,351 @@
|
|||
"""Authentication REST routes.
|
||||
|
||||
Endpoints
|
||||
---------
|
||||
- ``POST /auth/login`` — username + password → access + refresh JWT pair
|
||||
- ``POST /auth/refresh`` — refresh token → new access token
|
||||
- ``POST /auth/logout`` — revoke refresh token
|
||||
- ``GET /auth/me`` — current user info (requires auth)
|
||||
|
||||
The auth DB (SQLite via aiosqlite) and JWT secret are resolved from
|
||||
``app.state`` if set by the app factory, otherwise from the defaults in
|
||||
:mod:`agentkit.server.auth.models` / :mod:`agentkit.server.auth.jwt_utils`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import logging
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import aiosqlite
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from pydantic import BaseModel, ConfigDict, EmailStr
|
||||
|
||||
from agentkit.server.auth.dependencies import require_authenticated
|
||||
from agentkit.server.auth.jwt_utils import (
|
||||
ACCESS_TOKEN_TTL,
|
||||
create_token_pair,
|
||||
get_or_create_jwt_secret,
|
||||
verify_token,
|
||||
)
|
||||
from agentkit.server.auth.models import DEFAULT_AUTH_DB_PATH, init_auth_db
|
||||
from agentkit.server.auth.password import verify_password
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||
|
||||
# Pre-computed valid bcrypt hash format for timing-attack mitigation.
|
||||
# This is a valid $2b$12$ hash (correct salt + hash length, valid base64
|
||||
# alphabet) so bcrypt.checkpw runs the full computation (~250ms) instead
|
||||
# of raising ValueError immediately. The hash itself is meaningless —
|
||||
# it will return False for any password — but the timing matches a real
|
||||
# password verification, preventing username enumeration via timing.
|
||||
_DUMMY_BCRYPT_HASH = "$2b$12$abcdefghijklmnopqrstuuABCDEFGHIJKLMNOPQRSTUVWXYZ0123"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Pydantic request/response models
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
"""Username + password login payload."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
username: str
|
||||
password: str
|
||||
|
||||
|
||||
class RefreshRequest(BaseModel):
|
||||
"""Refresh-token exchange payload."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
refresh_token: str
|
||||
|
||||
|
||||
class UserResponse(BaseModel):
|
||||
"""Public user representation (no password hash)."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
id: str
|
||||
username: str
|
||||
email: EmailStr
|
||||
role: str
|
||||
is_active: bool
|
||||
is_terminal_authorized: bool
|
||||
is_server_terminal_authorized: bool
|
||||
|
||||
|
||||
class TokenResponse(BaseModel):
|
||||
"""JWT pair + user info returned by /auth/login and /auth/refresh."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
access_token: str
|
||||
refresh_token: str
|
||||
token_type: str = "bearer"
|
||||
expires_in: int = int(ACCESS_TOKEN_TTL.total_seconds())
|
||||
user: UserResponse
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _sha256(text: str) -> str:
|
||||
"""SHA-256 hex digest of a string."""
|
||||
return hashlib.sha256(text.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def _resolve_db_path(request: Request) -> Path:
|
||||
"""Resolve the auth DB path from app.state or the default."""
|
||||
path = getattr(request.app.state, "auth_db_path", None)
|
||||
return Path(path) if path else DEFAULT_AUTH_DB_PATH
|
||||
|
||||
|
||||
def _resolve_jwt_secret(request: Request) -> str:
|
||||
"""Resolve the JWT secret from app.state or the env var.
|
||||
|
||||
Falls back to :func:`get_or_create_jwt_secret` (which generates an
|
||||
ephemeral secret in dev mode) so token signing always works.
|
||||
"""
|
||||
secret = getattr(request.app.state, "jwt_secret", None)
|
||||
if secret:
|
||||
return secret
|
||||
return get_or_create_jwt_secret()
|
||||
|
||||
|
||||
async def _ensure_db(request: Request) -> Path:
|
||||
"""Ensure the auth DB exists and return its path."""
|
||||
db_path = _resolve_db_path(request)
|
||||
if not db_path.exists():
|
||||
await init_auth_db(db_path)
|
||||
return db_path
|
||||
|
||||
|
||||
def _user_row_to_response(row: aiosqlite.Row) -> UserResponse:
|
||||
"""Convert a users row to a :class:`UserResponse`."""
|
||||
return UserResponse(
|
||||
id=row["id"],
|
||||
username=row["username"],
|
||||
email=row["email"],
|
||||
role=row["role"],
|
||||
is_active=bool(row["is_active"]),
|
||||
is_terminal_authorized=bool(row["is_terminal_authorized"]),
|
||||
is_server_terminal_authorized=bool(row["is_server_terminal_authorized"]),
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Routes
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.post("/login", response_model=TokenResponse)
|
||||
async def login(payload: LoginRequest, request: Request) -> TokenResponse:
|
||||
"""Authenticate with username + password and receive a JWT pair.
|
||||
|
||||
Flow:
|
||||
1. Look up user by username.
|
||||
2. Verify bcrypt password hash.
|
||||
3. Ensure account is active.
|
||||
4. Issue access + refresh JWTs.
|
||||
5. Persist refresh-token hash to ``user_sessions``.
|
||||
6. Update ``last_login_at``.
|
||||
"""
|
||||
db_path = await _ensure_db(request)
|
||||
secret = _resolve_jwt_secret(request)
|
||||
|
||||
async with aiosqlite.connect(str(db_path)) as db:
|
||||
db.row_factory = aiosqlite.Row
|
||||
cursor = await db.execute(
|
||||
"SELECT * FROM users WHERE username = ?",
|
||||
(payload.username,),
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
|
||||
if row is None:
|
||||
# Constant-time: run a real bcrypt verification against a valid-format
|
||||
# hash so the response time matches the "user exists, wrong password"
|
||||
# path (~250ms), preventing username enumeration via timing.
|
||||
verify_password(payload.password, _DUMMY_BCRYPT_HASH)
|
||||
raise HTTPException(status_code=401, detail="Invalid username or password")
|
||||
|
||||
if not verify_password(payload.password, row["password_hash"]):
|
||||
raise HTTPException(status_code=401, detail="Invalid username or password")
|
||||
|
||||
if not bool(row["is_active"]):
|
||||
raise HTTPException(status_code=403, detail="Account is disabled")
|
||||
|
||||
user_id = row["id"]
|
||||
username = row["username"]
|
||||
role = row["role"]
|
||||
|
||||
token_pair = create_token_pair(
|
||||
user_id=user_id,
|
||||
username=username,
|
||||
role=role,
|
||||
secret=secret,
|
||||
)
|
||||
|
||||
# Persist refresh-token session
|
||||
session_id = str(uuid.uuid4())
|
||||
refresh_hash = _sha256(token_pair.refresh_token)
|
||||
now_iso = datetime.now(timezone.utc).isoformat()
|
||||
refresh_exp_iso = token_pair.refresh_expires_at.isoformat()
|
||||
|
||||
device_info = "{}" # V1: no device fingerprinting; reserved for future use
|
||||
async with aiosqlite.connect(str(db_path)) as db:
|
||||
await db.execute(
|
||||
"INSERT INTO user_sessions "
|
||||
"(id, user_id, refresh_token_hash, device_info, created_at, expires_at) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?)",
|
||||
(session_id, user_id, refresh_hash, device_info, now_iso, refresh_exp_iso),
|
||||
)
|
||||
await db.execute(
|
||||
"UPDATE users SET last_login_at = ?, updated_at = ? WHERE id = ?",
|
||||
(now_iso, now_iso, user_id),
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
user_resp = _user_row_to_response(row)
|
||||
return TokenResponse(
|
||||
access_token=token_pair.access_token,
|
||||
refresh_token=token_pair.refresh_token,
|
||||
token_type="bearer",
|
||||
expires_in=int(ACCESS_TOKEN_TTL.total_seconds()),
|
||||
user=user_resp,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/refresh", response_model=TokenResponse)
|
||||
async def refresh(payload: RefreshRequest, request: Request) -> TokenResponse:
|
||||
"""Exchange a valid refresh token for a new access token.
|
||||
|
||||
The refresh token's hash must match an unrevoked ``user_sessions`` row.
|
||||
A new access token is issued; the refresh token itself is *not* rotated
|
||||
in V1 (rotation is deferred to a later hardening pass).
|
||||
"""
|
||||
db_path = await _ensure_db(request)
|
||||
secret = _resolve_jwt_secret(request)
|
||||
|
||||
try:
|
||||
refresh_payload = verify_token(payload.refresh_token, secret)
|
||||
except Exception as exc: # noqa: BLE001 — PyJWT raises InvalidTokenError subclasses
|
||||
raise HTTPException(status_code=401, detail="Invalid refresh token") from exc
|
||||
|
||||
if refresh_payload.get("type") != "refresh":
|
||||
raise HTTPException(status_code=401, detail="Invalid refresh token")
|
||||
|
||||
refresh_hash = _sha256(payload.refresh_token)
|
||||
|
||||
async with aiosqlite.connect(str(db_path)) as db:
|
||||
db.row_factory = aiosqlite.Row
|
||||
cursor = await db.execute(
|
||||
"SELECT * FROM user_sessions WHERE refresh_token_hash = ?",
|
||||
(refresh_hash,),
|
||||
)
|
||||
session = await cursor.fetchone()
|
||||
|
||||
if session is None:
|
||||
raise HTTPException(status_code=401, detail="Invalid refresh token")
|
||||
if session["revoked_at"] is not None:
|
||||
raise HTTPException(status_code=401, detail="Refresh token has been revoked")
|
||||
|
||||
# Re-fetch user (in case role / active status changed since login)
|
||||
user_cursor = await db.execute(
|
||||
"SELECT * FROM users WHERE id = ?",
|
||||
(session["user_id"],),
|
||||
)
|
||||
user_row = await user_cursor.fetchone()
|
||||
|
||||
if user_row is None:
|
||||
raise HTTPException(status_code=401, detail="User not found")
|
||||
if not bool(user_row["is_active"]):
|
||||
raise HTTPException(status_code=403, detail="Account is disabled")
|
||||
|
||||
token_pair = create_token_pair(
|
||||
user_id=user_row["id"],
|
||||
username=user_row["username"],
|
||||
role=user_row["role"],
|
||||
secret=secret,
|
||||
)
|
||||
|
||||
user_resp = _user_row_to_response(user_row)
|
||||
# V1: refresh token is NOT rotated — return the original refresh token
|
||||
# so the client can continue using it. The new access token is the only
|
||||
# refreshed credential. Rotation (with hash persistence + revocation)
|
||||
# is deferred to a later hardening pass.
|
||||
return TokenResponse(
|
||||
access_token=token_pair.access_token,
|
||||
refresh_token=payload.refresh_token,
|
||||
token_type="bearer",
|
||||
expires_in=int(ACCESS_TOKEN_TTL.total_seconds()),
|
||||
user=user_resp,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/logout")
|
||||
async def logout(payload: RefreshRequest, request: Request) -> dict[str, Any]:
|
||||
"""Revoke a refresh token by marking its session row as revoked.
|
||||
|
||||
Idempotent: calling logout with an already-revoked or unknown token
|
||||
returns 200 with ``revoked=false`` (no error).
|
||||
"""
|
||||
db_path = await _ensure_db(request)
|
||||
secret = _resolve_jwt_secret(request)
|
||||
|
||||
try:
|
||||
verify_token(payload.refresh_token, secret)
|
||||
except Exception: # noqa: BLE001 — treat any JWT error as "not our token"
|
||||
return {"revoked": False, "message": "Invalid token, nothing to revoke"}
|
||||
|
||||
refresh_hash = _sha256(payload.refresh_token)
|
||||
now_iso = datetime.now(timezone.utc).isoformat()
|
||||
|
||||
async with aiosqlite.connect(str(db_path)) as db:
|
||||
cursor = await db.execute(
|
||||
"UPDATE user_sessions SET revoked_at = ? "
|
||||
"WHERE refresh_token_hash = ? AND revoked_at IS NULL",
|
||||
(now_iso, refresh_hash),
|
||||
)
|
||||
await db.commit()
|
||||
revoked = cursor.rowcount > 0
|
||||
|
||||
return {"revoked": revoked}
|
||||
|
||||
|
||||
@router.get("/me", response_model=UserResponse)
|
||||
async def me(
|
||||
request: Request,
|
||||
user: dict[str, Any] = Depends(require_authenticated),
|
||||
) -> UserResponse:
|
||||
"""Return the current authenticated user's public profile.
|
||||
|
||||
The JWT payload only carries ``user_id`` / ``username`` / ``role``; this
|
||||
endpoint re-fetches the full record from the auth DB so callers see the
|
||||
freshest ``email`` / ``is_active`` / terminal-authorization flags.
|
||||
"""
|
||||
user_id = user.get("user_id")
|
||||
if not user_id:
|
||||
raise HTTPException(status_code=401, detail="Authentication required")
|
||||
|
||||
db_path = await _ensure_db(request)
|
||||
async with aiosqlite.connect(str(db_path)) as db:
|
||||
db.row_factory = aiosqlite.Row
|
||||
cursor = await db.execute("SELECT * FROM users WHERE id = ?", (user_id,))
|
||||
row = await cursor.fetchone()
|
||||
|
||||
if row is None:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
return _user_row_to_response(row)
|
||||
|
|
@ -8,7 +8,20 @@ import json
|
|||
import logging
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, HTTPException, WebSocket, WebSocketDisconnect, Request
|
||||
import os
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import (
|
||||
APIRouter,
|
||||
File,
|
||||
HTTPException,
|
||||
Request,
|
||||
UploadFile,
|
||||
WebSocket,
|
||||
WebSocketDisconnect,
|
||||
)
|
||||
from fastapi.responses import FileResponse
|
||||
from pydantic import BaseModel
|
||||
|
||||
from agentkit.chat.skill_routing import ExecutionMode
|
||||
|
|
@ -192,7 +205,10 @@ async def _execute_board_meeting(
|
|||
|
||||
if not routing_result.topic:
|
||||
await websocket.send_json(
|
||||
{"type": "error", "data": {"message": "私董会需要一个讨论主题,例如:@board 如何看待 AI 未来"}}
|
||||
{
|
||||
"type": "error",
|
||||
"data": {"message": "私董会需要一个讨论主题,例如:@board 如何看待 AI 未来"},
|
||||
}
|
||||
)
|
||||
return True
|
||||
|
||||
|
|
@ -204,13 +220,15 @@ async def _execute_board_meeting(
|
|||
)
|
||||
return True
|
||||
|
||||
# Read board config from server_config if available
|
||||
max_rounds = 5
|
||||
server_config = getattr(app_state, "server_config", None)
|
||||
if server_config is not None:
|
||||
board_cfg = getattr(server_config, "board", None) or {}
|
||||
if isinstance(board_cfg, dict):
|
||||
max_rounds = int(board_cfg.get("max_rounds", 5))
|
||||
# Read board config from server_config if available; user-specified rounds take precedence
|
||||
max_rounds = routing_result.max_rounds
|
||||
if max_rounds is None:
|
||||
max_rounds = 5
|
||||
server_config = getattr(app_state, "server_config", None)
|
||||
if server_config is not None:
|
||||
board_cfg = getattr(server_config, "board", None) or {}
|
||||
if isinstance(board_cfg, dict):
|
||||
max_rounds = int(board_cfg.get("max_rounds", 5))
|
||||
|
||||
# Create BoardTeam
|
||||
team = BoardTeam(
|
||||
|
|
@ -252,11 +270,8 @@ async def _execute_board_meeting(
|
|||
pass
|
||||
return True
|
||||
finally:
|
||||
# Always remove handler to avoid leaks
|
||||
try:
|
||||
team.handoff_transport._handlers.pop(team.team_channel, None)
|
||||
except Exception:
|
||||
pass
|
||||
# dissolve() already clears handlers via handoff_transport.close()
|
||||
pass
|
||||
|
||||
# Build final answer text from conclusion
|
||||
summary = result.get("summary", "")
|
||||
|
|
@ -271,13 +286,9 @@ async def _execute_board_meeting(
|
|||
if decision_advice:
|
||||
final_parts.append(f"## 决策建议\n\n{decision_advice}")
|
||||
if consensus_points:
|
||||
final_parts.append(
|
||||
"## 共识点\n\n" + "\n".join(f"- {p}" for p in consensus_points)
|
||||
)
|
||||
final_parts.append("## 共识点\n\n" + "\n".join(f"- {p}" for p in consensus_points))
|
||||
if dissent_points:
|
||||
final_parts.append(
|
||||
"## 分歧点\n\n" + "\n".join(f"- {p}" for p in dissent_points)
|
||||
)
|
||||
final_parts.append("## 分歧点\n\n" + "\n".join(f"- {p}" for p in dissent_points))
|
||||
final_parts.append(f"\n\n_共进行 {total_rounds} 轮讨论_")
|
||||
|
||||
final_content = "\n\n".join(final_parts)
|
||||
|
|
@ -347,7 +358,10 @@ async def _execute_team_collab(
|
|||
|
||||
if not routing_result.task_content:
|
||||
await websocket.send_json(
|
||||
{"type": "error", "data": {"message": "团队任务需要一个描述,例如:@team 开发用户登录功能"}}
|
||||
{
|
||||
"type": "error",
|
||||
"data": {"message": "团队任务需要一个描述,例如:@team 开发用户登录功能"},
|
||||
}
|
||||
)
|
||||
return True
|
||||
|
||||
|
|
@ -380,22 +394,20 @@ async def _execute_team_collab(
|
|||
|
||||
team.handoff_transport.register_handler(team.team_channel, _relay_team_event)
|
||||
|
||||
# Append user task to session history
|
||||
await sm.append_message(
|
||||
session_id=session_id,
|
||||
role=MessageRole.USER,
|
||||
content=content,
|
||||
)
|
||||
|
||||
try:
|
||||
# Append user task to session history (inside try so we can compensate on error)
|
||||
await sm.append_message(
|
||||
session_id=session_id,
|
||||
role=MessageRole.USER,
|
||||
content=content,
|
||||
)
|
||||
|
||||
await team.create_team(lead_config=lead_config, member_configs=member_configs)
|
||||
orchestrator = TeamOrchestrator(team=team)
|
||||
result = await orchestrator.execute(routing_result.task_content)
|
||||
except asyncio.CancelledError:
|
||||
logger.info(f"Team collaboration cancelled for session {session_id}")
|
||||
await websocket.send_json(
|
||||
{"type": "error", "data": {"message": "团队协作已取消"}}
|
||||
)
|
||||
await websocket.send_json({"type": "error", "data": {"message": "团队协作已取消"}})
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Team collaboration failed for session {session_id}: {e}", exc_info=True)
|
||||
|
|
@ -409,23 +421,30 @@ async def _execute_team_collab(
|
|||
await team.dissolve()
|
||||
except Exception as e:
|
||||
logger.warning(f"Team dissolve failed: {e}")
|
||||
try:
|
||||
team.handoff_transport._handlers.pop(team.team_channel, None)
|
||||
except Exception:
|
||||
pass
|
||||
# dissolve() already clears handlers via handoff_transport.close()
|
||||
|
||||
# Build final answer text from synthesis result
|
||||
final_result = result.get("result") or {}
|
||||
final_content = final_result.get("content", "") if isinstance(final_result, dict) else str(final_result)
|
||||
final_content = (
|
||||
final_result.get("content", "") if isinstance(final_result, dict) else str(final_result)
|
||||
)
|
||||
|
||||
if not final_content:
|
||||
# Fallback: use phase results if synthesis is empty
|
||||
phase_results = result.get("phase_results") or {}
|
||||
if phase_results:
|
||||
parts = []
|
||||
# Build a phase_id -> phase_name lookup from the plan
|
||||
phase_names = {}
|
||||
plan_obj = result.get("plan")
|
||||
if plan_obj:
|
||||
for ph in plan_obj.phases:
|
||||
phase_names[ph.id] = ph.name
|
||||
for phase_id, pr in phase_results.items():
|
||||
if isinstance(pr, dict) and "content" in pr:
|
||||
parts.append(f"### {pr.get('phase_name', phase_id)}\n\n{pr['content']}")
|
||||
parts.append(
|
||||
f"### {phase_names.get(phase_id, pr.get('phase_name', phase_id))}\n\n{pr['content']}"
|
||||
)
|
||||
final_content = "\n\n".join(parts) if parts else "团队执行完成,但未生成最终结果。"
|
||||
else:
|
||||
final_content = "团队执行完成,但未生成最终结果。"
|
||||
|
|
@ -1062,3 +1081,70 @@ async def _handle_chat_message(
|
|||
if len(error_msg) > 200:
|
||||
error_msg = error_msg[:200] + "..."
|
||||
await websocket.send_json({"type": "error", "data": {"message": error_msg}})
|
||||
|
||||
|
||||
# ── File upload ───────────────────────────────────────────────────────
|
||||
|
||||
|
||||
MAX_UPLOAD_SIZE = 10 * 1024 * 1024 # 10 MB
|
||||
UPLOAD_DIR = Path(os.environ.get("AGENTKIT_UPLOAD_DIR", "data/uploads"))
|
||||
|
||||
|
||||
def _ensure_upload_dir() -> Path:
|
||||
UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
|
||||
return UPLOAD_DIR
|
||||
|
||||
|
||||
def _sanitize_filename(name: str) -> str:
|
||||
"""Remove path separators and keep only safe characters."""
|
||||
name = name.replace("\\", "_").replace("/", "_")
|
||||
return "".join(c for c in name if c.isalnum() or c in "._-").strip(".")
|
||||
|
||||
|
||||
@router.post("/upload")
|
||||
async def upload_chat_file(file: UploadFile = File(...)) -> dict[str, Any]:
|
||||
"""Upload a file to be referenced in chat messages.
|
||||
|
||||
Returns metadata including a public download URL.
|
||||
"""
|
||||
if file.size is not None and file.size > MAX_UPLOAD_SIZE:
|
||||
raise HTTPException(status_code=413, detail="File exceeds 10 MB limit")
|
||||
|
||||
original_name = file.filename or "unnamed"
|
||||
safe_name = _sanitize_filename(original_name) or "unnamed"
|
||||
ext = Path(safe_name).suffix
|
||||
stored_name = f"{uuid.uuid4().hex}{ext}"
|
||||
upload_dir = _ensure_upload_dir()
|
||||
file_path = upload_dir / stored_name
|
||||
|
||||
try:
|
||||
contents = await file.read()
|
||||
if len(contents) > MAX_UPLOAD_SIZE:
|
||||
raise HTTPException(status_code=413, detail="File exceeds 10 MB limit")
|
||||
file_path.write_bytes(contents)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as exc:
|
||||
logger.error(f"Failed to save uploaded file: {exc}")
|
||||
raise HTTPException(status_code=500, detail="Failed to save file") from exc
|
||||
finally:
|
||||
await file.close()
|
||||
|
||||
return {
|
||||
"filename": original_name,
|
||||
"stored_name": stored_name,
|
||||
"content_type": file.content_type or "application/octet-stream",
|
||||
"size": file_path.stat().st_size,
|
||||
"download_url": f"/api/v1/chat/uploads/{stored_name}",
|
||||
}
|
||||
|
||||
|
||||
@router.get("/uploads/{filename}")
|
||||
async def download_chat_file(filename: str) -> FileResponse:
|
||||
"""Download an uploaded chat file by its stored filename."""
|
||||
upload_dir = _ensure_upload_dir()
|
||||
safe_filename = _sanitize_filename(filename)
|
||||
file_path = upload_dir / safe_filename
|
||||
if not file_path.exists() or not file_path.is_file():
|
||||
raise HTTPException(status_code=404, detail="File not found")
|
||||
return FileResponse(file_path, filename=safe_filename)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,247 @@
|
|||
"""Configuration sync API — enables clients to pull skill/agent/workflow
|
||||
configs from the server.
|
||||
|
||||
Endpoints:
|
||||
- GET /api/v1/config/version — Returns a content hash of all configs.
|
||||
Clients poll this every 5 minutes; if the hash changes, they re-pull.
|
||||
- GET /api/v1/config/all — Returns all configs in one response.
|
||||
- GET /api/v1/config/skills — Returns all skill configs.
|
||||
- GET /api/v1/config/agents — Returns all agent configs (from skills).
|
||||
- GET /api/v1/config/workflows — Returns all workflow definitions.
|
||||
|
||||
Authentication: requires CHAT permission (low-risk). Dev mode allows access.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
|
||||
from agentkit.server.auth.dependencies import require_permission
|
||||
from agentkit.server.auth.permissions import Permission
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/config", tags=["config-sync"])
|
||||
|
||||
|
||||
# ── Helpers ───────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _stable_json_hash(data: Any) -> str:
|
||||
"""Compute a stable SHA-256 hash of JSON-serializable data.
|
||||
|
||||
Keys are sorted to ensure deterministic output regardless of dict
|
||||
insertion order.
|
||||
"""
|
||||
encoded = json.dumps(data, sort_keys=True, ensure_ascii=False, default=str)
|
||||
return hashlib.sha256(encoded.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def _collect_skill_configs(request: Request) -> list[dict[str, Any]]:
|
||||
"""Collect all skill configs from the SkillRegistry.
|
||||
|
||||
Returns a list of ``{name, version, config}`` dicts. The ``config``
|
||||
field is the full skill config as a dict (from ``SkillConfig.to_dict()``).
|
||||
"""
|
||||
registry = getattr(request.app.state, "skill_registry", None)
|
||||
if registry is None:
|
||||
return []
|
||||
|
||||
configs: list[dict[str, Any]] = []
|
||||
try:
|
||||
for name in sorted(registry.list_skills()):
|
||||
skill = registry.get(name)
|
||||
if skill is None or skill.config is None:
|
||||
continue
|
||||
config_dict = skill.config.to_dict() if hasattr(skill.config, "to_dict") else {}
|
||||
configs.append({
|
||||
"name": name,
|
||||
"version": getattr(skill.config, "version", "1.0.0"),
|
||||
"config": config_dict,
|
||||
})
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to collect skill configs: {e}")
|
||||
return configs
|
||||
|
||||
|
||||
def _collect_workflow_configs(request: Request) -> list[dict[str, Any]]:
|
||||
"""Collect all workflow definitions from the WorkflowStore.
|
||||
|
||||
Returns a list of workflow definition dicts.
|
||||
"""
|
||||
workflow_store = getattr(request.app.state, "workflow_store", None)
|
||||
if workflow_store is None:
|
||||
# Try the module-level singleton
|
||||
try:
|
||||
from agentkit.server.routes.workflows import _workflow_store
|
||||
|
||||
workflow_store = _workflow_store
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
configs: list[dict[str, Any]] = []
|
||||
try:
|
||||
workflows = workflow_store.list_workflows()
|
||||
for wf in workflows:
|
||||
if hasattr(wf, "model_dump"):
|
||||
configs.append(wf.model_dump())
|
||||
elif hasattr(wf, "to_dict"):
|
||||
configs.append(wf.to_dict())
|
||||
else:
|
||||
configs.append(dict(wf))
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to collect workflow configs: {e}")
|
||||
return configs
|
||||
|
||||
|
||||
def _compute_config_version(skills: list[dict[str, Any]], workflows: list[dict[str, Any]]) -> str:
|
||||
"""Compute a single version hash from all config payloads.
|
||||
|
||||
The hash is a SHA-256 of the sorted JSON encoding of both lists.
|
||||
Any change to any skill or workflow config produces a different hash.
|
||||
"""
|
||||
payload = {"skills": skills, "workflows": workflows}
|
||||
return _stable_json_hash(payload)
|
||||
|
||||
|
||||
# ── Schemas ───────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class ConfigVersionResponse(dict):
|
||||
"""Response for GET /config/version.
|
||||
|
||||
``{"version": "<sha256>", "skill_count": N, "workflow_count": M,
|
||||
"computed_at": "<iso>"}``
|
||||
"""
|
||||
|
||||
|
||||
class ConfigSyncResponse(dict):
|
||||
"""Response for GET /config/all.
|
||||
|
||||
``{"version": "<sha256>", "skills": [...], "workflows": [...],
|
||||
"synced_at": "<iso>"}``
|
||||
"""
|
||||
|
||||
|
||||
# ── Endpoints ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.get(
|
||||
"/version",
|
||||
response_model=dict,
|
||||
dependencies=[Depends(require_permission(Permission.CHAT))],
|
||||
)
|
||||
async def get_config_version(request: Request):
|
||||
"""Get the current config version hash.
|
||||
|
||||
Clients poll this endpoint every 5 minutes. If the hash differs from
|
||||
the locally cached version, the client re-pulls all configs via
|
||||
``GET /config/all``.
|
||||
"""
|
||||
from datetime import datetime, timezone
|
||||
|
||||
skills = _collect_skill_configs(request)
|
||||
workflows = _collect_workflow_configs(request)
|
||||
version = _compute_config_version(skills, workflows)
|
||||
|
||||
return {
|
||||
"version": version,
|
||||
"skill_count": len(skills),
|
||||
"workflow_count": len(workflows),
|
||||
"computed_at": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
|
||||
|
||||
@router.get(
|
||||
"/all",
|
||||
response_model=dict,
|
||||
dependencies=[Depends(require_permission(Permission.CHAT))],
|
||||
)
|
||||
async def get_all_configs(request: Request):
|
||||
"""Get all configs (skills + workflows) in a single response.
|
||||
|
||||
This is the primary sync endpoint. The response includes a ``version``
|
||||
hash that the client stores alongside the cached configs.
|
||||
"""
|
||||
from datetime import datetime, timezone
|
||||
|
||||
skills = _collect_skill_configs(request)
|
||||
workflows = _collect_workflow_configs(request)
|
||||
version = _compute_config_version(skills, workflows)
|
||||
|
||||
return {
|
||||
"version": version,
|
||||
"skills": skills,
|
||||
"workflows": workflows,
|
||||
"synced_at": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
|
||||
|
||||
@router.get(
|
||||
"/skills",
|
||||
response_model=dict,
|
||||
dependencies=[Depends(require_permission(Permission.CHAT))],
|
||||
)
|
||||
async def get_skill_configs(request: Request):
|
||||
"""Get all skill configs."""
|
||||
from datetime import datetime, timezone
|
||||
|
||||
skills = _collect_skill_configs(request)
|
||||
return {
|
||||
"skills": skills,
|
||||
"count": len(skills),
|
||||
"synced_at": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
|
||||
|
||||
@router.get(
|
||||
"/agents",
|
||||
response_model=dict,
|
||||
dependencies=[Depends(require_permission(Permission.CHAT))],
|
||||
)
|
||||
async def get_agent_configs(request: Request):
|
||||
"""Get all agent configs.
|
||||
|
||||
Agents are derived from skills (each skill defines an agent config).
|
||||
This endpoint returns the same data as ``/config/skills`` but under
|
||||
the ``agents`` key for clients that distinguish between the two.
|
||||
"""
|
||||
from datetime import datetime, timezone
|
||||
|
||||
skills = _collect_skill_configs(request)
|
||||
# Agent configs are the skill configs (each skill IS an agent definition)
|
||||
agents = [
|
||||
{
|
||||
"name": s["name"],
|
||||
"version": s["version"],
|
||||
"config": s["config"],
|
||||
}
|
||||
for s in skills
|
||||
]
|
||||
return {
|
||||
"agents": agents,
|
||||
"count": len(agents),
|
||||
"synced_at": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
|
||||
|
||||
@router.get(
|
||||
"/workflows",
|
||||
response_model=dict,
|
||||
dependencies=[Depends(require_permission(Permission.CHAT))],
|
||||
)
|
||||
async def get_workflow_configs(request: Request):
|
||||
"""Get all workflow definitions."""
|
||||
from datetime import datetime, timezone
|
||||
|
||||
workflows = _collect_workflow_configs(request)
|
||||
return {
|
||||
"workflows": workflows,
|
||||
"count": len(workflows),
|
||||
"synced_at": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
|
|
@ -0,0 +1,57 @@
|
|||
"""Expert template routes - 列出可用的专家模板(用于私董会 @board 模式)"""
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
|
||||
from agentkit.experts.registry import ExpertTemplateRegistry
|
||||
|
||||
router = APIRouter(tags=["experts"])
|
||||
|
||||
# 团队模板(非单个专家),不在专家选择列表中展示
|
||||
_TEAM_TEMPLATES = frozenset({"private_board", "dev_team"})
|
||||
|
||||
|
||||
def _get_template_registry(request: Request) -> ExpertTemplateRegistry:
|
||||
registry = getattr(request.app.state, "expert_template_registry", None)
|
||||
if registry is None:
|
||||
return ExpertTemplateRegistry()
|
||||
return registry
|
||||
|
||||
|
||||
def _serialize_expert(template) -> dict:
|
||||
"""序列化 ExpertTemplate 为前端可用的精简字典。
|
||||
|
||||
只暴露私董会 UI 需要的字段,避免泄露完整 prompt/llm 配置。
|
||||
"""
|
||||
config = template.config
|
||||
return {
|
||||
"name": template.name,
|
||||
"description": template.description,
|
||||
"is_builtin": template.is_builtin,
|
||||
"avatar": config.avatar,
|
||||
"color": config.color,
|
||||
"persona": config.persona,
|
||||
"thinking_style": config.thinking_style,
|
||||
"speaking_style": config.speaking_style,
|
||||
"decision_framework": config.decision_framework,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/experts")
|
||||
async def list_experts(req: Request):
|
||||
"""列出所有可用的专家模板(排除团队模板)。
|
||||
|
||||
返回的专家可用于私董会 @board 模式的专家选择。
|
||||
"""
|
||||
registry = _get_template_registry(req)
|
||||
experts = [_serialize_expert(t) for t in registry.list() if t.name not in _TEAM_TEMPLATES]
|
||||
return {"experts": experts, "total": len(experts)}
|
||||
|
||||
|
||||
@router.get("/experts/{name}")
|
||||
async def get_expert(name: str, req: Request):
|
||||
"""获取单个专家模板详情。"""
|
||||
registry = _get_template_registry(req)
|
||||
template = registry.get(name)
|
||||
if template is None or name in _TEAM_TEMPLATES:
|
||||
raise HTTPException(status_code=404, detail=f"Expert '{name}' not found")
|
||||
return _serialize_expert(template)
|
||||
|
|
@ -13,6 +13,9 @@ from fastapi import APIRouter, Depends, HTTPException, Request, Security, Upload
|
|||
from fastapi.security import APIKeyHeader, APIKeyQuery
|
||||
from pydantic import BaseModel
|
||||
|
||||
from agentkit.server.auth.dependencies import require_permission
|
||||
from agentkit.server.auth.permissions import Permission
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(tags=["kb-management"])
|
||||
|
|
@ -206,7 +209,10 @@ async def list_sources(req: Request, _auth: None = Depends(_verify_api_key)):
|
|||
|
||||
@router.post("/kb-management/sources", status_code=201)
|
||||
async def add_source(
|
||||
request: AddSourceRequest, req: Request, _auth: None = Depends(_verify_api_key)
|
||||
request: AddSourceRequest,
|
||||
req: Request,
|
||||
_auth: None = Depends(_verify_api_key),
|
||||
_user=Depends(require_permission(Permission.KB_WRITE)),
|
||||
):
|
||||
"""Add a knowledge source."""
|
||||
valid_types = {"local", "feishu", "confluence", "http"}
|
||||
|
|
@ -232,7 +238,12 @@ async def add_source(
|
|||
|
||||
|
||||
@router.delete("/kb-management/sources/{source_id}")
|
||||
async def remove_source(source_id: str, req: Request, _auth: None = Depends(_verify_api_key)):
|
||||
async def remove_source(
|
||||
source_id: str,
|
||||
req: Request,
|
||||
_auth: None = Depends(_verify_api_key),
|
||||
_user=Depends(require_permission(Permission.KB_WRITE)),
|
||||
):
|
||||
"""Remove a knowledge source."""
|
||||
if not _source_store.remove_source(source_id):
|
||||
raise HTTPException(status_code=404, detail=f"Source '{source_id}' not found")
|
||||
|
|
@ -240,7 +251,11 @@ async def remove_source(source_id: str, req: Request, _auth: None = Depends(_ver
|
|||
|
||||
|
||||
@router.post("/kb-management/sources/{source_id}/sync")
|
||||
async def sync_source(source_id: str, _auth: None = Depends(_verify_api_key)):
|
||||
async def sync_source(
|
||||
source_id: str,
|
||||
_auth: None = Depends(_verify_api_key),
|
||||
_user=Depends(require_permission(Permission.KB_WRITE)),
|
||||
):
|
||||
"""Trigger source sync."""
|
||||
source = _source_store.get_source(source_id)
|
||||
if not source:
|
||||
|
|
@ -251,7 +266,10 @@ async def sync_source(source_id: str, _auth: None = Depends(_verify_api_key)):
|
|||
|
||||
@router.put("/kb-management/sources/{source_id}")
|
||||
async def update_source(
|
||||
source_id: str, data: UpdateSourceRequest, _auth: None = Depends(_verify_api_key)
|
||||
source_id: str,
|
||||
data: UpdateSourceRequest,
|
||||
_auth: None = Depends(_verify_api_key),
|
||||
_user=Depends(require_permission(Permission.KB_WRITE)),
|
||||
):
|
||||
"""Update source config."""
|
||||
update_data = {k: v for k, v in data.model_dump().items() if v is not None}
|
||||
|
|
@ -288,7 +306,11 @@ async def list_documents(source_id: str | None = None, _auth: None = Depends(_ve
|
|||
|
||||
|
||||
@router.delete("/kb-management/documents/{document_id}")
|
||||
async def delete_document(document_id: str, _auth: None = Depends(_verify_api_key)):
|
||||
async def delete_document(
|
||||
document_id: str,
|
||||
_auth: None = Depends(_verify_api_key),
|
||||
_user=Depends(require_permission(Permission.KB_WRITE)),
|
||||
):
|
||||
"""Delete a document by ID."""
|
||||
success = _source_store.delete_document(document_id)
|
||||
if not success:
|
||||
|
|
@ -302,6 +324,7 @@ async def upload_document(
|
|||
file: UploadFile = File(...),
|
||||
source_id: str = "",
|
||||
_auth: None = Depends(_verify_api_key),
|
||||
_user=Depends(require_permission(Permission.KB_WRITE)),
|
||||
):
|
||||
"""Upload a document to the knowledge base."""
|
||||
if not file.filename:
|
||||
|
|
@ -365,7 +388,10 @@ async def upload_document(
|
|||
|
||||
@router.post("/kb-management/search")
|
||||
async def search_knowledge(
|
||||
request: SearchRequest, req: Request, _auth: None = Depends(_verify_api_key)
|
||||
request: SearchRequest,
|
||||
req: Request,
|
||||
_auth: None = Depends(_verify_api_key),
|
||||
_user=Depends(require_permission(Permission.KB_QUERY)),
|
||||
):
|
||||
"""Test search/retrieval against the knowledge base."""
|
||||
# Try to use semantic memory if available
|
||||
|
|
|
|||
|
|
@ -0,0 +1,131 @@
|
|||
"""LLM Gateway proxy routes — forwards client requests to LLM providers.
|
||||
|
||||
Supports both non-streaming (`POST /api/v1/llm/chat`) and SSE streaming
|
||||
(`POST /api/v1/llm/chat/stream`) modes.
|
||||
"""
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi.responses import StreamingResponse
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from agentkit.core.exceptions import LLMProviderError, ModelNotFoundError
|
||||
from agentkit.llm.protocol import LLMResponse, StreamChunk, TokenUsage, ToolCall
|
||||
|
||||
router = APIRouter(prefix="/llm", tags=["llm-gateway"])
|
||||
|
||||
|
||||
class LLMChatRequest(BaseModel):
|
||||
"""Request body for LLM chat endpoints."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
messages: list[dict[str, str]] = Field(..., min_length=1)
|
||||
model: str
|
||||
temperature: float = 0.7
|
||||
max_tokens: int = 2000
|
||||
tools: list[dict[str, Any]] | None = None
|
||||
tool_choice: str = "auto"
|
||||
timeout: float | None = None
|
||||
|
||||
|
||||
def _serialize_tool_call(tc: ToolCall) -> dict[str, Any]:
|
||||
return {"id": tc.id, "name": tc.name, "arguments": tc.arguments}
|
||||
|
||||
|
||||
def _serialize_usage(usage: TokenUsage) -> dict[str, int]:
|
||||
return {
|
||||
"prompt_tokens": usage.prompt_tokens,
|
||||
"completion_tokens": usage.completion_tokens,
|
||||
"total_tokens": usage.total_tokens,
|
||||
}
|
||||
|
||||
|
||||
def _serialize_response(response: LLMResponse) -> dict[str, Any]:
|
||||
return {
|
||||
"content": response.content,
|
||||
"model": response.model,
|
||||
"usage": _serialize_usage(response.usage),
|
||||
"tool_calls": [_serialize_tool_call(tc) for tc in response.tool_calls],
|
||||
"latency_ms": response.latency_ms,
|
||||
}
|
||||
|
||||
|
||||
def _serialize_chunk(chunk: StreamChunk) -> dict[str, Any]:
|
||||
payload: dict[str, Any] = {
|
||||
"content": chunk.content,
|
||||
"model": chunk.model,
|
||||
"is_final": chunk.is_final,
|
||||
}
|
||||
if chunk.tool_calls:
|
||||
payload["tool_calls"] = [_serialize_tool_call(tc) for tc in chunk.tool_calls]
|
||||
if chunk.usage is not None:
|
||||
payload["usage"] = _serialize_usage(chunk.usage)
|
||||
return payload
|
||||
|
||||
|
||||
@router.post("/chat")
|
||||
async def chat(request: Request, body: LLMChatRequest) -> dict[str, Any]:
|
||||
"""Non-streaming LLM chat proxy.
|
||||
|
||||
Forwards the request to the configured LLMGateway and returns the
|
||||
serialized LLMResponse.
|
||||
"""
|
||||
gateway = request.app.state.llm_gateway
|
||||
try:
|
||||
response = await gateway.chat(
|
||||
messages=body.messages,
|
||||
model=body.model,
|
||||
tools=body.tools,
|
||||
tool_choice=body.tool_choice,
|
||||
timeout=body.timeout,
|
||||
temperature=body.temperature,
|
||||
max_tokens=body.max_tokens,
|
||||
)
|
||||
except ModelNotFoundError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e)) from e
|
||||
except LLMProviderError as e:
|
||||
raise HTTPException(status_code=502, detail=str(e)) from e
|
||||
return _serialize_response(response)
|
||||
|
||||
|
||||
@router.post("/chat/stream")
|
||||
async def chat_stream(request: Request, body: LLMChatRequest) -> StreamingResponse:
|
||||
"""SSE streaming LLM chat proxy.
|
||||
|
||||
Each StreamChunk is serialized as `data: {json}\\n\\n`. The stream
|
||||
terminates with `data: [DONE]\\n\\n`.
|
||||
"""
|
||||
|
||||
async def event_generator():
|
||||
gateway = request.app.state.llm_gateway
|
||||
try:
|
||||
async for chunk in gateway.chat_stream(
|
||||
messages=body.messages,
|
||||
model=body.model,
|
||||
tools=body.tools,
|
||||
tool_choice=body.tool_choice,
|
||||
timeout=body.timeout,
|
||||
temperature=body.temperature,
|
||||
max_tokens=body.max_tokens,
|
||||
):
|
||||
payload = _serialize_chunk(chunk)
|
||||
yield f"data: {json.dumps(payload, ensure_ascii=False)}\n\n"
|
||||
except ModelNotFoundError as e:
|
||||
error_payload = {"error": "model_not_found", "detail": str(e)}
|
||||
yield f"data: {json.dumps(error_payload, ensure_ascii=False)}\n\n"
|
||||
except LLMProviderError as e:
|
||||
error_payload = {"error": "provider_error", "detail": str(e)}
|
||||
yield f"data: {json.dumps(error_payload, ensure_ascii=False)}\n\n"
|
||||
yield "data: [DONE]\n\n"
|
||||
|
||||
return StreamingResponse(
|
||||
content=event_generator(),
|
||||
media_type="text/event-stream",
|
||||
headers={
|
||||
"Cache-Control": "no-cache",
|
||||
"Connection": "keep-alive",
|
||||
},
|
||||
)
|
||||
|
|
@ -6,6 +6,7 @@ import os
|
|||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import (
|
||||
APIRouter,
|
||||
|
|
@ -38,6 +39,13 @@ logger = logging.getLogger(__name__)
|
|||
|
||||
router = APIRouter(tags=["portal"])
|
||||
|
||||
# Use a project-local SQLite file to avoid read-only sandbox restrictions on ~/.agentkit.
|
||||
_PROJECT_ROOT = Path(__file__).parents[4]
|
||||
_CONVERSATIONS_DB_PATH = Path(
|
||||
os.environ.get("AGENTKIT_CONVERSATIONS_DB", _PROJECT_ROOT / "data" / "conversations.db")
|
||||
)
|
||||
_CONVERSATIONS_DB_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Track background ReAct tasks so they are not garbage-collected mid-execution.
|
||||
# Tasks are removed automatically via add_done_callback when they complete.
|
||||
_running_background_tasks: set[asyncio.Task] = set()
|
||||
|
|
@ -168,7 +176,7 @@ class Conversation:
|
|||
|
||||
# Heartbeat timeout in seconds — 0 disables timeout (for testing)
|
||||
_WS_HEARTBEAT_TIMEOUT = float(os.environ.get("AGENTKIT_WS_TIMEOUT", "120"))
|
||||
_conversation_store = SqliteConversationStore()
|
||||
_conversation_store = SqliteConversationStore(db_path=_CONVERSATIONS_DB_PATH)
|
||||
|
||||
# P1 #9 fix: ReAct event type -> TurnEventType mapping for EQ subscribers.
|
||||
# Preserves the original EQ contract so CLI and other subscribers that
|
||||
|
|
|
|||
|
|
@ -6,9 +6,12 @@ import re
|
|||
from typing import Any
|
||||
|
||||
import yaml
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from pydantic import BaseModel
|
||||
|
||||
from agentkit.server.auth.dependencies import require_permission
|
||||
from agentkit.server.auth.permissions import Permission
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(tags=["settings"])
|
||||
|
|
@ -268,7 +271,11 @@ async def get_llm_settings(request: Request):
|
|||
|
||||
|
||||
@router.put("/settings/llm", response_model=LlmConfigResponse)
|
||||
async def update_llm_settings(request: Request, update: LlmConfigUpdate):
|
||||
async def update_llm_settings(
|
||||
request: Request,
|
||||
update: LlmConfigUpdate,
|
||||
_user=Depends(require_permission(Permission.SYSTEM_CONFIG)),
|
||||
):
|
||||
"""Update LLM config and trigger hot reload via config file write.
|
||||
|
||||
When a new plaintext API key is provided, it is stored in .env (not
|
||||
|
|
@ -369,7 +376,11 @@ async def get_skills_settings(request: Request):
|
|||
|
||||
|
||||
@router.put("/settings/skills", response_model=SkillsConfigResponse)
|
||||
async def update_skills_settings(request: Request, update: SkillsConfigUpdate):
|
||||
async def update_skills_settings(
|
||||
request: Request,
|
||||
update: SkillsConfigUpdate,
|
||||
_user=Depends(require_permission(Permission.SYSTEM_CONFIG)),
|
||||
):
|
||||
"""Update skill paths and trigger hot reload."""
|
||||
config_path = _get_config_path(request)
|
||||
data = _read_yaml_config(config_path)
|
||||
|
|
@ -407,7 +418,11 @@ async def get_kb_settings(request: Request):
|
|||
|
||||
|
||||
@router.put("/settings/kb", response_model=KbConfigResponse)
|
||||
async def update_kb_settings(request: Request, update: KbConfigUpdate):
|
||||
async def update_kb_settings(
|
||||
request: Request,
|
||||
update: KbConfigUpdate,
|
||||
_user=Depends(require_permission(Permission.SYSTEM_CONFIG)),
|
||||
):
|
||||
"""Update KB config and trigger hot reload."""
|
||||
config_path = _get_config_path(request)
|
||||
data = _read_yaml_config(config_path)
|
||||
|
|
@ -444,7 +459,11 @@ async def get_general_settings(request: Request):
|
|||
|
||||
|
||||
@router.put("/settings/general", response_model=GeneralConfigResponse)
|
||||
async def update_general_settings(request: Request, update: GeneralConfigUpdate):
|
||||
async def update_general_settings(
|
||||
request: Request,
|
||||
update: GeneralConfigUpdate,
|
||||
_user=Depends(require_permission(Permission.SYSTEM_CONFIG)),
|
||||
):
|
||||
"""Update general settings and trigger hot reload."""
|
||||
config_path = _get_config_path(request)
|
||||
data = _read_yaml_config(config_path)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,89 @@
|
|||
"""System resource monitoring route — /api/v1/system"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
|
||||
from agentkit.server.auth.dependencies import require_permission
|
||||
from agentkit.server.auth.permissions import Permission
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(tags=["system"])
|
||||
|
||||
|
||||
def _read_meminfo() -> dict[str, int]:
|
||||
"""Read memory info from /proc/meminfo (Linux only)."""
|
||||
values: dict[str, int] = {}
|
||||
try:
|
||||
with open("/proc/meminfo", encoding="utf-8") as f: # noqa: ASYNC230
|
||||
for line in f:
|
||||
parts = line.split(":")
|
||||
if len(parts) == 2:
|
||||
key = parts[0].strip()
|
||||
value = parts[1].strip().split()[0]
|
||||
values[key] = int(value) * 1024 # kB -> bytes
|
||||
except Exception as exc:
|
||||
logger.debug(f"Failed to read /proc/meminfo: {exc}")
|
||||
return values
|
||||
|
||||
|
||||
@router.get(
|
||||
"/system/resources",
|
||||
dependencies=[Depends(require_permission(Permission.SYSTEM_CONFIG))],
|
||||
)
|
||||
async def get_system_resources() -> dict[str, Any]:
|
||||
"""Return lightweight system resource usage.
|
||||
|
||||
Uses only stdlib modules so it works without psutil. Values are best-effort
|
||||
and may be partial on platforms without /proc/meminfo.
|
||||
"""
|
||||
now = time.time()
|
||||
|
||||
cpu_count = os.cpu_count() or 1
|
||||
loadavg = None
|
||||
if hasattr(os, "getloadavg"):
|
||||
try:
|
||||
loadavg = list(os.getloadavg())
|
||||
except Exception as exc:
|
||||
logger.debug(f"Failed to get loadavg: {exc}")
|
||||
|
||||
meminfo = _read_meminfo()
|
||||
total_mem = meminfo.get("MemTotal", 0)
|
||||
free_mem = meminfo.get("MemAvailable") or meminfo.get("MemFree", 0)
|
||||
used_mem = total_mem - free_mem if total_mem else 0
|
||||
|
||||
disk_total = 0
|
||||
disk_used = 0
|
||||
disk_free = 0
|
||||
try:
|
||||
du = shutil.disk_usage("/")
|
||||
disk_total = du.total
|
||||
disk_used = du.used
|
||||
disk_free = du.free
|
||||
except Exception as exc:
|
||||
logger.debug(f"Failed to get disk usage: {exc}")
|
||||
|
||||
return {
|
||||
"cpu": {
|
||||
"count": cpu_count,
|
||||
"load_average": loadavg,
|
||||
},
|
||||
"memory": {
|
||||
"total_bytes": total_mem,
|
||||
"used_bytes": used_mem,
|
||||
"free_bytes": free_mem,
|
||||
"percent": round(used_mem / total_mem * 100, 1) if total_mem else None,
|
||||
},
|
||||
"disk": {
|
||||
"total_bytes": disk_total,
|
||||
"used_bytes": disk_used,
|
||||
"free_bytes": disk_free,
|
||||
"percent": round(disk_used / disk_total * 100, 1) if disk_total else None,
|
||||
},
|
||||
"timestamp": now,
|
||||
}
|
||||
|
|
@ -5,6 +5,7 @@ Security model:
|
|||
- Non-whitelist commands require user confirmation via WebSocket.
|
||||
- User can choose to add confirmed command to session whitelist.
|
||||
- Session whitelist is stored in memory only (not persisted).
|
||||
- All endpoints require TERMINAL_LOCAL_USE permission + is_terminal_authorized flag.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
|
@ -20,9 +21,21 @@ import uuid
|
|||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, HTTPException, WebSocket, WebSocketDisconnect
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, WebSocket, WebSocketDisconnect
|
||||
from pydantic import BaseModel
|
||||
|
||||
from agentkit.server.auth.dependencies import require_terminal_authorized
|
||||
from agentkit.server.auth.permissions import Permission, has_permission
|
||||
from agentkit.server.auth.terminal_security import (
|
||||
DECISION_BLOCKED,
|
||||
DECISION_CONFIRMED,
|
||||
DECISION_EXECUTED,
|
||||
DECISION_REJECTED,
|
||||
SafetyDecision,
|
||||
check_command_safety_v2,
|
||||
compute_whitelist_entry,
|
||||
write_audit_log,
|
||||
)
|
||||
from agentkit.tools.pty_session import PTYSession
|
||||
from agentkit.tools.shell import (
|
||||
_SAFE_COMMAND_PREFIXES,
|
||||
|
|
@ -157,6 +170,12 @@ def _check_command_safety(command: str, session_id: str) -> dict[str, Any]:
|
|||
Returns dict with:
|
||||
- safe: bool
|
||||
- reason: str (only when not safe)
|
||||
|
||||
.. deprecated::
|
||||
This function only checks the session whitelist + built-in danger
|
||||
detection. It does NOT consult the DB-backed blocklist or user
|
||||
whitelist. New code should use :func:`check_command_safety_v2`
|
||||
via :func:`_check_safety_with_db`.
|
||||
"""
|
||||
# Check session whitelist first
|
||||
state = _sessions.get(session_id)
|
||||
|
|
@ -180,6 +199,71 @@ def _check_command_safety(command: str, session_id: str) -> dict[str, Any]:
|
|||
return {"safe": False, "reason": "此命令被识别为潜在危险操作,需要用户确认"}
|
||||
|
||||
|
||||
async def _resolve_db_path_from_app(app_state: Any) -> Any:
|
||||
"""Resolve the auth DB path from app.state (or None in dev mode)."""
|
||||
path = getattr(app_state, "auth_db_path", None)
|
||||
if path:
|
||||
from pathlib import Path
|
||||
|
||||
return Path(path)
|
||||
from agentkit.server.auth.models import DEFAULT_AUTH_DB_PATH
|
||||
|
||||
return DEFAULT_AUTH_DB_PATH
|
||||
|
||||
|
||||
async def _check_safety_with_db(
|
||||
command: str,
|
||||
state: TerminalSessionState,
|
||||
user: dict[str, Any] | None,
|
||||
app_state: Any,
|
||||
) -> SafetyDecision:
|
||||
"""Run the three-layer whitelist check with DB access.
|
||||
|
||||
Falls back to the legacy in-memory check if the DB path cannot be
|
||||
resolved (e.g. tests without an auth DB).
|
||||
"""
|
||||
db_path = await _resolve_db_path_from_app(app_state)
|
||||
user_id = user.get("user_id") if user and not user.get("dev_mode") else None
|
||||
|
||||
return await check_command_safety_v2(
|
||||
command,
|
||||
session_id=state.session_id,
|
||||
session_whitelist=state.whitelist,
|
||||
user_id=user_id,
|
||||
db_path=db_path,
|
||||
)
|
||||
|
||||
|
||||
async def _audit(
|
||||
app_state: Any,
|
||||
*,
|
||||
user: dict[str, Any] | None,
|
||||
session_id: str,
|
||||
command: str,
|
||||
decision: str,
|
||||
reason: str | None = None,
|
||||
cwd: str | None = None,
|
||||
exit_code: int | None = None,
|
||||
terminal_mode: str = "local",
|
||||
) -> None:
|
||||
"""Write an audit log entry (best-effort, never raises)."""
|
||||
db_path = await _resolve_db_path_from_app(app_state)
|
||||
user_id = user.get("user_id") if user and not user.get("dev_mode") else None
|
||||
username = user.get("username") if user else None
|
||||
await write_audit_log(
|
||||
db_path,
|
||||
user_id=user_id,
|
||||
username=username,
|
||||
session_id=session_id,
|
||||
command=command,
|
||||
decision=decision,
|
||||
reason=reason,
|
||||
cwd=cwd,
|
||||
exit_code=exit_code,
|
||||
terminal_mode=terminal_mode,
|
||||
)
|
||||
|
||||
|
||||
# ── Request/Response schemas ──────────────────────────────────────────
|
||||
|
||||
|
||||
|
|
@ -214,13 +298,31 @@ class HistoryResponse(BaseModel):
|
|||
|
||||
|
||||
@router.post("/execute", response_model=ExecuteResponse)
|
||||
async def execute_command(request: ExecuteRequest):
|
||||
async def execute_command(
|
||||
request: ExecuteRequest,
|
||||
http_request: Request,
|
||||
user: dict[str, Any] = Depends(require_terminal_authorized),
|
||||
):
|
||||
"""Execute a command. Returns output for safe commands, or confirmation_required for unsafe ones."""
|
||||
state = _get_or_create_session(request.session_id)
|
||||
|
||||
# Security check
|
||||
safety = _check_command_safety(request.command, state.session_id)
|
||||
if not safety["safe"]:
|
||||
# Three-layer whitelist safety check (blocklist → builtin → user → session → danger)
|
||||
decision = await _check_safety_with_db(
|
||||
request.command, state, user, http_request.app.state
|
||||
)
|
||||
|
||||
if not decision.safe:
|
||||
# Audit: denied / blocked
|
||||
await _audit(
|
||||
http_request.app.state,
|
||||
user=user,
|
||||
session_id=state.session_id,
|
||||
command=request.command,
|
||||
decision=decision.decision,
|
||||
reason=decision.reason,
|
||||
cwd=state.cwd,
|
||||
terminal_mode="local",
|
||||
)
|
||||
return ExecuteResponse(
|
||||
session_id=state.session_id,
|
||||
command=request.command,
|
||||
|
|
@ -229,7 +331,7 @@ async def execute_command(request: ExecuteRequest):
|
|||
cwd=state.cwd,
|
||||
duration_ms=0,
|
||||
confirmation_required=True,
|
||||
reason=safety.get("reason", "需要用户确认"),
|
||||
reason=decision.reason or "需要用户确认",
|
||||
)
|
||||
|
||||
# Execute the command
|
||||
|
|
@ -279,6 +381,19 @@ async def execute_command(request: ExecuteRequest):
|
|||
"duration_ms": duration_ms,
|
||||
})
|
||||
|
||||
# Audit: executed (whitelisted) — confirmation flow is WebSocket-only
|
||||
await _audit(
|
||||
http_request.app.state,
|
||||
user=user,
|
||||
session_id=state.session_id,
|
||||
command=request.command,
|
||||
decision=DECISION_EXECUTED,
|
||||
reason=f"matched_layer={decision.matched_layer}",
|
||||
cwd=state.cwd,
|
||||
exit_code=exit_code,
|
||||
terminal_mode="local",
|
||||
)
|
||||
|
||||
return ExecuteResponse(
|
||||
session_id=state.session_id,
|
||||
command=request.command,
|
||||
|
|
@ -290,7 +405,9 @@ async def execute_command(request: ExecuteRequest):
|
|||
|
||||
|
||||
@router.get("/sessions", response_model=dict)
|
||||
async def list_sessions():
|
||||
async def list_sessions(
|
||||
user: dict[str, Any] = Depends(require_terminal_authorized),
|
||||
):
|
||||
"""List active terminal sessions."""
|
||||
sessions = [
|
||||
SessionInfo(
|
||||
|
|
@ -304,7 +421,12 @@ async def list_sessions():
|
|||
|
||||
|
||||
@router.get("/sessions/{session_id}/history", response_model=HistoryResponse)
|
||||
async def get_session_history(session_id: str, limit: int = 100, offset: int = 0):
|
||||
async def get_session_history(
|
||||
session_id: str,
|
||||
limit: int = 100,
|
||||
offset: int = 0,
|
||||
user: dict[str, Any] = Depends(require_terminal_authorized),
|
||||
):
|
||||
"""Get command history for a session."""
|
||||
state = _sessions.get(session_id)
|
||||
if state is None:
|
||||
|
|
@ -315,7 +437,10 @@ async def get_session_history(session_id: str, limit: int = 100, offset: int = 0
|
|||
|
||||
|
||||
@router.delete("/sessions/{session_id}")
|
||||
async def close_session(session_id: str):
|
||||
async def close_session(
|
||||
session_id: str,
|
||||
user: dict[str, Any] = Depends(require_terminal_authorized),
|
||||
):
|
||||
"""Close and remove a terminal session."""
|
||||
if session_id not in _sessions:
|
||||
raise HTTPException(status_code=404, detail=f"Session '{session_id}' not found")
|
||||
|
|
@ -348,19 +473,74 @@ async def terminal_websocket(websocket: WebSocket) -> None:
|
|||
# Authentication (accept first, then check — FastAPI requires accept before close)
|
||||
await websocket.accept()
|
||||
|
||||
# --- Auth + permission check ---
|
||||
# The terminal WebSocket requires TERMINAL_LOCAL_USE permission.
|
||||
# Auth is checked via (in order):
|
||||
# 1. JWT via ?token= query param (preferred for browser clients)
|
||||
# 2. API key via ?api_key= query param (legacy / programmatic clients)
|
||||
# 3. Dev mode (no auth configured) — still requires no permission check,
|
||||
# but terminal is a high-risk endpoint so dev mode is NOT allowed
|
||||
# unless explicitly enabled via app.state.allow_dev_terminal.
|
||||
|
||||
jwt_secret: str | None = getattr(websocket.app.state, "jwt_secret", None)
|
||||
configured_api_key: str | None = None
|
||||
if hasattr(websocket.app.state, "server_config") and websocket.app.state.server_config:
|
||||
configured_api_key = websocket.app.state.server_config.api_key
|
||||
if configured_api_key is None and hasattr(websocket.app.state, "api_key"):
|
||||
configured_api_key = websocket.app.state.api_key
|
||||
|
||||
if configured_api_key:
|
||||
user_payload: dict[str, Any] | None = None
|
||||
auth_ok = False
|
||||
|
||||
# 1. JWT via ?token=
|
||||
jwt_token = websocket.query_params.get("token")
|
||||
if jwt_token and jwt_secret:
|
||||
try:
|
||||
from agentkit.server.auth.jwt_utils import verify_token
|
||||
|
||||
payload = verify_token(jwt_token, jwt_secret)
|
||||
if payload.get("type") == "access":
|
||||
user_payload = {
|
||||
"user_id": payload.get("sub"),
|
||||
"username": payload.get("username"),
|
||||
"role": payload.get("role"),
|
||||
}
|
||||
# Check role permission
|
||||
if has_permission(user_payload, Permission.TERMINAL_LOCAL_USE):
|
||||
auth_ok = True
|
||||
else:
|
||||
await websocket.send_json({
|
||||
"type": "error",
|
||||
"message": "Permission denied: TERMINAL_LOCAL_USE required",
|
||||
})
|
||||
await websocket.close(code=4003, reason="Permission denied")
|
||||
return
|
||||
except Exception:
|
||||
pass # Fall through to API key / dev mode
|
||||
|
||||
# 2. API key via ?api_key=
|
||||
if not auth_ok and configured_api_key:
|
||||
provided = websocket.query_params.get("api_key")
|
||||
if not hmac.compare_digest((provided or "").encode(), configured_api_key.encode()):
|
||||
if hmac.compare_digest((provided or "").encode(), configured_api_key.encode()):
|
||||
auth_ok = True
|
||||
else:
|
||||
await websocket.send_json({"type": "error", "message": "Invalid api_key"})
|
||||
await websocket.close(code=4001, reason="Invalid api_key")
|
||||
return
|
||||
|
||||
# 3. Dev mode — only allowed if explicitly enabled
|
||||
if not auth_ok:
|
||||
allow_dev = getattr(websocket.app.state, "allow_dev_terminal", False)
|
||||
if not jwt_secret and not configured_api_key and allow_dev:
|
||||
auth_ok = True
|
||||
else:
|
||||
await websocket.send_json({
|
||||
"type": "error",
|
||||
"message": "Authentication required",
|
||||
})
|
||||
await websocket.close(code=4001, reason="Authentication required")
|
||||
return
|
||||
|
||||
# Resolve or create session
|
||||
session_id = websocket.query_params.get("session_id")
|
||||
state = _get_or_create_session(session_id)
|
||||
|
|
@ -396,15 +576,42 @@ async def terminal_websocket(websocket: WebSocket) -> None:
|
|||
if not command:
|
||||
continue
|
||||
|
||||
# Security check
|
||||
safety = _check_command_safety(command, state.session_id)
|
||||
if not safety["safe"]:
|
||||
# Three-layer whitelist safety check
|
||||
# (blocklist → builtin → user → session → danger)
|
||||
decision = await _check_safety_with_db(
|
||||
command, state, user_payload, websocket.app.state
|
||||
)
|
||||
|
||||
# Blocklist match: reject outright, no confirmation prompt
|
||||
if decision.decision == DECISION_BLOCKED:
|
||||
await _audit(
|
||||
websocket.app.state,
|
||||
user=user_payload,
|
||||
session_id=state.session_id,
|
||||
command=command,
|
||||
decision=DECISION_BLOCKED,
|
||||
reason=decision.reason,
|
||||
cwd=state.cwd,
|
||||
terminal_mode="local",
|
||||
)
|
||||
await websocket.send_json({
|
||||
"type": "result",
|
||||
"command": command,
|
||||
"exit_code": 126,
|
||||
"output": f"命令被黑名单拒绝: {decision.reason or ''}",
|
||||
"cwd": state.cwd,
|
||||
"duration_ms": 0,
|
||||
})
|
||||
continue
|
||||
|
||||
# Dangerous command: require user confirmation
|
||||
if not decision.safe:
|
||||
confirmation_id = str(uuid.uuid4())
|
||||
await websocket.send_json({
|
||||
"type": "confirmation_required",
|
||||
"confirmation_id": confirmation_id,
|
||||
"command": command,
|
||||
"reason": safety.get("reason", "需要用户确认"),
|
||||
"reason": decision.reason or "需要用户确认",
|
||||
})
|
||||
# Wait for confirmation by reading messages inline
|
||||
# (cannot await a future because the main loop is blocked)
|
||||
|
|
@ -446,6 +653,17 @@ async def terminal_websocket(websocket: WebSocket) -> None:
|
|||
pending_confirmations.pop(confirmation_id, None)
|
||||
|
||||
if not approved:
|
||||
# Audit: user rejected the confirmation
|
||||
await _audit(
|
||||
websocket.app.state,
|
||||
user=user_payload,
|
||||
session_id=state.session_id,
|
||||
command=command,
|
||||
decision=DECISION_REJECTED,
|
||||
reason="用户拒绝确认",
|
||||
cwd=state.cwd,
|
||||
terminal_mode="local",
|
||||
)
|
||||
await websocket.send_json({
|
||||
"type": "result",
|
||||
"command": command,
|
||||
|
|
@ -458,17 +676,21 @@ async def terminal_websocket(websocket: WebSocket) -> None:
|
|||
|
||||
# Add to session whitelist if requested
|
||||
if add_to_whitelist_flag:
|
||||
try:
|
||||
tokens = shlex.split(command)
|
||||
if tokens:
|
||||
binary = os.path.basename(tokens[0]).lower()
|
||||
if binary in _DANGEROUS_BINARY_FLAGS:
|
||||
safe_prefix = tokens[0]
|
||||
state.whitelist.add(safe_prefix)
|
||||
else:
|
||||
state.whitelist.add(binary)
|
||||
except ValueError:
|
||||
pass
|
||||
entry = compute_whitelist_entry(command)
|
||||
if entry:
|
||||
state.whitelist.add(entry)
|
||||
|
||||
# Audit: user confirmed — will execute
|
||||
await _audit(
|
||||
websocket.app.state,
|
||||
user=user_payload,
|
||||
session_id=state.session_id,
|
||||
command=command,
|
||||
decision=DECISION_CONFIRMED,
|
||||
reason=f"matched_layer={decision.matched_layer}",
|
||||
cwd=state.cwd,
|
||||
terminal_mode="local",
|
||||
)
|
||||
|
||||
# Execute command with streaming output
|
||||
start_time = time.monotonic()
|
||||
|
|
@ -532,6 +754,23 @@ async def terminal_websocket(websocket: WebSocket) -> None:
|
|||
"duration_ms": duration_ms,
|
||||
})
|
||||
|
||||
# Audit: command executed (either whitelisted or confirmed)
|
||||
# If the command was confirmed above, we already wrote a
|
||||
# DECISION_CONFIRMED audit entry. For whitelisted commands
|
||||
# (no confirmation needed), write DECISION_EXECUTED here.
|
||||
if decision.safe:
|
||||
await _audit(
|
||||
websocket.app.state,
|
||||
user=user_payload,
|
||||
session_id=state.session_id,
|
||||
command=command,
|
||||
decision=DECISION_EXECUTED,
|
||||
reason=f"matched_layer={decision.matched_layer}",
|
||||
cwd=state.cwd,
|
||||
exit_code=result.exit_code,
|
||||
terminal_mode="local",
|
||||
)
|
||||
|
||||
await websocket.send_json({
|
||||
"type": "result",
|
||||
"command": command,
|
||||
|
|
@ -579,17 +818,9 @@ async def terminal_websocket(websocket: WebSocket) -> None:
|
|||
# Add to whitelist if approved and requested
|
||||
if approved and add_to_whitelist:
|
||||
command = msg.get("command", "")
|
||||
try:
|
||||
tokens = shlex.split(command)
|
||||
if tokens:
|
||||
binary = os.path.basename(tokens[0]).lower()
|
||||
if binary in _DANGEROUS_BINARY_FLAGS:
|
||||
safe_prefix = tokens[0]
|
||||
state.whitelist.add(safe_prefix)
|
||||
else:
|
||||
state.whitelist.add(binary)
|
||||
except ValueError:
|
||||
pass
|
||||
entry = compute_whitelist_entry(command)
|
||||
if entry:
|
||||
state.whitelist.add(entry)
|
||||
|
||||
elif msg_type == "input":
|
||||
# Send input to active PTY
|
||||
|
|
|
|||
|
|
@ -0,0 +1,970 @@
|
|||
"""Server-side terminal — PTY runs on the server, non-whitelisted commands
|
||||
require admin approval.
|
||||
|
||||
Endpoints:
|
||||
- WS /api/v1/terminal/server/ws — Server PTY WebSocket
|
||||
- GET /api/v1/terminal/approvals — List pending approvals (admin)
|
||||
- POST /api/v1/terminal/approvals/{id}/approve — Approve a request (admin)
|
||||
- POST /api/v1/terminal/approvals/{id}/reject — Reject a request (admin)
|
||||
- GET /api/v1/terminal/whitelist/global — List global whitelist (admin)
|
||||
- POST /api/v1/terminal/whitelist/global — Add global whitelist entry (admin)
|
||||
- DELETE /api/v1/terminal/whitelist/global/{id} — Remove global entry (admin)
|
||||
|
||||
Security model:
|
||||
- Requires TERMINAL_SERVER_USE permission + is_server_terminal_authorized flag
|
||||
- Command flow: blocklist → builtin → global whitelist → user whitelist
|
||||
→ session whitelist → danger check → ADMIN APPROVAL (not user confirmation)
|
||||
- Approvals expire after 5 minutes
|
||||
- All commands (executed/approved/rejected/blocked) are audit-logged
|
||||
- Session isolation: each user gets an independent PTY + working directory
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import aiosqlite
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, WebSocket, WebSocketDisconnect
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from agentkit.server.auth.dependencies import (
|
||||
require_permission,
|
||||
)
|
||||
from agentkit.server.auth.permissions import Permission, has_permission
|
||||
from agentkit.server.auth.terminal_security import (
|
||||
DECISION_BLOCKED,
|
||||
DECISION_CONFIRMED,
|
||||
DECISION_EXECUTED,
|
||||
DECISION_REJECTED,
|
||||
check_command_safety_v2,
|
||||
write_audit_log,
|
||||
)
|
||||
from agentkit.tools.pty_session import PTYSession
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/terminal", tags=["terminal-server"])
|
||||
|
||||
# Approval timeout: 5 minutes
|
||||
APPROVAL_TIMEOUT_SECONDS = 300
|
||||
|
||||
# Maximum concurrent server-terminal sessions
|
||||
MAX_CONCURRENT_SESSIONS = 20
|
||||
|
||||
# Session idle timeout: 30 minutes
|
||||
SESSION_IDLE_TIMEOUT = 1800
|
||||
|
||||
|
||||
# ── In-memory session store (separate from local terminal sessions) ──
|
||||
|
||||
|
||||
@dataclass
|
||||
class ServerTerminalSession:
|
||||
"""Per-user server-terminal session."""
|
||||
|
||||
session_id: str
|
||||
user_id: str
|
||||
username: str
|
||||
pty: PTYSession | None = None
|
||||
cwd: str = ""
|
||||
created_at: float = field(default_factory=time.time)
|
||||
last_activity: float = field(default_factory=time.time)
|
||||
whitelist: set[str] = field(default_factory=set)
|
||||
history: list[dict[str, Any]] = field(default_factory=list)
|
||||
|
||||
|
||||
# session_id → ServerTerminalSession
|
||||
_server_sessions: dict[str, ServerTerminalSession] = {}
|
||||
# user_id → session_id (one session per user for isolation)
|
||||
_user_sessions: dict[str, str] = {}
|
||||
|
||||
# Pending approvals: approval_id → asyncio.Future[bool] (True=approved)
|
||||
_pending_approvals: dict[str, asyncio.Future[bool]] = {}
|
||||
# Pending approval ownership: approval_id → session_id (for cancel ownership check)
|
||||
_approval_owners: dict[str, str] = {}
|
||||
|
||||
|
||||
def _now_iso() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
def _resolve_db(request: Request) -> Path:
|
||||
"""Resolve the auth DB path from app.state or default."""
|
||||
path = getattr(request.app.state, "auth_db_path", None)
|
||||
if path:
|
||||
return Path(path)
|
||||
from agentkit.server.auth.models import DEFAULT_AUTH_DB_PATH
|
||||
|
||||
return DEFAULT_AUTH_DB_PATH
|
||||
|
||||
|
||||
async def _resolve_db_ws(app_state: Any) -> Path:
|
||||
"""Resolve the auth DB path from app.state (for WebSocket context)."""
|
||||
path = getattr(app_state, "auth_db_path", None)
|
||||
if path:
|
||||
return Path(path)
|
||||
from agentkit.server.auth.models import DEFAULT_AUTH_DB_PATH
|
||||
|
||||
return DEFAULT_AUTH_DB_PATH
|
||||
|
||||
|
||||
# ── Approval helpers ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
async def _create_approval_request(
|
||||
db_path: Path,
|
||||
*,
|
||||
user_id: str,
|
||||
username: str,
|
||||
session_id: str,
|
||||
command: str,
|
||||
reason: str | None,
|
||||
) -> str:
|
||||
"""Create a pending approval request in the DB. Returns the approval ID."""
|
||||
approval_id = str(uuid.uuid4())
|
||||
expires_at = datetime.now(timezone.utc).timestamp() + APPROVAL_TIMEOUT_SECONDS
|
||||
expires_iso = datetime.fromtimestamp(expires_at, tz=timezone.utc).isoformat()
|
||||
|
||||
async with aiosqlite.connect(str(db_path)) as db:
|
||||
await db.execute(
|
||||
"""INSERT INTO terminal_approvals
|
||||
(id, user_id, username, session_id, command, reason, status,
|
||||
created_at, expires_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, 'pending', ?, ?)""",
|
||||
(approval_id, user_id, username, session_id, command[:4096], reason, _now_iso(), expires_iso),
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
return approval_id
|
||||
|
||||
|
||||
async def _get_approval(db_path: Path, approval_id: str) -> dict[str, Any] | None:
|
||||
"""Fetch an approval record by ID."""
|
||||
async with aiosqlite.connect(str(db_path)) as db:
|
||||
db.row_factory = aiosqlite.Row
|
||||
cursor = await db.execute(
|
||||
"SELECT * FROM terminal_approvals WHERE id = ?",
|
||||
(approval_id,),
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
if row is None:
|
||||
return None
|
||||
return dict(row)
|
||||
|
||||
|
||||
async def _update_approval_status(
|
||||
db_path: Path,
|
||||
approval_id: str,
|
||||
*,
|
||||
status: str,
|
||||
reviewer_id: str | None,
|
||||
reviewer_username: str | None,
|
||||
review_note: str | None = None,
|
||||
) -> bool:
|
||||
"""Update an approval's status. Returns True if updated, False if not found
|
||||
or already reviewed."""
|
||||
async with aiosqlite.connect(str(db_path)) as db:
|
||||
cursor = await db.execute(
|
||||
"UPDATE terminal_approvals SET status = ?, reviewer_id = ?, "
|
||||
"reviewer_username = ?, review_note = ?, reviewed_at = ? "
|
||||
"WHERE id = ? AND status = 'pending'",
|
||||
(status, reviewer_id, reviewer_username, review_note, _now_iso(), approval_id),
|
||||
)
|
||||
await db.commit()
|
||||
return cursor.rowcount > 0
|
||||
|
||||
|
||||
async def _expire_stale_approvals(db_path: Path) -> int:
|
||||
"""Mark expired approvals as 'expired'. Returns count of expired records."""
|
||||
now_iso = _now_iso()
|
||||
async with aiosqlite.connect(str(db_path)) as db:
|
||||
cursor = await db.execute(
|
||||
"UPDATE terminal_approvals SET status = 'expired', reviewed_at = ? "
|
||||
"WHERE status = 'pending' AND expires_at < ?",
|
||||
(now_iso, now_iso),
|
||||
)
|
||||
await db.commit()
|
||||
return cursor.rowcount
|
||||
|
||||
|
||||
# ── Schemas ───────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class ApprovalInfo(BaseModel):
|
||||
id: str
|
||||
user_id: str
|
||||
username: str
|
||||
session_id: str
|
||||
command: str
|
||||
reason: str | None = None
|
||||
status: str
|
||||
reviewer_id: str | None = None
|
||||
reviewer_username: str | None = None
|
||||
review_note: str | None = None
|
||||
created_at: str
|
||||
reviewed_at: str | None = None
|
||||
expires_at: str
|
||||
|
||||
|
||||
class ApprovalListResponse(BaseModel):
|
||||
approvals: list[ApprovalInfo]
|
||||
total: int
|
||||
|
||||
|
||||
class ReviewRequest(BaseModel):
|
||||
note: str | None = Field(None, max_length=512)
|
||||
|
||||
|
||||
class GlobalWhitelistEntry(BaseModel):
|
||||
id: str
|
||||
command_pattern: str
|
||||
created_at: str
|
||||
|
||||
|
||||
class GlobalWhitelistListResponse(BaseModel):
|
||||
entries: list[GlobalWhitelistEntry]
|
||||
|
||||
|
||||
class AddGlobalWhitelistRequest(BaseModel):
|
||||
command_pattern: str = Field(..., min_length=1, max_length=256)
|
||||
|
||||
|
||||
# ── Approval management endpoints (admin only) ────────────────────────
|
||||
|
||||
|
||||
@router.get(
|
||||
"/approvals",
|
||||
response_model=ApprovalListResponse,
|
||||
dependencies=[Depends(require_permission(Permission.TERMINAL_WHITELIST_MANAGE))],
|
||||
)
|
||||
async def list_approvals(
|
||||
request: Request,
|
||||
status: str | None = None,
|
||||
limit: int = 100,
|
||||
offset: int = 0,
|
||||
):
|
||||
"""List terminal approval requests (admin only).
|
||||
|
||||
Filter by status: pending, approved, rejected, expired, cancelled.
|
||||
"""
|
||||
db_path = _resolve_db(request)
|
||||
|
||||
# Expire stale approvals first
|
||||
await _expire_stale_approvals(db_path)
|
||||
|
||||
where_sql = " WHERE status = ?" if status else ""
|
||||
params: list[Any] = [status] if status else []
|
||||
|
||||
async with aiosqlite.connect(str(db_path)) as db:
|
||||
db.row_factory = aiosqlite.Row
|
||||
|
||||
count_cursor = await db.execute(
|
||||
f"SELECT COUNT(*) FROM terminal_approvals{where_sql}",
|
||||
params,
|
||||
)
|
||||
total = (await count_cursor.fetchone())[0]
|
||||
|
||||
cursor = await db.execute(
|
||||
f"""SELECT * FROM terminal_approvals{where_sql}
|
||||
ORDER BY created_at DESC LIMIT ? OFFSET ?""",
|
||||
[*params, limit, offset],
|
||||
)
|
||||
rows = await cursor.fetchall()
|
||||
|
||||
return ApprovalListResponse(
|
||||
approvals=[
|
||||
ApprovalInfo(
|
||||
id=row["id"],
|
||||
user_id=row["user_id"],
|
||||
username=row["username"],
|
||||
session_id=row["session_id"],
|
||||
command=row["command"],
|
||||
reason=row["reason"],
|
||||
status=row["status"],
|
||||
reviewer_id=row["reviewer_id"],
|
||||
reviewer_username=row["reviewer_username"],
|
||||
review_note=row["review_note"],
|
||||
created_at=row["created_at"],
|
||||
reviewed_at=row["reviewed_at"],
|
||||
expires_at=row["expires_at"],
|
||||
)
|
||||
for row in rows
|
||||
],
|
||||
total=total,
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/approvals/{approval_id}/approve",
|
||||
response_model=ApprovalInfo,
|
||||
dependencies=[Depends(require_permission(Permission.TERMINAL_WHITELIST_MANAGE))],
|
||||
)
|
||||
async def approve_request(
|
||||
approval_id: str,
|
||||
request: Request,
|
||||
payload: ReviewRequest | None = None,
|
||||
):
|
||||
"""Approve a pending terminal command request (admin only)."""
|
||||
db_path = _resolve_db(request)
|
||||
user = getattr(request.state, "current_user", None) or {}
|
||||
|
||||
# Expire stale approvals
|
||||
await _expire_stale_approvals(db_path)
|
||||
|
||||
note = payload.note if payload else None
|
||||
updated = await _update_approval_status(
|
||||
db_path,
|
||||
approval_id,
|
||||
status="approved",
|
||||
reviewer_id=user.get("user_id"),
|
||||
reviewer_username=user.get("username"),
|
||||
review_note=note,
|
||||
)
|
||||
|
||||
if not updated:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail="Approval not found or already reviewed",
|
||||
)
|
||||
|
||||
# Notify the waiting WebSocket
|
||||
future = _pending_approvals.get(approval_id)
|
||||
if future and not future.done():
|
||||
future.set_result(True)
|
||||
_pending_approvals.pop(approval_id, None)
|
||||
_approval_owners.pop(approval_id, None)
|
||||
|
||||
approval = await _get_approval(db_path, approval_id)
|
||||
return ApprovalInfo(**approval)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/approvals/{approval_id}/reject",
|
||||
response_model=ApprovalInfo,
|
||||
dependencies=[Depends(require_permission(Permission.TERMINAL_WHITELIST_MANAGE))],
|
||||
)
|
||||
async def reject_request(
|
||||
approval_id: str,
|
||||
request: Request,
|
||||
payload: ReviewRequest | None = None,
|
||||
):
|
||||
"""Reject a pending terminal command request (admin only)."""
|
||||
db_path = _resolve_db(request)
|
||||
user = getattr(request.state, "current_user", None) or {}
|
||||
|
||||
await _expire_stale_approvals(db_path)
|
||||
|
||||
note = payload.note if payload else None
|
||||
updated = await _update_approval_status(
|
||||
db_path,
|
||||
approval_id,
|
||||
status="rejected",
|
||||
reviewer_id=user.get("user_id"),
|
||||
reviewer_username=user.get("username"),
|
||||
review_note=note,
|
||||
)
|
||||
|
||||
if not updated:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail="Approval not found or already reviewed",
|
||||
)
|
||||
|
||||
# Notify the waiting WebSocket
|
||||
future = _pending_approvals.get(approval_id)
|
||||
if future and not future.done():
|
||||
future.set_result(False)
|
||||
_pending_approvals.pop(approval_id, None)
|
||||
_approval_owners.pop(approval_id, None)
|
||||
|
||||
approval = await _get_approval(db_path, approval_id)
|
||||
return ApprovalInfo(**approval)
|
||||
|
||||
|
||||
# ── Global whitelist endpoints (admin only) ───────────────────────────
|
||||
|
||||
|
||||
@router.get(
|
||||
"/whitelist/global",
|
||||
response_model=GlobalWhitelistListResponse,
|
||||
dependencies=[Depends(require_permission(Permission.TERMINAL_WHITELIST_MANAGE))],
|
||||
)
|
||||
async def list_global_whitelist(request: Request):
|
||||
"""List the global (admin-managed) terminal whitelist."""
|
||||
db_path = _resolve_db(request)
|
||||
|
||||
async with aiosqlite.connect(str(db_path)) as db:
|
||||
db.row_factory = aiosqlite.Row
|
||||
cursor = await db.execute(
|
||||
"SELECT id, command_pattern, created_at "
|
||||
"FROM terminal_whitelist_user WHERE scope = 'global' "
|
||||
"ORDER BY created_at DESC"
|
||||
)
|
||||
rows = await cursor.fetchall()
|
||||
|
||||
return GlobalWhitelistListResponse(
|
||||
entries=[
|
||||
GlobalWhitelistEntry(
|
||||
id=row["id"],
|
||||
command_pattern=row["command_pattern"],
|
||||
created_at=row["created_at"],
|
||||
)
|
||||
for row in rows
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/whitelist/global",
|
||||
response_model=GlobalWhitelistEntry,
|
||||
status_code=201,
|
||||
dependencies=[Depends(require_permission(Permission.TERMINAL_WHITELIST_MANAGE))],
|
||||
)
|
||||
async def add_global_whitelist(payload: AddGlobalWhitelistRequest, request: Request):
|
||||
"""Add a command pattern to the global whitelist (admin only)."""
|
||||
db_path = _resolve_db(request)
|
||||
user = getattr(request.state, "current_user", None) or {}
|
||||
entry_id = str(uuid.uuid4())
|
||||
|
||||
async with aiosqlite.connect(str(db_path)) as db:
|
||||
# Check for duplicate
|
||||
cursor = await db.execute(
|
||||
"SELECT id FROM terminal_whitelist_user "
|
||||
"WHERE command_pattern = ? AND scope = 'global'",
|
||||
(payload.command_pattern,),
|
||||
)
|
||||
existing = await cursor.fetchone()
|
||||
if existing:
|
||||
raise HTTPException(status_code=409, detail="Pattern already exists in global whitelist")
|
||||
|
||||
await db.execute(
|
||||
"""INSERT INTO terminal_whitelist_user
|
||||
(id, user_id, command_pattern, scope, created_at, created_by)
|
||||
VALUES (?, ?, ?, 'global', ?, ?)""",
|
||||
(entry_id, user.get("user_id") or "system", payload.command_pattern, _now_iso(),
|
||||
user.get("user_id")),
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
db.row_factory = aiosqlite.Row
|
||||
cursor = await db.execute(
|
||||
"SELECT id, command_pattern, created_at "
|
||||
"FROM terminal_whitelist_user WHERE id = ?",
|
||||
(entry_id,),
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
|
||||
return GlobalWhitelistEntry(
|
||||
id=row["id"],
|
||||
command_pattern=row["command_pattern"],
|
||||
created_at=row["created_at"],
|
||||
)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/whitelist/global/{entry_id}",
|
||||
status_code=204,
|
||||
dependencies=[Depends(require_permission(Permission.TERMINAL_WHITELIST_MANAGE))],
|
||||
)
|
||||
async def delete_global_whitelist(entry_id: str, request: Request):
|
||||
"""Remove a pattern from the global whitelist (admin only)."""
|
||||
db_path = _resolve_db(request)
|
||||
|
||||
async with aiosqlite.connect(str(db_path)) as db:
|
||||
cursor = await db.execute(
|
||||
"DELETE FROM terminal_whitelist_user WHERE id = ? AND scope = 'global'",
|
||||
(entry_id,),
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
if cursor.rowcount == 0:
|
||||
raise HTTPException(status_code=404, detail="Global whitelist entry not found")
|
||||
|
||||
|
||||
# ── Server-terminal WebSocket ─────────────────────────────────────────
|
||||
|
||||
|
||||
@router.websocket("/server/ws")
|
||||
async def server_terminal_websocket(websocket: WebSocket) -> None:
|
||||
"""WebSocket endpoint for server-side terminal.
|
||||
|
||||
The PTY runs on the server. Non-whitelisted dangerous commands
|
||||
require admin approval (not just user confirmation).
|
||||
|
||||
Client → Server messages:
|
||||
{"type": "execute", "command": "..."} — Execute a command
|
||||
{"type": "cancel_approval", "approval_id": "..."} — Cancel pending approval
|
||||
{"type": "input", "data": "..."} — Send input to running PTY
|
||||
{"type": "ping"} — Heartbeat
|
||||
|
||||
Server → Client messages:
|
||||
{"type": "connected", "session_id": "..."} — Connection confirmed
|
||||
{"type": "output", "data": "..."} — Terminal output chunk
|
||||
{"type": "result", "command": "...", ...} — Command completed
|
||||
{"type": "approval_required", "approval_id": "...", "command": "...", "reason": "..."}
|
||||
— Need admin approval
|
||||
{"type": "approval_approved", "approval_id": "..."} — Admin approved
|
||||
{"type": "approval_rejected", "approval_id": "...", "reason": "..."} — Admin rejected
|
||||
{"type": "approval_expired", "approval_id": "..."} — Approval timed out
|
||||
{"type": "error", "message": "..."} — Error occurred
|
||||
{"type": "pong"} — Heartbeat response
|
||||
"""
|
||||
await websocket.accept()
|
||||
|
||||
# ── Authentication ────────────────────────────────────────────
|
||||
jwt_secret: str | None = getattr(websocket.app.state, "jwt_secret", None)
|
||||
|
||||
user_payload: dict[str, Any] | None = None
|
||||
auth_ok = False
|
||||
|
||||
# JWT via ?token=
|
||||
jwt_token = websocket.query_params.get("token")
|
||||
if jwt_token and jwt_secret:
|
||||
try:
|
||||
from agentkit.server.auth.jwt_utils import verify_token
|
||||
|
||||
payload = verify_token(jwt_token, jwt_secret)
|
||||
if payload.get("type") == "access":
|
||||
user_payload = {
|
||||
"user_id": payload.get("sub"),
|
||||
"username": payload.get("username"),
|
||||
"role": payload.get("role"),
|
||||
}
|
||||
# Check TERMINAL_SERVER_USE permission
|
||||
if has_permission(user_payload, Permission.TERMINAL_SERVER_USE):
|
||||
auth_ok = True
|
||||
else:
|
||||
await websocket.send_json({
|
||||
"type": "error",
|
||||
"message": "Permission denied: TERMINAL_SERVER_USE required",
|
||||
})
|
||||
await websocket.close(code=4003, reason="Permission denied")
|
||||
return
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if not auth_ok:
|
||||
# Dev mode — only if explicitly allowed
|
||||
allow_dev = getattr(websocket.app.state, "allow_dev_terminal", False)
|
||||
if not jwt_secret and allow_dev:
|
||||
user_payload = {"user_id": "dev", "username": "dev", "role": "admin", "dev_mode": True}
|
||||
auth_ok = True
|
||||
else:
|
||||
await websocket.send_json({
|
||||
"type": "error",
|
||||
"message": "Authentication required for server terminal",
|
||||
})
|
||||
await websocket.close(code=4001, reason="Authentication required")
|
||||
return
|
||||
|
||||
# ── Check is_server_terminal_authorized flag (skip in dev mode) ──
|
||||
if not user_payload.get("dev_mode"):
|
||||
user_id = user_payload.get("user_id")
|
||||
if user_id:
|
||||
db_path = await _resolve_db_ws(websocket.app.state)
|
||||
try:
|
||||
async with aiosqlite.connect(str(db_path)) as db:
|
||||
cursor = await db.execute(
|
||||
"SELECT is_server_terminal_authorized FROM users WHERE id = ?",
|
||||
(user_id,),
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
if row is None or not bool(row[0]):
|
||||
await websocket.send_json({
|
||||
"type": "error",
|
||||
"message": "Server terminal access not authorized for this account",
|
||||
})
|
||||
await websocket.close(code=4003, reason="Not authorized")
|
||||
return
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to check server terminal authorization: {e}")
|
||||
# If DB check fails, deny access
|
||||
await websocket.send_json({
|
||||
"type": "error",
|
||||
"message": "Failed to verify server terminal authorization",
|
||||
})
|
||||
await websocket.close(code=500, reason="Authorization check failed")
|
||||
return
|
||||
|
||||
# ── Session management ────────────────────────────────────────
|
||||
user_id = user_payload["user_id"]
|
||||
username = user_payload.get("username", "unknown")
|
||||
|
||||
# Check concurrent session limit
|
||||
if len(_server_sessions) >= MAX_CONCURRENT_SESSIONS and user_id not in _user_sessions:
|
||||
await websocket.send_json({
|
||||
"type": "error",
|
||||
"message": f"Maximum concurrent sessions ({MAX_CONCURRENT_SESSIONS}) reached",
|
||||
})
|
||||
await websocket.close(code=503, reason="Server busy")
|
||||
return
|
||||
|
||||
# Reuse existing session for this user, or create a new one
|
||||
session_id = _user_sessions.get(user_id)
|
||||
if session_id and session_id in _server_sessions:
|
||||
state = _server_sessions[session_id]
|
||||
else:
|
||||
session_id = str(uuid.uuid4())
|
||||
state = ServerTerminalSession(
|
||||
session_id=session_id,
|
||||
user_id=user_id,
|
||||
username=username,
|
||||
cwd=os.getcwd(),
|
||||
)
|
||||
_server_sessions[session_id] = state
|
||||
_user_sessions[user_id] = session_id
|
||||
|
||||
state.last_activity = time.time()
|
||||
|
||||
active_pty: PTYSession | None = None
|
||||
|
||||
try:
|
||||
await websocket.send_json({
|
||||
"type": "connected",
|
||||
"session_id": state.session_id,
|
||||
"terminal_mode": "server",
|
||||
})
|
||||
|
||||
while True:
|
||||
try:
|
||||
raw = await asyncio.wait_for(
|
||||
websocket.receive_text(),
|
||||
timeout=SESSION_IDLE_TIMEOUT,
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
await websocket.send_json({"type": "pong"})
|
||||
continue
|
||||
|
||||
state.last_activity = time.time()
|
||||
|
||||
try:
|
||||
msg = json.loads(raw)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
|
||||
msg_type = msg.get("type")
|
||||
|
||||
if msg_type == "execute":
|
||||
command = msg.get("command", "").strip()
|
||||
if not command:
|
||||
continue
|
||||
|
||||
db_path = await _resolve_db_ws(websocket.app.state)
|
||||
|
||||
# Three-layer whitelist check (with global whitelist layer)
|
||||
decision = await check_command_safety_v2(
|
||||
command,
|
||||
session_id=state.session_id,
|
||||
session_whitelist=state.whitelist,
|
||||
user_id=user_id if not user_payload.get("dev_mode") else None,
|
||||
db_path=db_path,
|
||||
)
|
||||
|
||||
# Blocklist: reject outright
|
||||
if decision.decision == DECISION_BLOCKED:
|
||||
await write_audit_log(
|
||||
db_path,
|
||||
user_id=user_id,
|
||||
username=username,
|
||||
session_id=state.session_id,
|
||||
command=command,
|
||||
decision=DECISION_BLOCKED,
|
||||
reason=decision.reason,
|
||||
cwd=state.cwd,
|
||||
terminal_mode="server",
|
||||
)
|
||||
await websocket.send_json({
|
||||
"type": "result",
|
||||
"command": command,
|
||||
"exit_code": 126,
|
||||
"output": f"命令被黑名单拒绝: {decision.reason or ''}",
|
||||
"cwd": state.cwd,
|
||||
"duration_ms": 0,
|
||||
})
|
||||
continue
|
||||
|
||||
# Dangerous command: require admin approval
|
||||
if not decision.safe:
|
||||
approval_id = await _create_approval_request(
|
||||
db_path,
|
||||
user_id=user_id,
|
||||
username=username,
|
||||
session_id=state.session_id,
|
||||
command=command,
|
||||
reason=decision.reason,
|
||||
)
|
||||
|
||||
await websocket.send_json({
|
||||
"type": "approval_required",
|
||||
"approval_id": approval_id,
|
||||
"command": command,
|
||||
"reason": decision.reason or "需要管理员审批",
|
||||
"expires_in": APPROVAL_TIMEOUT_SECONDS,
|
||||
})
|
||||
|
||||
# Register a future for the approval response
|
||||
loop = asyncio.get_running_loop()
|
||||
approval_future: asyncio.Future[bool] = loop.create_future()
|
||||
_pending_approvals[approval_id] = approval_future
|
||||
_approval_owners[approval_id] = state.session_id
|
||||
|
||||
# Wait for admin response or timeout
|
||||
try:
|
||||
approved = await asyncio.wait_for(
|
||||
approval_future,
|
||||
timeout=APPROVAL_TIMEOUT_SECONDS,
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
approved = False
|
||||
# Mark as expired in DB
|
||||
await _update_approval_status(
|
||||
db_path,
|
||||
approval_id,
|
||||
status="expired",
|
||||
reviewer_id=None,
|
||||
reviewer_username=None,
|
||||
)
|
||||
await websocket.send_json({
|
||||
"type": "approval_expired",
|
||||
"approval_id": approval_id,
|
||||
"command": command,
|
||||
})
|
||||
await write_audit_log(
|
||||
db_path,
|
||||
user_id=user_id,
|
||||
username=username,
|
||||
session_id=state.session_id,
|
||||
command=command,
|
||||
decision="expired",
|
||||
reason="Approval timed out",
|
||||
cwd=state.cwd,
|
||||
terminal_mode="server",
|
||||
)
|
||||
continue
|
||||
finally:
|
||||
_pending_approvals.pop(approval_id, None)
|
||||
_approval_owners.pop(approval_id, None)
|
||||
|
||||
if not approved:
|
||||
await websocket.send_json({
|
||||
"type": "approval_rejected",
|
||||
"approval_id": approval_id,
|
||||
"command": command,
|
||||
"reason": "管理员拒绝",
|
||||
})
|
||||
await write_audit_log(
|
||||
db_path,
|
||||
user_id=user_id,
|
||||
username=username,
|
||||
session_id=state.session_id,
|
||||
command=command,
|
||||
decision=DECISION_REJECTED,
|
||||
reason="Admin rejected",
|
||||
cwd=state.cwd,
|
||||
terminal_mode="server",
|
||||
)
|
||||
continue
|
||||
|
||||
# Approved!
|
||||
await websocket.send_json({
|
||||
"type": "approval_approved",
|
||||
"approval_id": approval_id,
|
||||
"command": command,
|
||||
})
|
||||
|
||||
# Audit: approved
|
||||
await write_audit_log(
|
||||
db_path,
|
||||
user_id=user_id,
|
||||
username=username,
|
||||
session_id=state.session_id,
|
||||
command=command,
|
||||
decision=DECISION_CONFIRMED,
|
||||
reason=f"Admin approved (approval_id={approval_id})",
|
||||
cwd=state.cwd,
|
||||
terminal_mode="server",
|
||||
)
|
||||
|
||||
# Execute command with streaming output
|
||||
start_time = time.monotonic()
|
||||
try:
|
||||
pty = PTYSession()
|
||||
await pty.start()
|
||||
active_pty = pty
|
||||
|
||||
run_task = asyncio.create_task(
|
||||
pty.run_command(
|
||||
command,
|
||||
timeout=msg.get("timeout", 30.0),
|
||||
cwd=state.cwd or None,
|
||||
)
|
||||
)
|
||||
|
||||
# Stream output
|
||||
while not run_task.done():
|
||||
try:
|
||||
chunk = await asyncio.wait_for(
|
||||
pty.read_output(timeout=0.5), timeout=1.0
|
||||
)
|
||||
if chunk:
|
||||
await websocket.send_json({
|
||||
"type": "output",
|
||||
"data": chunk,
|
||||
})
|
||||
except asyncio.TimeoutError:
|
||||
continue
|
||||
except Exception:
|
||||
break
|
||||
|
||||
result = await run_task
|
||||
await pty.close()
|
||||
active_pty = None
|
||||
|
||||
duration_ms = int((time.monotonic() - start_time) * 1000)
|
||||
|
||||
# Update cwd if command was cd — parse directly instead
|
||||
# of spawning a new PTY (which doesn't inherit the cd).
|
||||
if command.strip().startswith("cd "):
|
||||
try:
|
||||
cd_arg = command.strip()[3:].strip()
|
||||
# Handle cd with no args → home directory
|
||||
if not cd_arg or cd_arg == "~":
|
||||
state.cwd = os.path.expanduser("~")
|
||||
elif cd_arg.startswith("~/"):
|
||||
state.cwd = os.path.expanduser(cd_arg)
|
||||
elif os.path.isabs(cd_arg):
|
||||
state.cwd = os.path.normpath(cd_arg)
|
||||
else:
|
||||
base = state.cwd or os.path.expanduser("~")
|
||||
state.cwd = os.path.normpath(os.path.join(base, cd_arg))
|
||||
except Exception:
|
||||
pass # cd parsing failed — keep old cwd
|
||||
|
||||
# Record history
|
||||
state.history.append({
|
||||
"command": command,
|
||||
"exit_code": result.exit_code,
|
||||
"output": result.output[:5000],
|
||||
"cwd": state.cwd,
|
||||
"timestamp": time.time(),
|
||||
"duration_ms": duration_ms,
|
||||
})
|
||||
|
||||
# Audit: executed (if whitelisted, not already audited above)
|
||||
if decision.safe:
|
||||
await write_audit_log(
|
||||
db_path,
|
||||
user_id=user_id,
|
||||
username=username,
|
||||
session_id=state.session_id,
|
||||
command=command,
|
||||
decision=DECISION_EXECUTED,
|
||||
reason=f"matched_layer={decision.matched_layer}",
|
||||
cwd=state.cwd,
|
||||
exit_code=result.exit_code,
|
||||
terminal_mode="server",
|
||||
)
|
||||
|
||||
await websocket.send_json({
|
||||
"type": "result",
|
||||
"command": command,
|
||||
"exit_code": result.exit_code,
|
||||
"output": result.output,
|
||||
"cwd": state.cwd,
|
||||
"duration_ms": duration_ms,
|
||||
})
|
||||
|
||||
except asyncio.TimeoutError:
|
||||
duration_ms = int((time.monotonic() - start_time) * 1000)
|
||||
await websocket.send_json({
|
||||
"type": "result",
|
||||
"command": command,
|
||||
"exit_code": -1,
|
||||
"output": "命令执行超时",
|
||||
"cwd": state.cwd,
|
||||
"duration_ms": duration_ms,
|
||||
})
|
||||
except Exception as e:
|
||||
duration_ms = int((time.monotonic() - start_time) * 1000)
|
||||
await websocket.send_json({
|
||||
"type": "error",
|
||||
"message": str(e)[:200],
|
||||
})
|
||||
finally:
|
||||
if active_pty and active_pty.is_running:
|
||||
try:
|
||||
await active_pty.close()
|
||||
except Exception:
|
||||
pass
|
||||
active_pty = None
|
||||
|
||||
elif msg_type == "cancel_approval":
|
||||
approval_id = msg.get("approval_id")
|
||||
if approval_id and approval_id in _pending_approvals:
|
||||
# Verify ownership: only the user who requested the
|
||||
# approval can cancel it.
|
||||
approval_state = _approval_owners.get(approval_id)
|
||||
if approval_state is not None and approval_state != state.session_id:
|
||||
await websocket.send_json({
|
||||
"type": "error",
|
||||
"message": "Cannot cancel another user's approval",
|
||||
})
|
||||
continue
|
||||
fut = _pending_approvals[approval_id]
|
||||
if not fut.done():
|
||||
fut.set_result(False)
|
||||
_pending_approvals.pop(approval_id, None)
|
||||
_approval_owners.pop(approval_id, None)
|
||||
db_path = await _resolve_db_ws(websocket.app.state)
|
||||
await _update_approval_status(
|
||||
db_path,
|
||||
approval_id,
|
||||
status="cancelled",
|
||||
reviewer_id=user_id,
|
||||
reviewer_username=username,
|
||||
)
|
||||
|
||||
elif msg_type == "input":
|
||||
if active_pty and active_pty.is_running:
|
||||
await active_pty.send(msg.get("data", ""))
|
||||
|
||||
elif msg_type == "ping":
|
||||
await websocket.send_json({"type": "pong"})
|
||||
|
||||
except WebSocketDisconnect:
|
||||
logger.debug(f"Server terminal WebSocket disconnected for session {state.session_id}")
|
||||
except Exception as e:
|
||||
logger.error(f"Server terminal WebSocket error for session {state.session_id}: {e}")
|
||||
try:
|
||||
await websocket.send_json({"type": "error", "message": str(e)[:200]})
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
if active_pty and active_pty.is_running:
|
||||
try:
|
||||
await active_pty.close()
|
||||
except Exception:
|
||||
pass
|
||||
# Clean up session from in-memory stores to prevent leaks
|
||||
_server_sessions.pop(state.session_id, None)
|
||||
_user_sessions.pop(state.user_id, None)
|
||||
# Cancel any pending approvals owned by this session
|
||||
for ap_id in list(_approval_owners.keys()):
|
||||
if _approval_owners.get(ap_id) == state.session_id:
|
||||
fut = _pending_approvals.get(ap_id)
|
||||
if fut is not None and not fut.done():
|
||||
try:
|
||||
fut.set_result(False)
|
||||
except asyncio.InvalidStateError:
|
||||
pass
|
||||
_pending_approvals.pop(ap_id, None)
|
||||
_approval_owners.pop(ap_id, None)
|
||||
|
|
@ -0,0 +1,437 @@
|
|||
"""Terminal whitelist management API.
|
||||
|
||||
Endpoints:
|
||||
- GET /api/v1/terminal/whitelist/user — list current user's whitelist
|
||||
- POST /api/v1/terminal/whitelist/user — add a pattern to user whitelist
|
||||
- DELETE /api/v1/terminal/whitelist/user/{id} — remove a pattern
|
||||
|
||||
- GET /api/v1/terminal/blocklist — list blocklist (admin only)
|
||||
- POST /api/v1/terminal/blocklist — add a pattern (admin only)
|
||||
- DELETE /api/v1/terminal/blocklist/{id} — remove a pattern (admin only)
|
||||
|
||||
- GET /api/v1/terminal/audit-logs — query audit logs (admin only)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
import aiosqlite
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
|
||||
from agentkit.server.auth.dependencies import (
|
||||
_resolve_db_path,
|
||||
get_current_user,
|
||||
require_permission,
|
||||
)
|
||||
from agentkit.server.auth.permissions import Permission
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/terminal", tags=["terminal-whitelist"])
|
||||
|
||||
|
||||
# Whitelist pattern validation: 1-256 chars, no newlines, no shell metachar
|
||||
# chains. We allow spaces (for prefixes like "git push") but reject control
|
||||
# chars and command separators.
|
||||
_PATTERN_RE = re.compile(r"^[^\n\r;&|`$<>]{1,256}$")
|
||||
|
||||
|
||||
# ── Schemas ───────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class WhitelistEntry(BaseModel):
|
||||
id: str
|
||||
command_pattern: str
|
||||
scope: str = "user"
|
||||
created_at: str
|
||||
|
||||
|
||||
class WhitelistListResponse(BaseModel):
|
||||
entries: list[WhitelistEntry]
|
||||
|
||||
|
||||
class AddWhitelistRequest(BaseModel):
|
||||
command_pattern: str = Field(..., min_length=1, max_length=256)
|
||||
|
||||
@field_validator("command_pattern")
|
||||
@classmethod
|
||||
def _validate_pattern(cls, v: str) -> str:
|
||||
v = v.strip()
|
||||
if not v:
|
||||
raise ValueError("command_pattern cannot be empty")
|
||||
if not _PATTERN_RE.match(v):
|
||||
raise ValueError(
|
||||
"command_pattern contains invalid characters "
|
||||
"(newlines, shell separators, or backticks are not allowed)"
|
||||
)
|
||||
return v
|
||||
|
||||
|
||||
class BlocklistEntry(BaseModel):
|
||||
id: str
|
||||
command_pattern: str
|
||||
reason: str | None = None
|
||||
created_at: str
|
||||
|
||||
|
||||
class BlocklistListResponse(BaseModel):
|
||||
entries: list[BlocklistEntry]
|
||||
|
||||
|
||||
class AddBlocklistRequest(BaseModel):
|
||||
command_pattern: str = Field(..., min_length=1, max_length=256)
|
||||
reason: str | None = Field(None, max_length=512)
|
||||
|
||||
@field_validator("command_pattern")
|
||||
@classmethod
|
||||
def _validate_pattern(cls, v: str) -> str:
|
||||
v = v.strip()
|
||||
if not v:
|
||||
raise ValueError("command_pattern cannot be empty")
|
||||
if not _PATTERN_RE.match(v):
|
||||
raise ValueError(
|
||||
"command_pattern contains invalid characters "
|
||||
"(newlines, shell separators, or backticks are not allowed)"
|
||||
)
|
||||
return v
|
||||
|
||||
|
||||
class AuditLogEntry(BaseModel):
|
||||
id: str
|
||||
user_id: str | None = None
|
||||
username: str | None = None
|
||||
session_id: str
|
||||
command: str
|
||||
decision: str
|
||||
reason: str | None = None
|
||||
cwd: str | None = None
|
||||
exit_code: int | None = None
|
||||
terminal_mode: str = "local"
|
||||
created_at: str
|
||||
|
||||
|
||||
class AuditLogListResponse(BaseModel):
|
||||
entries: list[AuditLogEntry]
|
||||
total: int
|
||||
|
||||
|
||||
# ── User whitelist endpoints ──────────────────────────────────────────
|
||||
|
||||
|
||||
@router.get("/whitelist/user", response_model=WhitelistListResponse)
|
||||
async def list_user_whitelist(
|
||||
request: Request,
|
||||
user: dict[str, Any] = Depends(require_permission(Permission.TERMINAL_LOCAL_USE)),
|
||||
):
|
||||
"""List the current user's terminal whitelist."""
|
||||
db_path = await _resolve_db_path(request)
|
||||
|
||||
if user.get("dev_mode"):
|
||||
# Dev mode has no DB-backed whitelist
|
||||
return WhitelistListResponse(entries=[])
|
||||
|
||||
user_id = user["user_id"]
|
||||
async with aiosqlite.connect(str(db_path)) as db:
|
||||
db.row_factory = aiosqlite.Row
|
||||
cursor = await db.execute(
|
||||
"SELECT id, command_pattern, scope, created_at "
|
||||
"FROM terminal_whitelist_user WHERE user_id = ? AND scope = 'user' "
|
||||
"ORDER BY created_at DESC",
|
||||
(user_id,),
|
||||
)
|
||||
rows = await cursor.fetchall()
|
||||
|
||||
return WhitelistListResponse(
|
||||
entries=[
|
||||
WhitelistEntry(
|
||||
id=row["id"],
|
||||
command_pattern=row["command_pattern"],
|
||||
scope=row["scope"],
|
||||
created_at=row["created_at"],
|
||||
)
|
||||
for row in rows
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
@router.post("/whitelist/user", response_model=WhitelistEntry, status_code=201)
|
||||
async def add_user_whitelist(
|
||||
payload: AddWhitelistRequest,
|
||||
request: Request,
|
||||
user: dict[str, Any] = Depends(require_permission(Permission.TERMINAL_LOCAL_USE)),
|
||||
):
|
||||
"""Add a command pattern to the current user's whitelist."""
|
||||
db_path = await _resolve_db_path(request)
|
||||
|
||||
if user.get("dev_mode"):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="User whitelist is not available in dev mode (no authenticated user)",
|
||||
)
|
||||
|
||||
user_id = user["user_id"]
|
||||
entry_id = str(uuid.uuid4())
|
||||
|
||||
async with aiosqlite.connect(str(db_path)) as db:
|
||||
# Check for duplicate
|
||||
cursor = await db.execute(
|
||||
"SELECT id FROM terminal_whitelist_user "
|
||||
"WHERE user_id = ? AND command_pattern = ? AND scope = 'user'",
|
||||
(user_id, payload.command_pattern),
|
||||
)
|
||||
existing = await cursor.fetchone()
|
||||
if existing:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail="Pattern already exists in user whitelist",
|
||||
)
|
||||
|
||||
from agentkit.server.auth.models import _now_iso
|
||||
|
||||
await db.execute(
|
||||
"""INSERT INTO terminal_whitelist_user
|
||||
(id, user_id, command_pattern, scope, created_at, created_by)
|
||||
VALUES (?, ?, ?, 'user', ?, ?)""",
|
||||
(entry_id, user_id, payload.command_pattern, _now_iso(), user_id),
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
db.row_factory = aiosqlite.Row
|
||||
cursor = await db.execute(
|
||||
"SELECT id, command_pattern, scope, created_at "
|
||||
"FROM terminal_whitelist_user WHERE id = ?",
|
||||
(entry_id,),
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
|
||||
return WhitelistEntry(
|
||||
id=row["id"],
|
||||
command_pattern=row["command_pattern"],
|
||||
scope=row["scope"],
|
||||
created_at=row["created_at"],
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/whitelist/user/{entry_id}", status_code=204)
|
||||
async def delete_user_whitelist(
|
||||
entry_id: str,
|
||||
request: Request,
|
||||
user: dict[str, Any] = Depends(require_permission(Permission.TERMINAL_LOCAL_USE)),
|
||||
):
|
||||
"""Remove a pattern from the current user's whitelist."""
|
||||
db_path = await _resolve_db_path(request)
|
||||
|
||||
if user.get("dev_mode"):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="User whitelist is not available in dev mode",
|
||||
)
|
||||
|
||||
user_id = user["user_id"]
|
||||
async with aiosqlite.connect(str(db_path)) as db:
|
||||
cursor = await db.execute(
|
||||
"DELETE FROM terminal_whitelist_user "
|
||||
"WHERE id = ? AND user_id = ? AND scope = 'user'",
|
||||
(entry_id, user_id),
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
if cursor.rowcount == 0:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail="Whitelist entry not found or not owned by current user",
|
||||
)
|
||||
|
||||
|
||||
# ── Blocklist endpoints (admin only) ──────────────────────────────────
|
||||
|
||||
|
||||
@router.get(
|
||||
"/blocklist",
|
||||
response_model=BlocklistListResponse,
|
||||
dependencies=[Depends(require_permission(Permission.TERMINAL_WHITELIST_MANAGE))],
|
||||
)
|
||||
async def list_blocklist(request: Request):
|
||||
"""List the global terminal blocklist."""
|
||||
db_path = await _resolve_db_path(request)
|
||||
|
||||
async with aiosqlite.connect(str(db_path)) as db:
|
||||
db.row_factory = aiosqlite.Row
|
||||
cursor = await db.execute(
|
||||
"SELECT id, command_pattern, reason, created_at "
|
||||
"FROM terminal_blocklist ORDER BY created_at DESC"
|
||||
)
|
||||
rows = await cursor.fetchall()
|
||||
|
||||
return BlocklistListResponse(
|
||||
entries=[
|
||||
BlocklistEntry(
|
||||
id=row["id"],
|
||||
command_pattern=row["command_pattern"],
|
||||
reason=row["reason"],
|
||||
created_at=row["created_at"],
|
||||
)
|
||||
for row in rows
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/blocklist",
|
||||
response_model=BlocklistEntry,
|
||||
status_code=201,
|
||||
dependencies=[Depends(require_permission(Permission.TERMINAL_WHITELIST_MANAGE))],
|
||||
)
|
||||
async def add_blocklist(payload: AddBlocklistRequest, request: Request):
|
||||
"""Add a command pattern to the global blocklist."""
|
||||
db_path = await _resolve_db_path(request)
|
||||
user = await get_current_user(request)
|
||||
entry_id = str(uuid.uuid4())
|
||||
|
||||
async with aiosqlite.connect(str(db_path)) as db:
|
||||
cursor = await db.execute(
|
||||
"SELECT id FROM terminal_blocklist WHERE command_pattern = ?",
|
||||
(payload.command_pattern,),
|
||||
)
|
||||
existing = await cursor.fetchone()
|
||||
if existing:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail="Pattern already exists in blocklist",
|
||||
)
|
||||
|
||||
from agentkit.server.auth.models import _now_iso
|
||||
|
||||
await db.execute(
|
||||
"""INSERT INTO terminal_blocklist
|
||||
(id, command_pattern, reason, created_at, created_by)
|
||||
VALUES (?, ?, ?, ?, ?)""",
|
||||
(
|
||||
entry_id,
|
||||
payload.command_pattern,
|
||||
payload.reason,
|
||||
_now_iso(),
|
||||
user.get("user_id") if user else None,
|
||||
),
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
db.row_factory = aiosqlite.Row
|
||||
cursor = await db.execute(
|
||||
"SELECT id, command_pattern, reason, created_at "
|
||||
"FROM terminal_blocklist WHERE id = ?",
|
||||
(entry_id,),
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
|
||||
return BlocklistEntry(
|
||||
id=row["id"],
|
||||
command_pattern=row["command_pattern"],
|
||||
reason=row["reason"],
|
||||
created_at=row["created_at"],
|
||||
)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/blocklist/{entry_id}",
|
||||
status_code=204,
|
||||
dependencies=[Depends(require_permission(Permission.TERMINAL_WHITELIST_MANAGE))],
|
||||
)
|
||||
async def delete_blocklist(entry_id: str, request: Request):
|
||||
"""Remove a pattern from the global blocklist."""
|
||||
db_path = await _resolve_db_path(request)
|
||||
|
||||
async with aiosqlite.connect(str(db_path)) as db:
|
||||
cursor = await db.execute(
|
||||
"DELETE FROM terminal_blocklist WHERE id = ?",
|
||||
(entry_id,),
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
if cursor.rowcount == 0:
|
||||
raise HTTPException(status_code=404, detail="Blocklist entry not found")
|
||||
|
||||
|
||||
# ── Audit log endpoints (admin only) ──────────────────────────────────
|
||||
|
||||
|
||||
@router.get(
|
||||
"/audit-logs",
|
||||
response_model=AuditLogListResponse,
|
||||
dependencies=[Depends(require_permission(Permission.TERMINAL_WHITELIST_MANAGE))],
|
||||
)
|
||||
async def list_audit_logs(
|
||||
request: Request,
|
||||
user_id: str | None = Query(None, description="Filter by user ID"),
|
||||
session_id: str | None = Query(None, description="Filter by session ID"),
|
||||
decision: str | None = Query(None, description="Filter by decision"),
|
||||
terminal_mode: str | None = Query(
|
||||
None,
|
||||
description="Filter by terminal mode (local/server)",
|
||||
),
|
||||
limit: int = Query(100, ge=1, le=1000),
|
||||
offset: int = Query(0, ge=0),
|
||||
):
|
||||
"""Query terminal audit logs (admin only)."""
|
||||
db_path = await _resolve_db_path(request)
|
||||
|
||||
where_clauses: list[str] = []
|
||||
params: list[Any] = []
|
||||
if user_id is not None:
|
||||
where_clauses.append("user_id = ?")
|
||||
params.append(user_id)
|
||||
if session_id is not None:
|
||||
where_clauses.append("session_id = ?")
|
||||
params.append(session_id)
|
||||
if decision is not None:
|
||||
where_clauses.append("decision = ?")
|
||||
params.append(decision)
|
||||
if terminal_mode is not None:
|
||||
where_clauses.append("terminal_mode = ?")
|
||||
params.append(terminal_mode)
|
||||
|
||||
where_sql = (" WHERE " + " AND ".join(where_clauses)) if where_clauses else ""
|
||||
|
||||
async with aiosqlite.connect(str(db_path)) as db:
|
||||
db.row_factory = aiosqlite.Row
|
||||
|
||||
count_cursor = await db.execute(
|
||||
f"SELECT COUNT(*) FROM terminal_audit_logs{where_sql}",
|
||||
params,
|
||||
)
|
||||
total = (await count_cursor.fetchone())[0]
|
||||
|
||||
cursor = await db.execute(
|
||||
f"""SELECT id, user_id, username, session_id, command, decision,
|
||||
reason, cwd, exit_code, terminal_mode, created_at
|
||||
FROM terminal_audit_logs{where_sql}
|
||||
ORDER BY created_at DESC
|
||||
LIMIT ? OFFSET ?""",
|
||||
[*params, limit, offset],
|
||||
)
|
||||
rows = await cursor.fetchall()
|
||||
|
||||
return AuditLogListResponse(
|
||||
entries=[
|
||||
AuditLogEntry(
|
||||
id=row["id"],
|
||||
user_id=row["user_id"],
|
||||
username=row["username"],
|
||||
session_id=row["session_id"],
|
||||
command=row["command"],
|
||||
decision=row["decision"],
|
||||
reason=row["reason"],
|
||||
cwd=row["cwd"],
|
||||
exit_code=row["exit_code"],
|
||||
terminal_mode=row["terminal_mode"],
|
||||
created_at=row["created_at"],
|
||||
)
|
||||
for row in rows
|
||||
],
|
||||
total=total,
|
||||
)
|
||||
|
|
@ -1 +0,0 @@
|
|||
const F={BACKSPACE:8,TAB:9,ENTER:13,SHIFT:16,CTRL:17,ALT:18,CAPS_LOCK:20,ESC:27,SPACE:32,LEFT:37,UP:38,RIGHT:39,DOWN:40,N:78,P:80,META:91,WIN_KEY_RIGHT:92,CONTEXT_MENU:93,F1:112,F2:113,F3:114,F4:115,F5:116,F6:117,F7:118,F8:119,F9:120,F10:121,F11:122,F12:123,SEMICOLON:186,EQUALS:187,WIN_KEY:224};export{F as K};
|
||||
|
|
@ -1 +0,0 @@
|
|||
.document-upload[data-v-55410552]{padding:8px 0}.upload-spin[data-v-55410552]{display:block;text-align:center;padding:16px}.document-list[data-v-55410552]{margin-top:16px}.source-config[data-v-752313e6]{padding:8px 0}.source-config__header[data-v-752313e6]{margin-bottom:16px;display:flex;justify-content:flex-end}.search-test[data-v-bfaf0801]{padding:8px 0}.advanced-options[data-v-bfaf0801]{margin-top:12px}.advanced-form[data-v-bfaf0801]{flex-wrap:wrap;gap:8px}.search-results[data-v-bfaf0801]{margin-top:16px}.search-result-item[data-v-bfaf0801]{background:var(--bg-secondary);border:1px solid var(--border-color-split);border-radius:6px;padding:12px 16px;margin-bottom:12px}.search-result-item__header[data-v-bfaf0801]{display:flex;align-items:center;gap:8px;margin-bottom:8px}.search-result-item__index[data-v-bfaf0801]{font-weight:600;color:var(--color-primary)}.search-result-item__score[data-v-bfaf0801]{margin-left:auto;font-size:12px;color:var(--text-placeholder)}.search-result-item__content[data-v-bfaf0801]{font-size:14px;line-height:1.6;color:var(--text-primary);white-space:pre-wrap;max-height:200px;overflow-y:auto}.search-result-item__meta[data-v-bfaf0801]{margin-top:8px;display:flex;flex-wrap:wrap;gap:4px}.kb-view[data-v-e4e6375c]{height:100%;padding:var(--space-4) var(--space-6);overflow-y:auto;background:var(--bg-primary)}
|
||||
|
|
@ -1 +0,0 @@
|
|||
let i={};function t(a,n){}function c(a,n,e){!n&&!i[e]&&(i[e]=!0)}function d(a,n){c(t,a,n)}const o=(a,n,e)=>{d(a,`[ant-design-vue: ${n}] ${e}`)};export{d as a,o as d,t as w};
|
||||
|
|
@ -5,8 +5,8 @@
|
|||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Fischer AgentKit</title>
|
||||
<script type="module" crossorigin src="/assets/index-CnfHmcYr.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-DnToQpcu.css">
|
||||
<script type="module" crossorigin src="/assets/index-Dokv1VM5.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-bWefhn9S.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
|
|
|||
|
|
@ -57,12 +57,14 @@ def _make_registry_with_experts() -> ExpertTemplateRegistry:
|
|||
task_mode="llm_generate",
|
||||
prompt={"identity": "Private Board"},
|
||||
)
|
||||
registry.register(ExpertTemplate(
|
||||
name="private_board",
|
||||
config=board_config,
|
||||
is_builtin=True,
|
||||
description="默认私董会模板",
|
||||
))
|
||||
registry.register(
|
||||
ExpertTemplate(
|
||||
name="private_board",
|
||||
config=board_config,
|
||||
is_builtin=True,
|
||||
description="默认私董会模板",
|
||||
)
|
||||
)
|
||||
return registry
|
||||
|
||||
|
||||
|
|
@ -164,6 +166,34 @@ class TestBoardRouterResolve:
|
|||
assert result.matched is True
|
||||
assert result.topic == ""
|
||||
|
||||
def test_resolve_with_rounds_option(self):
|
||||
"""@board:expert1,expert2 rounds=N 主题 → 解析最大轮次"""
|
||||
router = BoardRouter(template_registry=_make_registry_with_experts())
|
||||
result = router.resolve("@board:elon_musk,jeff_bezos rounds=3 SpaceX上市问题")
|
||||
|
||||
assert result.matched is True
|
||||
assert result.max_rounds == 3
|
||||
assert result.topic == "SpaceX上市问题"
|
||||
assert result.specified_experts == ["elon_musk", "jeff_bezos"]
|
||||
|
||||
def test_resolve_rounds_option_clamped(self):
|
||||
"""rounds 超出范围会被限制"""
|
||||
router = BoardRouter(template_registry=_make_registry_with_experts())
|
||||
result = router.resolve("@board rounds=999 讨论主题")
|
||||
|
||||
assert result.matched is True
|
||||
assert result.max_rounds == 50
|
||||
assert result.topic == "讨论主题"
|
||||
|
||||
def test_resolve_without_rounds_option(self):
|
||||
"""未指定 rounds 时 max_rounds 为 None"""
|
||||
router = BoardRouter(template_registry=_make_registry_with_experts())
|
||||
result = router.resolve("@board:elon_musk 讨论主题")
|
||||
|
||||
assert result.matched is True
|
||||
assert result.max_rounds is None
|
||||
assert result.topic == "讨论主题"
|
||||
|
||||
def test_resolve_invalid_expert_names_filtered(self):
|
||||
"""无效专家名被过滤"""
|
||||
router = BoardRouter(template_registry=_make_registry_with_experts())
|
||||
|
|
@ -282,6 +312,7 @@ class TestBoardRoutingResult:
|
|||
assert result.topic == ""
|
||||
assert result.use_default_template is False
|
||||
assert result.match_method == ""
|
||||
assert result.max_rounds is None
|
||||
|
||||
def test_custom_values(self):
|
||||
"""自定义值"""
|
||||
|
|
@ -292,6 +323,7 @@ class TestBoardRoutingResult:
|
|||
topic="测试主题",
|
||||
use_default_template=True,
|
||||
match_method="explicit_board",
|
||||
max_rounds=7,
|
||||
)
|
||||
assert result.matched is True
|
||||
assert result.board_mode is True
|
||||
|
|
|
|||
|
|
@ -130,7 +130,7 @@ def _make_mock_llm_gateway(
|
|||
decomp_response.content = phases_json
|
||||
synth_response = MagicMock()
|
||||
synth_response.content = synthesis_content
|
||||
gateway.chat = AsyncMock(side_effect=[decomp_response, synth_response])
|
||||
gateway.chat = AsyncMock(side_effect=[decomp_response, synth_response, synth_response])
|
||||
else:
|
||||
response = MagicMock()
|
||||
response.content = synthesis_content
|
||||
|
|
@ -202,6 +202,15 @@ class TestPipelineExecution:
|
|||
)
|
||||
team._experts["lead"].agent._llm_gateway = gateway
|
||||
|
||||
# Track execution order
|
||||
execution_order: list[str] = []
|
||||
for name in ["lead", "member1", "member2"]:
|
||||
original_execute = team._experts[name].agent.execute
|
||||
async def tracking_execute(task_msg, _orig=original_execute, _name=name):
|
||||
execution_order.append(_name)
|
||||
return await _orig(task_msg)
|
||||
team._experts[name].agent.execute = tracking_execute
|
||||
|
||||
result = await orchestrator.execute("串行任务")
|
||||
|
||||
assert result["status"] == "completed"
|
||||
|
|
@ -210,6 +219,8 @@ class TestPipelineExecution:
|
|||
# All phases should be completed
|
||||
for ph in plan.phases:
|
||||
assert ph.status == PhaseStatus.COMPLETED
|
||||
# Verify sequential execution order: A(lead) → B(member1) → C(member2)
|
||||
assert execution_order == ["lead", "member1", "member2"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pipeline_parallel_phases(self):
|
||||
|
|
|
|||
|
|
@ -272,6 +272,8 @@ class TestTeamCollabErrorHandling:
|
|||
)
|
||||
# 应该调用了 dissolve 清理
|
||||
mock_team.dissolve.assert_called()
|
||||
# 失败时不应发送 final_answer
|
||||
assert not any(msg.get("type") == "final_answer" for msg in websocket.sent)
|
||||
|
||||
|
||||
def _make_mock_orchestrator_result_failing() -> MagicMock:
|
||||
|
|
@ -402,6 +404,39 @@ class TestTeamCollabEventRelay:
|
|||
call_args = mock_team.handoff_transport.register_handler.call_args
|
||||
assert call_args[0][0] == "team:test-channel"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_team_events_relayed_to_websocket(
|
||||
self, websocket, session_manager, mock_orchestrator_result
|
||||
):
|
||||
"""注册的 handler 将团队事件转发到 WebSocket"""
|
||||
session_id = session_manager._test_session_id
|
||||
with patch(
|
||||
"agentkit.experts.team.ExpertTeam"
|
||||
) as mock_team_cls, patch(
|
||||
"agentkit.experts.orchestrator.TeamOrchestrator"
|
||||
) as mock_orch_cls:
|
||||
mock_team = _make_mock_team()
|
||||
mock_team.team_channel = "team:test-channel"
|
||||
mock_team_cls.return_value = mock_team
|
||||
mock_orch_cls.return_value = _make_mock_orchestrator(mock_orchestrator_result)
|
||||
|
||||
await _execute_team_collab(
|
||||
websocket, session_id, "@team 开发功能", session_manager
|
||||
)
|
||||
|
||||
# Get the registered handler and invoke it with a sample event
|
||||
register_call = mock_team.handoff_transport.register_handler.call_args
|
||||
handler = register_call[0][1] # second arg is the handler callback
|
||||
await handler({"type": "expert_step", "expert": "lead", "message": "开始分析"})
|
||||
|
||||
# Verify the event was relayed to WebSocket via emit_team_event
|
||||
team_event_msgs = [
|
||||
msg for msg in websocket.sent
|
||||
if msg.get("type") == "expert_step"
|
||||
]
|
||||
assert len(team_event_msgs) == 1
|
||||
assert team_event_msgs[0].get("data", {}).get("expert") == "lead"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_team_called_with_lead_and_members(
|
||||
self, websocket, session_manager, mock_orchestrator_result
|
||||
|
|
@ -460,7 +495,7 @@ class TestTeamCollabEventRelay:
|
|||
final_msgs = [msg for msg in websocket.sent if msg.get("type") == "final_answer"]
|
||||
assert len(final_msgs) == 1
|
||||
# 应该包含 phase_results 的内容
|
||||
assert "阶段1结果" in final_msgs[0]["content"] or "阶段2结果" in final_msgs[0]["content"]
|
||||
assert "阶段1结果" in final_msgs[0]["content"] and "阶段2结果" in final_msgs[0]["content"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_completely_empty_result(
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import io
|
|||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from fastapi import Request
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from agentkit.llm.gateway import LLMGateway
|
||||
|
|
@ -39,11 +40,24 @@ def tool_registry():
|
|||
|
||||
@pytest.fixture
|
||||
def app(mock_llm_gateway, skill_registry, tool_registry):
|
||||
return create_app(
|
||||
application = create_app(
|
||||
llm_gateway=mock_llm_gateway,
|
||||
skill_registry=skill_registry,
|
||||
tool_registry=tool_registry,
|
||||
)
|
||||
# Inject a dev-mode admin user so the KB_WRITE / KB_QUERY permission
|
||||
# checks pass. In production, AuthMiddleware sets this from JWT.
|
||||
@application.middleware("http")
|
||||
async def _set_dev_admin_user(request: Request, call_next):
|
||||
request.state.current_user = {
|
||||
"user_id": "dev-admin",
|
||||
"username": "dev-admin",
|
||||
"role": "admin",
|
||||
"dev_mode": True,
|
||||
}
|
||||
return await call_next(request)
|
||||
|
||||
return application
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import unittest.mock
|
|||
|
||||
import pytest
|
||||
import yaml
|
||||
from fastapi import Request
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from agentkit.server.app import create_app
|
||||
|
|
@ -83,9 +84,22 @@ def server_config(config_file):
|
|||
|
||||
@pytest.fixture
|
||||
def app(server_config):
|
||||
return create_app(
|
||||
application = create_app(
|
||||
server_config=server_config,
|
||||
)
|
||||
# Inject a dev-mode admin user so the SYSTEM_CONFIG permission checks
|
||||
# on PUT endpoints pass. In production, AuthMiddleware sets this from JWT.
|
||||
@application.middleware("http")
|
||||
async def _set_dev_admin_user(request: Request, call_next):
|
||||
request.state.current_user = {
|
||||
"user_id": "dev-admin",
|
||||
"username": "dev-admin",
|
||||
"role": "admin",
|
||||
"dev_mode": True,
|
||||
}
|
||||
return await call_next(request)
|
||||
|
||||
return application
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
|
|
@ -364,6 +378,19 @@ class TestNoConfigPath:
|
|||
with unittest.mock.patch("pathlib.Path.exists", return_value=False):
|
||||
app = create_app(llm_gateway=LLMGateway())
|
||||
# server_config is None in this case
|
||||
|
||||
# Inject dev-admin user so the SYSTEM_CONFIG permission check passes
|
||||
# and the request reaches the config-path check (which returns 400).
|
||||
@app.middleware("http")
|
||||
async def _set_dev_admin_user(request: Request, call_next):
|
||||
request.state.current_user = {
|
||||
"user_id": "dev-admin",
|
||||
"username": "dev-admin",
|
||||
"role": "admin",
|
||||
"dev_mode": True,
|
||||
}
|
||||
return await call_next(request)
|
||||
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.put(
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue