Merge branch 'feat/enterprise-client-server' into main
Deploy to Production / deploy (push) Failing after 7s Details

企业级客户端-服务端架构 + 代码审查修复
- JWT 认证 + RBAC 权限矩阵
- 终端六层安全防御
- 远程 LLM 网关(401 重试)
- Tauri 客户端配置同步
- 代码审查 P0/P1/P2 修复
This commit is contained in:
chiguyong 2026-06-20 06:48:34 +08:00
commit aeb82ad7a0
109 changed files with 18758 additions and 1135 deletions

3
.gitignore vendored
View File

@ -39,3 +39,6 @@ src/agentkit/server/static/
# Env
.env
# Runtime data (auth DB, conversation DB, etc.)
data/

View File

@ -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
View File

@ -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 | 本地终端 WebSocketJWT 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/ # 客户端 SDKConfigSync + 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/ # 遥测追踪

View File

@ -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 万文件项目代码检索延迟 < 500msR5
- VS Code 扩展日活用户 > 100R6
### 差距缩小目标
| 能力维度 | 当前 | 目标 | 对标 |
|----------|------|------|------|
| 任务分解执行 | ⚠️ 死代码 | ✅ 流水线可用 | 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` 任务分解执行 — 死代码,需集成
- 流水线编排模式 — 需实现
- 上下文隔离 — 需实现
- 编程专家模板 — 需新增
- 多模型路由 — 需消费已有字段
- 仓库级理解 — 需实现(长期)

View File

@ -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 体系 —— 文本是主体,色彩是信号
- 重新设计聊天主区布局:常规气泡 + 复杂事件拆为 cardCursor / 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 bar48px 高,左对齐文字 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)

View File

@ -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

View File

@ -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 sidecarLLM 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/OIDCV1 本地账号V2 预留接口)
- 代码库语义索引
- 多租户隔离(表预留 tenant_idV2 启用)
- 多设备会话同步
## 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 过期后返回 401refresh 后可用
- 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` 改动文件无新增错误

View File

@ -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()

View File

@ -0,0 +1 @@
"""AgentKit client-side modules (desktop / sidecar)."""

331
src/agentkit/client/sync.py Normal file
View File

@ -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

View File

@ -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:

View File

@ -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

View File

@ -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",

View File

@ -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

View File

@ -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")

View File

@ -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)
"""

View File

@ -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

View File

@ -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])

View File

@ -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",
},
)

View File

@ -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"],
}

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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']
}
}

View File

@ -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 = '正在启动后端...'

View File

@ -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 }

View File

@ -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}`)
}
}

View File

@ -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()

View File

@ -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)

View File

@ -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()

View File

@ -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

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;')
const alt = rawAlt.replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;')
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>

View File

@ -47,7 +47,7 @@ const emit = defineEmits<{
select: [id: string]
}>()
const collapsed = ref(true)
const collapsed = ref(false)
function handleCreate(): void {
emit('create')

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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 },
}
}
})
}

View File

@ -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, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;')
const alt = rawAlt.replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;')
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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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'

View File

@ -97,7 +97,9 @@ async function handleSearch(): Promise<void> {
}
onMounted(() => {
kbStore.fetchSources()
if (kbStore.sources.length === 0) {
kbStore.fetchSources()
}
})
</script>

View File

@ -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;

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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

View File

@ -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
}

View File

@ -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,
}

View File

@ -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,
}
})

View File

@ -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 重梳: 主色改为近黑(#1a1a1aaccent 仅做语义信号
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>,
},
}

View File

@ -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;

View File

@ -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>

View File

@ -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;

View File

@ -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>

View File

@ -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);

View File

@ -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",
]

View File

@ -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)

View File

@ -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)

View File

@ -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(),
}

View File

@ -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)

View File

@ -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

View File

@ -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",
},
)

View File

@ -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

View File

@ -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)

View File

@ -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,
}

View File

@ -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

View File

@ -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)

View File

@ -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,
)

View File

@ -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};

View File

@ -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)}

View File

@ -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};

View File

@ -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>

View File

@ -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

View File

@ -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):

View File

@ -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(

View File

@ -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

View File

@ -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