mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-17 13:13:35 +08:00
feat: add Desktop UI backend server with full REST API and WebSocket support
Add complete server-side implementation for the Claude Code Desktop App UI: - REST API: sessions, conversations, settings, models, scheduled tasks, search, agents, and status endpoints (9 modules, 30+ endpoints) - WebSocket: real-time chat streaming with state transitions, ping/pong, permission request forwarding, and stop generation support - Services: sessionService (JSONL read/write, CLI-compatible), settingsService (atomic writes), cronService, searchService (ripgrep), agentService (YAML management) - Middleware: CORS (localhost-only), auth, unified error handling - Tests: 180 tests (unit + E2E + business flow), all passing - Docs: PRD, UI design spec, server architecture design Non-invasive: all new code under src/server/, no changes to existing CLI code. CLI/UI data interop: reads/writes the same JSONL/JSON files as the CLI. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
072e5bcc48
commit
0c6d2754b4
53
.env.example
53
.env.example
@ -1,50 +1,3 @@
|
||||
# ============================================================
|
||||
# MiniMax(直连 Anthropic 兼容接口)
|
||||
# ============================================================
|
||||
# ANTHROPIC_AUTH_TOKEN=your_token_here
|
||||
# ANTHROPIC_BASE_URL=https://api.minimaxi.com/anthropic
|
||||
# ANTHROPIC_MODEL=MiniMax-M2.7-highspeed
|
||||
# ANTHROPIC_DEFAULT_SONNET_MODEL=MiniMax-M2.7-highspeed
|
||||
# ANTHROPIC_DEFAULT_HAIKU_MODEL=MiniMax-M2.7-highspeed
|
||||
# ANTHROPIC_DEFAULT_OPUS_MODEL=MiniMax-M2.7-highspeed
|
||||
# API_TIMEOUT_MS=3000000
|
||||
|
||||
# ============================================================
|
||||
# OpenAI(通过 LiteLLM 代理)
|
||||
# 先启动: litellm --config litellm_config.yaml --port 4000
|
||||
# ============================================================
|
||||
# ANTHROPIC_AUTH_TOKEN=sk-anything
|
||||
# ANTHROPIC_BASE_URL=http://localhost:4000
|
||||
# ANTHROPIC_MODEL=gpt-4o
|
||||
# ANTHROPIC_DEFAULT_SONNET_MODEL=gpt-4o
|
||||
# ANTHROPIC_DEFAULT_HAIKU_MODEL=gpt-4o
|
||||
# ANTHROPIC_DEFAULT_OPUS_MODEL=gpt-4o
|
||||
# API_TIMEOUT_MS=3000000
|
||||
|
||||
# ============================================================
|
||||
# DeepSeek(通过 LiteLLM 代理)
|
||||
# 先启动: litellm --config litellm_config.yaml --port 4000
|
||||
# ============================================================
|
||||
# ANTHROPIC_AUTH_TOKEN=sk-anything
|
||||
# ANTHROPIC_BASE_URL=http://localhost:4000
|
||||
# ANTHROPIC_MODEL=deepseek-chat
|
||||
# ANTHROPIC_DEFAULT_SONNET_MODEL=deepseek-chat
|
||||
# ANTHROPIC_DEFAULT_HAIKU_MODEL=deepseek-chat
|
||||
# ANTHROPIC_DEFAULT_OPUS_MODEL=deepseek-chat
|
||||
# API_TIMEOUT_MS=3000000
|
||||
|
||||
# ============================================================
|
||||
# OpenRouter(直连 Anthropic 兼容接口)
|
||||
# ============================================================
|
||||
# ANTHROPIC_AUTH_TOKEN=sk-or-v1-xxx
|
||||
# ANTHROPIC_BASE_URL=https://openrouter.ai/api/v1
|
||||
# ANTHROPIC_MODEL=openai/gpt-4o
|
||||
# ANTHROPIC_DEFAULT_SONNET_MODEL=openai/gpt-4o
|
||||
# ANTHROPIC_DEFAULT_HAIKU_MODEL=openai/gpt-4o-mini
|
||||
# ANTHROPIC_DEFAULT_OPUS_MODEL=openai/gpt-4o
|
||||
|
||||
# ============================================================
|
||||
# 通用设置(建议始终开启)
|
||||
# ============================================================
|
||||
DISABLE_TELEMETRY=1
|
||||
CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1
|
||||
ANTHROPIC_API_KEY=sk-ant-your-key-here
|
||||
SERVER_PORT=3456
|
||||
SERVER_HOST=127.0.0.1
|
||||
|
||||
568
docs/ui-clone/01-requirements.md
Normal file
568
docs/ui-clone/01-requirements.md
Normal file
@ -0,0 +1,568 @@
|
||||
# Claude Code Desktop App — 产品需求文档 (PRD)
|
||||
|
||||
## 一、项目概述
|
||||
|
||||
### 1.1 目标
|
||||
|
||||
完全复刻 Claude Code Desktop App 的桌面端 GUI 界面。原始应用是 Anthropic 出品的 AI 编程助手,采用跨平台桌面架构(Electron),支持 macOS/Windows。
|
||||
|
||||
### 1.2 原始技术栈
|
||||
|
||||
- **前端框架**: React 18 + TypeScript
|
||||
- **终端渲染**: Ink(自定义 fork,基于 Yoga 布局引擎)
|
||||
- **桌面容器**: Electron
|
||||
- **状态管理**: React Context + 自定义 Store(类 Zustand)
|
||||
- **构建工具**: Bun
|
||||
|
||||
### 1.3 复刻技术栈建议
|
||||
|
||||
- **前端框架**: React 18 + TypeScript
|
||||
- **UI 渲染**: 标准浏览器 DOM(替换 Ink 终端渲染)
|
||||
- **桌面容器**: Electron 或 Tauri
|
||||
- **样式方案**: TailwindCSS v4
|
||||
- **状态管理**: Zustand 或 Jotai
|
||||
|
||||
---
|
||||
|
||||
## 二、页面清单
|
||||
|
||||
| 编号 | 页面 | 描述 | 优先级 |
|
||||
|------|------|------|--------|
|
||||
| P1 | 顶部标题栏 | macOS 窗口控件 + Chat/Cowork/Code 标签页 | P0 |
|
||||
| P2 | 左侧边栏 | 导航菜单 + 项目过滤 + 会话列表 | P0 |
|
||||
| P3 | 底部状态栏 | 用户信息 + 仓库信息 + 模式切换 | P1 |
|
||||
| P4 | Code 空状态页 | 吉祥物 + 输入框 + 快捷入口 | P0 |
|
||||
| P5 | Code 活跃会话页 | 消息列表 + 输入区 + 工具调用展示 | P0 |
|
||||
| P6 | 权限模式选择器 | 下拉菜单:Ask/Auto/Plan/Bypass | P0 |
|
||||
| P7 | 模型选择器 | 下拉菜单:模型列表 + Effort 等级 | P0 |
|
||||
| P8 | Scheduled 定时任务页 | 任务列表 + 空状态 | P1 |
|
||||
| P9 | 新建定时任务模态框 | 创建定时任务表单 | P1 |
|
||||
| P10 | Search 搜索页 | 全局内容搜索 | P1 |
|
||||
| P11 | Dispatch 分发页 | Agent 管理 + 任务分发 | P2 |
|
||||
| P12 | Customize 设置页 | Status/Config/Usage 标签页 | P1 |
|
||||
|
||||
---
|
||||
|
||||
## 三、功能需求详述
|
||||
|
||||
### 3.1 顶部标题栏 (P1)
|
||||
|
||||
**功能描述**: macOS 风格标题栏,集成窗口控件和主导航标签页。
|
||||
|
||||
**需求项**:
|
||||
- R-001: 显示 macOS 红绿灯按钮(关闭/最小化/全屏)
|
||||
- R-002: 显示前进/后退导航箭头
|
||||
- R-003: 显示 `Chat` / `Cowork` / `Code` 三个标签页,支持切换
|
||||
- R-004: 活跃标签页高亮显示
|
||||
- R-005: 标题栏区域支持拖拽移动窗口
|
||||
|
||||
**源码参考**: `src/components/design-system/Tabs.tsx`
|
||||
|
||||
---
|
||||
|
||||
### 3.2 左侧边栏 (P2)
|
||||
|
||||
**功能描述**: 垂直导航面板,包含功能入口和会话管理。
|
||||
|
||||
#### 导航菜单
|
||||
|
||||
- R-006: 显示 5 个导航项(New session / Search / Scheduled / Dispatch / Customize)
|
||||
- R-007: 点击导航项切换主内容区页面
|
||||
- R-008: 当前选中项高亮显示
|
||||
|
||||
#### 项目过滤器
|
||||
|
||||
- R-009: 显示 `All projects ▾` 下拉选择器
|
||||
- R-010: 可选择特定项目过滤会话列表
|
||||
- R-011: 右侧筛选图标,点击打开高级过滤
|
||||
|
||||
#### 会话列表
|
||||
|
||||
- R-012: 按时间分组显示会话(Today / Previous 7 Days / Previous 30 Days / Older)
|
||||
- R-013: 每项显示会话标题(首条用户消息摘要)
|
||||
- R-014: 选中会话高亮,左侧显示圆点指示器
|
||||
- R-015: 支持模糊搜索过滤会话列表
|
||||
- R-016: 右键菜单支持重命名、删除操作
|
||||
- R-017: 点击会话切换到对应的活跃会话页
|
||||
|
||||
**数据模型**:
|
||||
```typescript
|
||||
type Session = {
|
||||
id: string
|
||||
title: string // 首条消息摘要或自定义标题
|
||||
createdAt: Date
|
||||
modifiedAt: Date
|
||||
messageCount: number
|
||||
projectName?: string // 所属项目
|
||||
summary?: string // AI 生成的摘要
|
||||
}
|
||||
```
|
||||
|
||||
**源码参考**: `src/components/LogSelector.tsx`, `src/types/logs.ts`
|
||||
|
||||
---
|
||||
|
||||
### 3.3 底部状态栏 (P3)
|
||||
|
||||
**需求项**:
|
||||
- R-018: 左侧显示用户头像 + 用户名 + 订阅等级
|
||||
- R-019: 显示下载按钮和更多操作按钮
|
||||
- R-020: 中部显示当前 Git 仓库名称(GitHub 图标 + 仓库名)
|
||||
- R-021: 显示当前 Git 分支(分支图标 + 分支名)
|
||||
- R-022: 显示 `worktree` 复选框,控制是否使用 Git worktree
|
||||
- R-023: 右侧显示 `Local ▾` / `Remote ▾` 运行模式切换
|
||||
|
||||
**源码参考**: `src/components/StatusLine.tsx`
|
||||
|
||||
---
|
||||
|
||||
### 3.4 Code 空状态页 (P4)
|
||||
|
||||
**需求项**:
|
||||
- R-024: 居中显示 Clawd 吉祥物(像素风格小动物,橙色)
|
||||
- R-025: 显示输入框,圆角矩形,带 placeholder 文本
|
||||
- R-026: 输入框左侧 `+` 按钮,支持添加附件/上下文
|
||||
- R-027: 输入框左下显示权限模式选择器
|
||||
- R-028: 输入框右下显示模型选择器
|
||||
- R-029: 输入框右侧显示麦克风图标(语音输入)
|
||||
|
||||
**源码参考**: `src/components/LogoV2/`, `src/components/PromptInput/`
|
||||
|
||||
---
|
||||
|
||||
### 3.5 Code 活跃会话页 (P5)
|
||||
|
||||
**功能描述**: 最核心的页面,显示 AI 对话过程。
|
||||
|
||||
#### 会话标题栏
|
||||
|
||||
- R-030: 显示会话标题,可点击修改
|
||||
- R-031: 右上角显示 `▷ Preview ▾` 按钮
|
||||
|
||||
#### 消息列表
|
||||
|
||||
- R-032: 虚拟滚动渲染消息列表,支持大量消息高性能显示
|
||||
- R-033: 用户消息右对齐,暖色背景气泡
|
||||
- R-034: AI 文本回复左对齐,支持 Markdown 富文本渲染
|
||||
- R-035: AI 思考过程可折叠展示
|
||||
- R-036: 工具调用块可折叠,显示工具名 + 参数摘要
|
||||
- R-037: 系统消息居中灰色显示
|
||||
- R-038: 支持消息分组折叠(如 `▸ Initialized your session`)
|
||||
- R-039: 折叠区段内显示工具调用列表(`Agent 描述` / `Bash · N tool calls`)
|
||||
- R-040: 上下文压缩分界线标记
|
||||
- R-041: 滚动到顶部时显示 "N new messages" 跳转提示
|
||||
|
||||
**消息类型数据模型**:
|
||||
```typescript
|
||||
type MessageType =
|
||||
| 'user_text' // 用户文本
|
||||
| 'user_image' // 用户图片
|
||||
| 'assistant_text' // AI 文本
|
||||
| 'assistant_thinking' // AI 思考
|
||||
| 'assistant_tool_use' // AI 工具调用
|
||||
| 'user_tool_result' // 工具执行结果
|
||||
| 'system_text' // 系统消息
|
||||
| 'system_error' // API 错误
|
||||
| 'grouped_tool_use' // 分组工具调用
|
||||
| 'collapsed_section' // 折叠区段
|
||||
| 'compact_boundary' // 压缩分界线
|
||||
| 'attachment' // 附件
|
||||
```
|
||||
|
||||
#### 实时状态指示器
|
||||
|
||||
- R-042: AI 处理中显示闪烁图标 + 动态动词(`Crafting...` / `Thinking...`)
|
||||
- R-043: 显示已用时间计时器
|
||||
- R-044: 显示已生成 token 数
|
||||
- R-045: 提供停止生成按钮
|
||||
|
||||
#### 底部输入区
|
||||
|
||||
- R-046: 多行文本输入框,支持自动扩展高度
|
||||
- R-047: 左侧 `+` 按钮添加附件
|
||||
- R-048: 显示当前权限模式指示器
|
||||
- R-049: 显示当前模型选择器
|
||||
- R-050: AI 运行中显示停止按钮
|
||||
|
||||
**源码参考**: `src/screens/REPL.tsx`, `src/components/FullscreenLayout.tsx`, `src/components/VirtualMessageList.tsx`
|
||||
|
||||
---
|
||||
|
||||
### 3.6 权限模式选择器 (P6)
|
||||
|
||||
**需求项**:
|
||||
- R-051: 下拉菜单显示 4 个权限选项
|
||||
- R-052: 每个选项显示图标 + 名称 + 描述
|
||||
- R-053: 选中项显示 ✓ 标记
|
||||
- R-054: 切换后即时生效
|
||||
|
||||
**选项列表**:
|
||||
|
||||
| 选项 | 图标 | 描述 |
|
||||
|------|------|------|
|
||||
| Ask permissions | ⚙ | Always ask before making changes |
|
||||
| Auto accept edits | `</>` | Automatically accept all file edits |
|
||||
| Plan mode | 📋 | Create a plan before making changes |
|
||||
| Bypass permissions | ⚠ | Accepts all permissions |
|
||||
|
||||
**数据模型**:
|
||||
```typescript
|
||||
type PermissionMode = 'default' | 'acceptEdits' | 'plan' | 'bypassPermissions' | 'dontAsk' | 'auto'
|
||||
```
|
||||
|
||||
**源码参考**: `src/types/permissions.ts`, `src/utils/permissions/PermissionMode.ts`
|
||||
|
||||
---
|
||||
|
||||
### 3.7 模型选择器 (P7)
|
||||
|
||||
**需求项**:
|
||||
- R-055: 上部显示模型列表,下部显示 Effort 等级(分隔线分割)
|
||||
- R-056: 每个模型显示名称 + 简短描述
|
||||
- R-057: 选中模型显示 ✓ 标记
|
||||
- R-058: Effort 等级: Low / Medium / High / Max
|
||||
- R-059: 选中 Effort 显示 ✓ 标记
|
||||
- R-060: 不同模型支持不同的 Effort 范围
|
||||
|
||||
**模型列表**:
|
||||
|
||||
| 模型 | 描述 |
|
||||
|------|------|
|
||||
| Opus 4.6 | Most capable for ambitious work |
|
||||
| Opus 4.6 1M | Most capable for ambitious work |
|
||||
| Sonnet 4.6 | Most efficient for everyday tasks |
|
||||
| Haiku 4.5 | Fastest for quick answers |
|
||||
|
||||
**源码参考**: `src/components/ModelPicker.tsx`, `src/utils/model/modelOptions.ts`, `src/utils/effort.ts`
|
||||
|
||||
---
|
||||
|
||||
### 3.8 Scheduled 定时任务页 (P8)
|
||||
|
||||
**需求项**:
|
||||
- R-061: 页面标题 `Scheduled tasks`
|
||||
- R-062: 描述文本说明如何使用定时任务
|
||||
- R-063: 空状态显示时钟图标 + `No scheduled tasks yet.`
|
||||
- R-064: 右上角 `+ New task` 按钮
|
||||
- R-065: 有任务时显示任务列表(名称 + 描述 + 频率 + 下次执行时间)
|
||||
- R-066: 每项支持编辑、删除操作
|
||||
|
||||
**数据模型**:
|
||||
```typescript
|
||||
type ScheduledTask = {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
prompt: string
|
||||
cron: string // 5-field cron 表达式
|
||||
frequency: 'daily' | 'weekly' | 'monthly' | 'custom'
|
||||
time: string // HH:mm
|
||||
createdAt: number
|
||||
lastFiredAt?: number
|
||||
recurring: boolean
|
||||
permissionMode: PermissionMode
|
||||
model: string
|
||||
folderPath?: string
|
||||
useWorktree: boolean
|
||||
}
|
||||
```
|
||||
|
||||
**源码参考**: `src/utils/cronTasks.ts`, `src/tools/ScheduleCronTool/`
|
||||
|
||||
---
|
||||
|
||||
### 3.9 新建定时任务模态框 (P9)
|
||||
|
||||
**需求项**:
|
||||
- R-067: 模态框标题 `New scheduled task`
|
||||
- R-068: Info 横幅: `Local tasks only run while your computer is awake.`
|
||||
- R-069: Name 文本输入(必填)
|
||||
- R-070: Description 文本输入(必填)
|
||||
- R-071: Prompt 多行文本输入
|
||||
- R-072: Permissions 下拉选择器
|
||||
- R-073: Model 下拉选择器
|
||||
- R-074: Select folder 按钮
|
||||
- R-075: worktree 复选框
|
||||
- R-076: Frequency 下拉选择(Daily/Weekly/Monthly/Custom)
|
||||
- R-077: Time 时间选择器
|
||||
- R-078: 底部注释文本
|
||||
- R-079: Cancel / Create task 操作按钮
|
||||
|
||||
---
|
||||
|
||||
### 3.10 Search 搜索页 (P10)
|
||||
|
||||
**需求项**:
|
||||
- R-080: 搜索输入框,支持实时搜索
|
||||
- R-081: 防抖输入(100ms)
|
||||
- R-082: 搜索结果列表:文件路径 + 行号 + 匹配文本高亮
|
||||
- R-083: 选中结果显示上下文代码预览(±4 行)
|
||||
- R-084: 最多 500 条结果,每文件最多 10 条
|
||||
|
||||
**源码参考**: `src/components/GlobalSearchDialog.tsx`, `src/utils/ripgrep.ts`
|
||||
|
||||
---
|
||||
|
||||
### 3.11 Dispatch 分发页 (P11)
|
||||
|
||||
**需求项**:
|
||||
- R-085: Agent 列表:显示已配置的 Agent 定义
|
||||
- R-086: Agent 编辑器:创建/编辑 Agent 配置(名称、描述、模型、工具、颜色)
|
||||
- R-087: 任务监控面板:后台任务状态列表
|
||||
- R-088: Remote 会话管理
|
||||
|
||||
**源码参考**: `src/components/agents/`, `src/components/tasks/`
|
||||
|
||||
---
|
||||
|
||||
### 3.12 Customize 设置页 (P12)
|
||||
|
||||
**需求项**:
|
||||
- R-089: Status 标签:系统诊断信息展示
|
||||
- R-090: Config 标签:权限规则编辑 + MCP 服务器配置
|
||||
- R-091: Usage 标签:Token 用量和成本统计
|
||||
- R-092: 主题选择器
|
||||
- R-093: 输出风格选择器
|
||||
- R-094: MCP 服务器管理面板
|
||||
|
||||
**源码参考**: `src/components/Settings/`, `src/components/mcp/`
|
||||
|
||||
---
|
||||
|
||||
## 四、交互组件需求
|
||||
|
||||
### 4.1 权限请求对话框
|
||||
|
||||
**功能描述**: AI 执行工具前弹出的确认对话框。
|
||||
|
||||
- R-095: Bash 命令权限请求 — 显示命令内容
|
||||
- R-096: 文件编辑权限请求 — 显示 diff 预览
|
||||
- R-097: 文件写入权限请求 — 显示文件路径
|
||||
- R-098: 网络请求权限请求 — 显示 URL
|
||||
- R-099: 操作按钮:允许 / 拒绝 / 总是允许 / 总是拒绝
|
||||
|
||||
**源码参考**: `src/components/permissions/`
|
||||
|
||||
### 4.2 Markdown 渲染器
|
||||
|
||||
- R-100: 标题、列表、粗体/斜体
|
||||
- R-101: 代码块语法高亮
|
||||
- R-102: 表格渲染
|
||||
- R-103: 文件路径可点击跳转
|
||||
- R-104: 图片内联显示
|
||||
|
||||
**源码参考**: `src/components/Markdown.tsx`, `src/components/HighlightedCode/`
|
||||
|
||||
### 4.3 Diff 展示
|
||||
|
||||
- R-105: 添加行绿色 / 删除行红色
|
||||
- R-106: 行号显示
|
||||
- R-107: 文件路径头部
|
||||
- R-108: 行内 word-level diff 高亮
|
||||
|
||||
**源码参考**: `src/components/StructuredDiff.tsx`, `src/components/diff/`
|
||||
|
||||
### 4.4 通知系统
|
||||
|
||||
- R-109: Toast 通知(成功/错误/警告/信息)
|
||||
- R-110: 自动消失或手动关闭
|
||||
- R-111: 队列管理
|
||||
|
||||
**源码参考**: `src/context/notifications.tsx`
|
||||
|
||||
### 4.5 通用对话框
|
||||
|
||||
- R-112: 对话框基础组件(标题 + 内容 + 按钮)
|
||||
- R-113: 模态遮罩层
|
||||
- R-114: ESC 关闭最上层对话框
|
||||
|
||||
**源码参考**: `src/components/design-system/Dialog.tsx`
|
||||
|
||||
### 4.6 下拉选择器
|
||||
|
||||
- R-115: 单选 / 多选模式
|
||||
- R-116: 键盘导航(↑↓ 选择 / Enter 确认 / Esc 关闭)
|
||||
- R-117: 搜索过滤
|
||||
- R-118: ✓ 选中标记
|
||||
|
||||
**源码参考**: `src/components/CustomSelect/`
|
||||
|
||||
---
|
||||
|
||||
## 五、数据模型
|
||||
|
||||
### 5.1 全局应用状态 (AppState)
|
||||
|
||||
```typescript
|
||||
type AppState = {
|
||||
// 模型设置
|
||||
mainLoopModel: string | null // 当前模型 ID
|
||||
effort: 'low' | 'medium' | 'high' | 'max'
|
||||
|
||||
// 视图状态
|
||||
expandedView: 'none' | 'tasks' | 'teammates'
|
||||
footerSelection: 'tasks' | 'tmux' | 'bagel' | 'teams' | 'bridge' | 'companion' | null
|
||||
|
||||
// 权限
|
||||
permissionMode: PermissionMode
|
||||
|
||||
// 任务
|
||||
tasks: Record<string, TaskState>
|
||||
foregroundedTaskId?: string
|
||||
viewingAgentTaskId?: string
|
||||
|
||||
// MCP 服务器
|
||||
mcp: {
|
||||
clients: MCPConnection[]
|
||||
tools: Tool[]
|
||||
commands: Command[]
|
||||
}
|
||||
|
||||
// 团队协作
|
||||
teamContext?: {
|
||||
teamName: string
|
||||
teammates: Record<string, TeammateInfo>
|
||||
isLeader: boolean
|
||||
}
|
||||
|
||||
// 通知
|
||||
notifications: {
|
||||
current: Notification | null
|
||||
queue: Notification[]
|
||||
}
|
||||
|
||||
// 思考模式
|
||||
thinkingEnabled: boolean
|
||||
|
||||
// 设置
|
||||
settings: Settings
|
||||
}
|
||||
```
|
||||
|
||||
**源码参考**: `src/state/AppStateStore.ts`
|
||||
|
||||
### 5.2 消息模型
|
||||
|
||||
```typescript
|
||||
type Message = {
|
||||
id: string
|
||||
role: 'user' | 'assistant' | 'system'
|
||||
content: ContentBlock[]
|
||||
timestamp: number
|
||||
model?: string
|
||||
metadata?: MessageMetadata
|
||||
}
|
||||
|
||||
type ContentBlock =
|
||||
| { type: 'text'; text: string }
|
||||
| { type: 'image'; source: ImageSource }
|
||||
| { type: 'tool_use'; id: string; name: string; input: any }
|
||||
| { type: 'tool_result'; tool_use_id: string; content: any }
|
||||
| { type: 'thinking'; thinking: string }
|
||||
```
|
||||
|
||||
### 5.3 任务状态模型
|
||||
|
||||
```typescript
|
||||
type TaskState =
|
||||
| { type: 'local_shell'; command: string; status: 'running' | 'completed' | 'failed' }
|
||||
| { type: 'local_agent'; agentName: string; status: 'running' | 'completed' }
|
||||
| { type: 'remote_agent'; url: string; status: 'connecting' | 'connected' | 'disconnected' }
|
||||
| { type: 'in_process_teammate'; name: string; agentType: string }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 六、导航与路由
|
||||
|
||||
### 6.1 导航模型
|
||||
|
||||
应用采用**状态驱动导航**(非 URL 路由):
|
||||
|
||||
```
|
||||
顶级切换: Chat / Cowork / Code (标签页)
|
||||
↓
|
||||
侧边栏导航: New session → Code 空状态
|
||||
Search → 搜索页
|
||||
Scheduled → 定时任务页
|
||||
Dispatch → 分发页
|
||||
Customize → 设置页
|
||||
↓
|
||||
会话列表: 点击会话 → Code 活跃会话页
|
||||
↓
|
||||
子视图: expandedView → tasks 面板 / teammates 面板
|
||||
viewingAgentTaskId → Agent 对话视图
|
||||
```
|
||||
|
||||
### 6.2 模态层叠
|
||||
|
||||
- 对话框使用 LIFO 栈管理
|
||||
- ESC 关闭最上层
|
||||
- 遮罩层防止底层交互
|
||||
|
||||
---
|
||||
|
||||
## 七、实施路线图
|
||||
|
||||
### Phase 1: 基础框架(可独立实现)
|
||||
1. 项目脚手架搭建
|
||||
2. 主题系统(颜色 token)
|
||||
3. 设计系统基础组件(Dialog, Tabs, Select, Button)
|
||||
4. 全局状态管理
|
||||
5. App Shell 三栏布局
|
||||
|
||||
### Phase 2: 核心对话页
|
||||
6. Code 空状态页(吉祥物 + 输入框)
|
||||
7. 输入区域(文本 + 附件 + 选择器)
|
||||
8. 消息列表基础版(用户文本 + AI 文本)
|
||||
9. 权限 / 模型选择器
|
||||
10. 加载状态指示器
|
||||
|
||||
### Phase 3: 高级渲染
|
||||
11. Markdown 渲染器
|
||||
12. 代码语法高亮
|
||||
13. 工具调用展示(折叠/展开)
|
||||
14. Diff 结构化展示
|
||||
15. 权限请求对话框
|
||||
|
||||
### Phase 4: 辅助页面
|
||||
16. Scheduled 定时任务页 + 新建模态框
|
||||
17. Search 搜索页
|
||||
18. Dispatch 分发页
|
||||
19. Customize 设置页
|
||||
|
||||
### Phase 5: 交互完善
|
||||
20. 会话管理全流程
|
||||
21. 键盘快捷键
|
||||
22. 通知系统
|
||||
23. 虚拟滚动优化
|
||||
24. 响应式适配
|
||||
|
||||
---
|
||||
|
||||
## 八、关键源码文件索引
|
||||
|
||||
| 功能 | 路径 |
|
||||
|------|------|
|
||||
| 全局状态 | `src/state/AppStateStore.ts` |
|
||||
| 状态 Provider | `src/state/AppState.tsx` |
|
||||
| 主屏幕 | `src/screens/REPL.tsx` |
|
||||
| 全屏布局 | `src/components/FullscreenLayout.tsx` |
|
||||
| 输入组件 | `src/components/PromptInput/PromptInput.tsx` |
|
||||
| 消息渲染 | `src/components/Message.tsx` |
|
||||
| 虚拟滚动 | `src/components/VirtualMessageList.tsx` |
|
||||
| 权限类型 | `src/types/permissions.ts` |
|
||||
| 权限 UI | `src/components/permissions/PermissionRequest.tsx` |
|
||||
| 模型选择 | `src/components/ModelPicker.tsx` |
|
||||
| 主题定义 | `src/utils/theme.ts` |
|
||||
| 设计系统 | `src/components/design-system/` |
|
||||
| 定时任务 | `src/utils/cronTasks.ts` |
|
||||
| 搜索 | `src/components/GlobalSearchDialog.tsx` |
|
||||
| 设置 | `src/components/Settings/` |
|
||||
| Agent | `src/components/agents/` |
|
||||
| MCP | `src/components/mcp/` |
|
||||
| 工具接口 | `src/Tool.ts` |
|
||||
| 消息类型 | `src/components/messages/` |
|
||||
| Diff | `src/components/StructuredDiff.tsx` |
|
||||
| 会话管理 | `src/components/LogSelector.tsx` |
|
||||
| 导航 hook | `src/hooks/useGlobalKeybindings.tsx` |
|
||||
897
docs/ui-clone/02-ui-design-spec.md
Normal file
897
docs/ui-clone/02-ui-design-spec.md
Normal file
@ -0,0 +1,897 @@
|
||||
# Claude Code Desktop App — UI 设计规范
|
||||
|
||||
> 本文档面向设计图生成,包含完整的配色方案、布局尺寸、组件规格和页面交互逻辑。
|
||||
> 可直接用于 AI 设计工具或设计师参考。
|
||||
|
||||
---
|
||||
|
||||
## 一、设计风格概述
|
||||
|
||||
### 1.1 整体风格
|
||||
|
||||
- **设计语言**: 现代简约,暖色调,类 Notion/Linear 风格
|
||||
- **背景色**: 白色/米白色为主,非纯白(偏暖)
|
||||
- **圆角**: 大圆角风格(8-12px),卡片和输入框更大(12-16px)
|
||||
- **阴影**: 微妙的阴影,仅用于浮层和下拉菜单
|
||||
- **字体**: 系统字体栈(-apple-system, SF Pro, Segoe UI, Inter)
|
||||
- **图标风格**: 线条图标,1.5px 线宽,圆角端点
|
||||
- **品牌色**: 橙色 `rgb(215,119,87)` — Claude 品牌橙
|
||||
|
||||
### 1.2 吉祥物 (Clawd)
|
||||
|
||||
- 像素风格小动物(类似小螃蟹/外星生物)
|
||||
- 主体颜色: `rgb(215,119,87)` (Claude 橙)
|
||||
- 8bit 像素风格,大约 64x64px 像素尺寸
|
||||
- 两个黑色方形眼睛
|
||||
- 四条短腿
|
||||
- 背景: 透明或 `rgb(0,0,0)` (clawd_background)
|
||||
|
||||
---
|
||||
|
||||
## 二、配色方案
|
||||
|
||||
### 2.1 Light 主题(默认主题,截图使用的主题)
|
||||
|
||||
#### 品牌与核心色
|
||||
|
||||
| Token | 色值 | 用途 |
|
||||
|-------|------|------|
|
||||
| `claude` | `rgb(215,119,87)` | 品牌橙,主色调,吉祥物颜色 |
|
||||
| `claudeShimmer` | `rgb(245,149,117)` | 品牌橙浅色,闪烁动画 |
|
||||
| `permission` | `rgb(87,105,247)` | 权限蓝,权限模式标识 |
|
||||
| `permissionShimmer` | `rgb(137,155,255)` | 权限蓝浅色 |
|
||||
| `autoAccept` | `rgb(135,0,255)` | 自动接受紫,Auto 模式标识 |
|
||||
| `planMode` | `rgb(0,102,102)` | 计划模式青,Plan 模式标识 |
|
||||
| `fastMode` | `rgb(255,106,0)` | 快速模式橙 |
|
||||
|
||||
#### 文本色
|
||||
|
||||
| Token | 色值 | 用途 |
|
||||
|-------|------|------|
|
||||
| `text` | `rgb(0,0,0)` | 主文本(黑色) |
|
||||
| `inverseText` | `rgb(255,255,255)` | 反色文本(白色) |
|
||||
| `inactive` | `rgb(102,102,102)` | 不活跃文本(深灰) |
|
||||
| `subtle` | `rgb(175,175,175)` | 次要文本(浅灰) |
|
||||
| `suggestion` | `rgb(87,105,247)` | 建议文本(蓝紫) |
|
||||
|
||||
#### 语义色
|
||||
|
||||
| Token | 色值 | 用途 |
|
||||
|-------|------|------|
|
||||
| `success` | `rgb(44,122,57)` | 成功(绿色) |
|
||||
| `error` | `rgb(171,43,63)` | 错误(红色) |
|
||||
| `warning` | `rgb(150,108,30)` | 警告(琥珀色) |
|
||||
| `merged` | `rgb(135,0,255)` | 已合并(紫色) |
|
||||
|
||||
#### Diff 色
|
||||
|
||||
| Token | 色值 | 用途 |
|
||||
|-------|------|------|
|
||||
| `diffAdded` | `rgb(105,219,124)` | 添加行背景(浅绿) |
|
||||
| `diffRemoved` | `rgb(255,168,180)` | 删除行背景(浅红) |
|
||||
| `diffAddedDimmed` | `rgb(199,225,203)` | 添加行弱化(极浅绿) |
|
||||
| `diffRemovedDimmed` | `rgb(253,210,216)` | 删除行弱化(极浅红) |
|
||||
| `diffAddedWord` | `rgb(47,157,68)` | 添加的单词(中绿) |
|
||||
| `diffRemovedWord` | `rgb(209,69,75)` | 删除的单词(中红) |
|
||||
|
||||
#### 背景色
|
||||
|
||||
| Token | 色值 | 用途 |
|
||||
|-------|------|------|
|
||||
| `userMessageBackground` | `rgb(240,240,240)` | 用户消息气泡背景 |
|
||||
| `userMessageBackgroundHover` | `rgb(252,252,252)` | 用户消息悬停 |
|
||||
| `messageActionsBackground` | `rgb(232,236,244)` | 消息操作栏背景 |
|
||||
| `bashMessageBackgroundColor` | `rgb(250,245,250)` | Bash 命令背景 |
|
||||
| `memoryBackgroundColor` | `rgb(230,245,250)` | 记忆块背景 |
|
||||
| `selectionBg` | `rgb(180,213,255)` | 文本选中背景 |
|
||||
|
||||
#### 边框色
|
||||
|
||||
| Token | 色值 | 用途 |
|
||||
|-------|------|------|
|
||||
| `promptBorder` | `rgb(153,153,153)` | 输入框边框 |
|
||||
| `promptBorderShimmer` | `rgb(183,183,183)` | 输入框边框闪烁 |
|
||||
| `bashBorder` | `rgb(255,0,135)` | Bash 块边框(粉色) |
|
||||
|
||||
#### Agent 专用色
|
||||
|
||||
| Token | 色值 |
|
||||
|-------|------|
|
||||
| `red` | `rgb(220,38,38)` |
|
||||
| `blue` | `rgb(37,99,235)` |
|
||||
| `green` | `rgb(22,163,74)` |
|
||||
| `yellow` | `rgb(202,138,4)` |
|
||||
| `purple` | `rgb(147,51,234)` |
|
||||
| `orange` | `rgb(234,88,12)` |
|
||||
| `pink` | `rgb(219,39,119)` |
|
||||
| `cyan` | `rgb(8,145,178)` |
|
||||
|
||||
#### 其他
|
||||
|
||||
| Token | 色值 | 用途 |
|
||||
|-------|------|------|
|
||||
| `rate_limit_fill` | `rgb(87,105,247)` | 速率限制进度条填充 |
|
||||
| `rate_limit_empty` | `rgb(39,47,111)` | 速率限制进度条空白 |
|
||||
| `briefLabelYou` | `rgb(37,99,235)` | 用户标签蓝 |
|
||||
| `briefLabelClaude` | `rgb(215,119,87)` | Claude 标签橙 |
|
||||
|
||||
---
|
||||
|
||||
### 2.2 Dark 主题
|
||||
|
||||
#### 品牌与核心色
|
||||
|
||||
| Token | 色值 | 用途 |
|
||||
|-------|------|------|
|
||||
| `claude` | `rgb(215,119,87)` | 品牌橙(同 Light) |
|
||||
| `claudeShimmer` | `rgb(235,159,127)` | 品牌橙浅色 |
|
||||
| `permission` | `rgb(177,185,249)` | 权限蓝紫 |
|
||||
| `autoAccept` | `rgb(175,135,255)` | 自动接受紫 |
|
||||
| `planMode` | `rgb(72,150,140)` | 计划模式青绿 |
|
||||
| `fastMode` | `rgb(255,120,20)` | 快速模式橙 |
|
||||
|
||||
#### 文本色
|
||||
|
||||
| Token | 色值 |
|
||||
|-------|------|
|
||||
| `text` | `rgb(255,255,255)` |
|
||||
| `inverseText` | `rgb(0,0,0)` |
|
||||
| `inactive` | `rgb(153,153,153)` |
|
||||
| `subtle` | `rgb(80,80,80)` |
|
||||
| `suggestion` | `rgb(177,185,249)` |
|
||||
|
||||
#### 语义色
|
||||
|
||||
| Token | 色值 |
|
||||
|-------|------|
|
||||
| `success` | `rgb(78,186,101)` |
|
||||
| `error` | `rgb(255,107,128)` |
|
||||
| `warning` | `rgb(255,193,7)` |
|
||||
|
||||
#### 背景色
|
||||
|
||||
| Token | 色值 |
|
||||
|-------|------|
|
||||
| `userMessageBackground` | `rgb(55,55,55)` |
|
||||
| `userMessageBackgroundHover` | `rgb(70,70,70)` |
|
||||
| `messageActionsBackground` | `rgb(44,50,62)` |
|
||||
| `bashMessageBackgroundColor` | `rgb(65,60,65)` |
|
||||
| `memoryBackgroundColor` | `rgb(55,65,70)` |
|
||||
| `selectionBg` | `rgb(38,79,120)` |
|
||||
|
||||
#### Diff 色
|
||||
|
||||
| Token | 色值 |
|
||||
|-------|------|
|
||||
| `diffAdded` | `rgb(34,92,43)` |
|
||||
| `diffRemoved` | `rgb(122,41,54)` |
|
||||
| `diffAddedWord` | `rgb(56,166,96)` |
|
||||
| `diffRemovedWord` | `rgb(179,89,107)` |
|
||||
|
||||
---
|
||||
|
||||
### 2.3 应用 UI 表面色(从截图提取)
|
||||
|
||||
截图中使用的 Light 主题 UI 表面色:
|
||||
|
||||
| 区域 | 颜色 | 说明 |
|
||||
|------|------|------|
|
||||
| 应用背景 | `#FFFFFF` | 纯白 |
|
||||
| 侧边栏背景 | `#FAF9F7` | 暖白/米白 |
|
||||
| 侧边栏选中项 | `#F0EDE8` | 暖灰 |
|
||||
| 导航项悬停 | `#F5F3EF` | 极浅暖灰 |
|
||||
| 主内容区背景 | `#FFFFFF` | 纯白 |
|
||||
| 输入框背景 | `#FFFFFF` | 纯白 |
|
||||
| 输入框边框 | `#E5E3DE` | 浅灰 |
|
||||
| 输入框圆角 | `16px` | 大圆角 |
|
||||
| 分隔线 | `#E5E3DE` | 浅灰 |
|
||||
| 状态栏背景 | `#FAF9F7` | 暖白 |
|
||||
| 按钮背景(主要) | `#3D3D3D` | 深灰/黑色 |
|
||||
| 按钮文字(主要) | `#FFFFFF` | 白色 |
|
||||
| 按钮背景(次要) | `#FFFFFF` | 白色 |
|
||||
| 按钮边框(次要) | `#E5E3DE` | 浅灰 |
|
||||
| 下拉菜单背景 | `#FFFFFF` | 白色 |
|
||||
| 下拉菜单阴影 | `0 4px 12px rgba(0,0,0,0.1)` | 微妙阴影 |
|
||||
| 用户消息气泡 | `#F5F0E8` | 暖米色 |
|
||||
| Info 横幅背景 | `#F5F3EF` | 浅暖灰 |
|
||||
|
||||
---
|
||||
|
||||
## 三、排版规范
|
||||
|
||||
### 3.1 字体栈
|
||||
|
||||
```css
|
||||
--font-family-sans: -apple-system, BlinkMacSystemFont, "SF Pro Text", "Segoe UI", Inter, Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
--font-family-mono: "SF Mono", "JetBrains Mono", "Fira Code", Menlo, Monaco, "Courier New", monospace;
|
||||
```
|
||||
|
||||
### 3.2 字号层级
|
||||
|
||||
| 层级 | 字号 | 行高 | 字重 | 用途 |
|
||||
|------|------|------|------|------|
|
||||
| H1 | 24px | 32px | 700 (Bold) | 页面标题(如 "Scheduled tasks") |
|
||||
| H2 | 18px | 24px | 600 (SemiBold) | 区段标题 |
|
||||
| H3 | 16px | 22px | 600 | 对话框标题、组件标题 |
|
||||
| Body | 14px | 20px | 400 (Regular) | 正文文本 |
|
||||
| Small | 13px | 18px | 400 | 次要文本、时间戳 |
|
||||
| Caption | 12px | 16px | 400 | 标签、描述、提示文本 |
|
||||
| Tiny | 11px | 14px | 500 (Medium) | Badge、状态标签 |
|
||||
| Code | 13px | 18px | 400 | 代码块内文本 |
|
||||
|
||||
### 3.3 文本颜色层级
|
||||
|
||||
| 层级 | Light 色值 | Dark 色值 | 用途 |
|
||||
|------|-----------|----------|------|
|
||||
| Primary | `#000000` | `#FFFFFF` | 标题、正文 |
|
||||
| Secondary | `#666666` | `#999999` | 次要信息 |
|
||||
| Tertiary | `#AFAFAF` | `#505050` | 占位符、禁用 |
|
||||
| Accent | `rgb(87,105,247)` | `rgb(177,185,249)` | 链接、强调 |
|
||||
| Brand | `rgb(215,119,87)` | `rgb(215,119,87)` | 品牌元素 |
|
||||
|
||||
---
|
||||
|
||||
## 四、布局规格
|
||||
|
||||
### 4.1 整体布局
|
||||
|
||||
```
|
||||
┌───────────────────────────────────────────────────────────┐
|
||||
│ Title Bar (40px) │
|
||||
├─────────────┬─────────────────────────────────────────────┤
|
||||
│ │ │
|
||||
│ Sidebar │ Main Content Area │
|
||||
│ (280px) │ (flex: 1) │
|
||||
│ │ │
|
||||
│ │ │
|
||||
│ │ │
|
||||
│ │ │
|
||||
│ │ │
|
||||
│ │ │
|
||||
├─────────────┴─────────────────────────────────────────────┤
|
||||
│ Status Bar (36px) │
|
||||
└───────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
| 区域 | 尺寸 | 说明 |
|
||||
|------|------|------|
|
||||
| Title Bar | 高 40px | 固定,不可滚动 |
|
||||
| Sidebar | 宽 280px | 固定宽度,可调整(最小 240px,最大 400px) |
|
||||
| Main Content | flex: 1 | 自适应填充剩余空间 |
|
||||
| Status Bar | 高 36px | 固定,常驻底部 |
|
||||
| 最小窗口尺寸 | 900 x 600px | |
|
||||
| 推荐窗口尺寸 | 1280 x 800px | |
|
||||
|
||||
### 4.2 间距系统
|
||||
|
||||
| Token | 值 | 用途 |
|
||||
|-------|-----|------|
|
||||
| `space-1` | 4px | 最小间距 |
|
||||
| `space-2` | 8px | 紧凑间距 |
|
||||
| `space-3` | 12px | 标准间距 |
|
||||
| `space-4` | 16px | 舒适间距 |
|
||||
| `space-5` | 20px | 区块间距 |
|
||||
| `space-6` | 24px | 大区块间距 |
|
||||
| `space-8` | 32px | 区段间距 |
|
||||
| `space-10` | 40px | 页面边距 |
|
||||
|
||||
### 4.3 圆角规范
|
||||
|
||||
| 元素 | 圆角 |
|
||||
|------|------|
|
||||
| 按钮 | 8px |
|
||||
| 输入框 | 12px |
|
||||
| 对话输入框 | 16px |
|
||||
| 卡片/面板 | 12px |
|
||||
| 下拉菜单 | 12px |
|
||||
| 模态框 | 16px |
|
||||
| Avatar | 50% (圆形) |
|
||||
| Badge | 4px |
|
||||
| Tooltip | 8px |
|
||||
|
||||
---
|
||||
|
||||
## 五、组件设计规格
|
||||
|
||||
### 5.1 Title Bar(标题栏)
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ ● ● ● ← → │ Chat Cowork [Code] │ │
|
||||
└─────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
| 元素 | 规格 |
|
||||
|------|------|
|
||||
| 高度 | 40px |
|
||||
| 背景 | 白色 `#FFFFFF` |
|
||||
| 底部分隔线 | 1px solid `#E5E3DE` |
|
||||
| 红绿灯按钮 | 左偏移 12px,垂直居中 |
|
||||
| 红绿灯尺寸 | 12px 圆形,间距 8px |
|
||||
| 导航箭头 | 尺寸 20px,灰色 `#666666`,hover 变深 |
|
||||
| 标签页 | 居中对齐 |
|
||||
| 标签页字号 | 14px, 字重 500 |
|
||||
| 活跃标签 | 背景 `#F0EDE8`,圆角 8px,padding 6px 12px |
|
||||
| 非活跃标签 | 无背景,色 `#666666` |
|
||||
| 标签间距 | 4px |
|
||||
|
||||
---
|
||||
|
||||
### 5.2 Sidebar(侧边栏)
|
||||
|
||||
```
|
||||
┌──────────────┐
|
||||
│ + New session │ ← 导航项
|
||||
│ 🔍 Search │
|
||||
│ ⏰ Scheduled │
|
||||
│ 📦 Dispatch │
|
||||
│ ⚙ Customize │
|
||||
│ │
|
||||
│ All projects ▾│ ← 项目过滤
|
||||
│ ─────────────│
|
||||
│ Today │ ← 时间分组标题
|
||||
│ ○ Session 1 │ ← 会话项
|
||||
│ ● Session 2 │ ← 选中项
|
||||
│ │
|
||||
│ Previous 7d │
|
||||
│ ○ Session 3 │
|
||||
└──────────────┘
|
||||
```
|
||||
|
||||
| 元素 | 规格 |
|
||||
|------|------|
|
||||
| 宽度 | 280px |
|
||||
| 背景 | `#FAF9F7` (暖白) |
|
||||
| 右侧分隔线 | 1px solid `#E5E3DE` |
|
||||
| 内边距 | 上下 12px,左右 12px |
|
||||
| 导航项高度 | 36px |
|
||||
| 导航项 padding | 8px 12px |
|
||||
| 导航项字号 | 14px |
|
||||
| 导航项图标 | 18px,与文字间距 10px |
|
||||
| 导航项悬停 | 背景 `#F5F3EF`,圆角 8px |
|
||||
| 导航项选中 | 背景 `#F0EDE8`,字重 500 |
|
||||
| 项目过滤器高度 | 32px |
|
||||
| 分组标题 | 12px, 字重 600, 色 `#999999`, uppercase |
|
||||
| 分组标题上边距 | 16px |
|
||||
| 会话项高度 | 36px |
|
||||
| 会话项 padding | 8px 12px 8px 20px |
|
||||
| 选中指示器 | 左侧 4px 宽圆形点,色 `#000000` |
|
||||
| 选中背景 | `#F0EDE8` |
|
||||
|
||||
---
|
||||
|
||||
### 5.3 Status Bar(状态栏)
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────┐
|
||||
│ [N] nanmi │ ⬇ ⇅ │ 🐙 Repo/name 🔀 main □worktree Local▾ │
|
||||
│ Max plan │ │ │
|
||||
└──────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
| 元素 | 规格 |
|
||||
|------|------|
|
||||
| 高度 | 36px |
|
||||
| 背景 | `#FAF9F7` |
|
||||
| 顶部分隔线 | 1px solid `#E5E3DE` |
|
||||
| 内边距 | 0 12px |
|
||||
| 用户 Avatar | 28px 圆形 |
|
||||
| 用户名字号 | 13px, 字重 500 |
|
||||
| 订阅标签 | 11px, 色 `#999999` |
|
||||
| 仓库名 | 13px, 带 GitHub 图标 |
|
||||
| 分支名 | 13px, 带分支图标 |
|
||||
| Worktree 复选框 | 14px |
|
||||
| Local/Remote | 13px 下拉,带 ▾ |
|
||||
| 分隔符 | 1px solid `#E5E3DE`,垂直 |
|
||||
|
||||
---
|
||||
|
||||
### 5.4 对话输入框
|
||||
|
||||
```
|
||||
┌─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─┐
|
||||
│ │
|
||||
│ 🐙 (Clawd 吉祥物) │
|
||||
│ │
|
||||
│ ┌────────────────────────────────────────────────┐ │
|
||||
│ │ Find a small todo in the codebase and do it │ │
|
||||
│ │ │ │
|
||||
│ │ ┌─────────────────┐ ┌──────────────┐│ │
|
||||
│ │ │+ ⚙ Ask perms ▾ │ │Opus 4.6 1M ▾ 🎤││
|
||||
│ │ └─────────────────┘ └──────────────┘│ │
|
||||
│ └────────────────────────────────────────────────┘ │
|
||||
└─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─┘
|
||||
```
|
||||
|
||||
| 元素 | 规格 |
|
||||
|------|------|
|
||||
| 输入框外边距 | 水平 auto(居中),最大宽度 720px |
|
||||
| 输入框圆角 | 16px |
|
||||
| 输入框边框 | 1px solid `#E5E3DE` |
|
||||
| 输入框 focus 边框 | 1px solid `#999999` |
|
||||
| 输入框 padding | 16px 16px 48px 16px |
|
||||
| placeholder 色 | `#AFAFAF` |
|
||||
| placeholder 字号 | 14px |
|
||||
| 底部工具栏高度 | 36px |
|
||||
| 底部工具栏 padding | 0 8px |
|
||||
| `+` 按钮 | 24px, 圆角 6px, hover 背景 `#F5F3EF` |
|
||||
| 权限选择器 | 字号 13px, 左对齐 |
|
||||
| 权限图标 | 16px |
|
||||
| 模型选择器 | 字号 13px, 右对齐 |
|
||||
| 麦克风图标 | 20px, 最右侧 |
|
||||
|
||||
---
|
||||
|
||||
### 5.5 权限模式下拉菜单
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────┐
|
||||
│ ⚙ Ask permissions ✓ │
|
||||
│ Always ask before making changes │
|
||||
│──────────────────────────────────────│
|
||||
│ </> Auto accept edits │
|
||||
│ Automatically accept all file edits│
|
||||
│──────────────────────────────────────│
|
||||
│ 📋 Plan mode │
|
||||
│ Create a plan before making changes│
|
||||
│──────────────────────────────────────│
|
||||
│ ⚠ Bypass permissions │
|
||||
│ Accepts all permissions │
|
||||
└──────────────────────────────────────┘
|
||||
```
|
||||
|
||||
| 元素 | 规格 |
|
||||
|------|------|
|
||||
| 宽度 | 320px |
|
||||
| 背景 | `#FFFFFF` |
|
||||
| 圆角 | 12px |
|
||||
| 阴影 | `0 4px 16px rgba(0,0,0,0.12)` |
|
||||
| 边框 | 1px solid `#E5E3DE` |
|
||||
| 选项高度 | 56px |
|
||||
| 选项 padding | 12px 16px |
|
||||
| 选项标题 | 14px, 字重 500, 色 `#000000` |
|
||||
| 选项描述 | 12px, 色 `#999999` |
|
||||
| 选项图标 | 18px, 左侧 |
|
||||
| ✓ 标记 | 右侧, 色 `#000000` |
|
||||
| 选项间分隔线 | 1px solid `#F0EDE8` |
|
||||
| 选项 hover | 背景 `#F5F3EF` |
|
||||
|
||||
---
|
||||
|
||||
### 5.6 模型选择器下拉菜单
|
||||
|
||||
```
|
||||
┌──────────────────────────────────┐
|
||||
│ Opus 4.6 │
|
||||
│ Most capable for ambitious work │
|
||||
│──────────────────────────────────│
|
||||
│ Opus 4.6 1M ✓ │
|
||||
│ Most capable for ambitious work │
|
||||
│──────────────────────────────────│
|
||||
│ Sonnet 4.6 │
|
||||
│ Most efficient for everyday... │
|
||||
│──────────────────────────────────│
|
||||
│ Haiku 4.5 │
|
||||
│ Fastest for quick answers │
|
||||
│══════════════════════════════════│
|
||||
│ Effort │
|
||||
│──────────────────────────────────│
|
||||
│ Low │
|
||||
│ Medium ✓ │
|
||||
│ High │
|
||||
│ Max │
|
||||
└──────────────────────────────────┘
|
||||
```
|
||||
|
||||
| 元素 | 规格 |
|
||||
|------|------|
|
||||
| 宽度 | 300px |
|
||||
| 背景 | `#FFFFFF` |
|
||||
| 圆角 | 12px |
|
||||
| 阴影 | `0 4px 16px rgba(0,0,0,0.12)` |
|
||||
| 模型项高度 | 52px |
|
||||
| 模型名字号 | 14px, 字重 500 |
|
||||
| 模型描述字号 | 12px, 色 `#999999` |
|
||||
| 分隔线(模型/Effort) | 1px solid `#E5E3DE` |
|
||||
| "Effort" 标签 | 12px, 字重 600, 色 `#999999` |
|
||||
| Effort 项高度 | 36px |
|
||||
| Effort 字号 | 14px |
|
||||
| ✓ 标记 | 右侧, 色 `#000000` |
|
||||
|
||||
---
|
||||
|
||||
### 5.7 消息气泡
|
||||
|
||||
#### 用户消息
|
||||
|
||||
```
|
||||
┌──────────────────────┐
|
||||
│ 分析下这个项目 │
|
||||
└──────────────────────┘
|
||||
```
|
||||
|
||||
| 属性 | 规格 |
|
||||
|------|------|
|
||||
| 对齐 | 右对齐 |
|
||||
| 背景 | `#F5F0E8` (暖米色) |
|
||||
| 圆角 | 16px(左上 16px,右上 4px,右下 16px,左下 16px) |
|
||||
| 最大宽度 | 70% 容器宽度 |
|
||||
| 字号 | 14px |
|
||||
| 字色 | `#000000` |
|
||||
| padding | 10px 14px |
|
||||
| margin-bottom | 12px |
|
||||
|
||||
#### AI 回复消息
|
||||
|
||||
| 属性 | 规格 |
|
||||
|------|------|
|
||||
| 对齐 | 左对齐 |
|
||||
| 背景 | 无(透明) |
|
||||
| 最大宽度 | 100% |
|
||||
| 字号 | 14px |
|
||||
| 字色 | `#000000` |
|
||||
| padding | 0 16px |
|
||||
| margin-bottom | 16px |
|
||||
| Markdown | 使用标准 Markdown 渲染 |
|
||||
|
||||
---
|
||||
|
||||
### 5.8 折叠区段
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────┐
|
||||
│ ▸ Initialized your session │
|
||||
│ Ran a hook on session startup │
|
||||
│ { "hookSpecificOutput": { ... │
|
||||
│ │
|
||||
│ ▸ 探索配置和基础设施 │
|
||||
│ │ Agent 探索项目整体结构 │
|
||||
│ │ Bash · 3 tool calls │
|
||||
│ │ Agent 探索核心业务代码 │
|
||||
│ │ Bash · 2 tool calls │
|
||||
│ │ Agent 探索配置和基础设施 │
|
||||
└──────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
| 元素 | 规格 |
|
||||
|------|------|
|
||||
| 折叠标题 | 14px, 字重 500 |
|
||||
| 折叠图标 | ▸ (收起) / ▾ (展开), 12px |
|
||||
| 左侧竖线 | 2px 宽, 色 `#E5E3DE` |
|
||||
| 子项缩进 | 24px |
|
||||
| `Agent` 标签 | 12px, 字重 500, 背景 `#F0EDE8`, 圆角 4px, padding 2px 6px |
|
||||
| `Bash · N tool calls` 标签 | 12px, 色 `#666666` |
|
||||
| 子项间距 | 4px |
|
||||
| 区段间距 | 8px |
|
||||
|
||||
---
|
||||
|
||||
### 5.9 加载状态
|
||||
|
||||
```
|
||||
✦ Crafting... 13s · ↓ 531 tokens
|
||||
```
|
||||
|
||||
| 元素 | 规格 |
|
||||
|------|------|
|
||||
| ✦ 图标 | 16px, 色 `rgb(215,119,87)`, 闪烁动画 |
|
||||
| 状态文本 | 14px, 色 `rgb(215,119,87)`, 字重 500 |
|
||||
| 计时器 | 13px, 色 `#999999` |
|
||||
| Token 计数 | 13px, 色 `#999999` |
|
||||
| 闪烁周期 | 1.5s ease-in-out |
|
||||
|
||||
---
|
||||
|
||||
### 5.10 Scheduled 页面
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────┐
|
||||
│ Scheduled tasks [+ New task]│
|
||||
│ │
|
||||
│ Run tasks on a schedule or whenever │
|
||||
│ you need them. Type /schedule in any │
|
||||
│ existing session to set one up. │
|
||||
│ │
|
||||
│ ┌─────┐ │
|
||||
│ │ ⏰ │ │
|
||||
│ └─────┘ │
|
||||
│ No scheduled tasks yet. │
|
||||
└──────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
| 元素 | 规格 |
|
||||
|------|------|
|
||||
| 页面 padding | 32px 40px |
|
||||
| 标题字号 | 24px, 字重 700 |
|
||||
| 描述字号 | 14px, 色 `#666666` |
|
||||
| 空状态图标 | 48px, 居中 |
|
||||
| 空状态文本 | 14px, 色 `#999999`, 居中 |
|
||||
| `+ New task` 按钮 | 背景 `#3D3D3D`, 色 `#FFFFFF`, 圆角 8px, padding 8px 16px |
|
||||
|
||||
---
|
||||
|
||||
### 5.11 New Scheduled Task 模态框
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────┐
|
||||
│ New scheduled task │
|
||||
│ │
|
||||
│ ┌──────────────────────────────────────┐ │
|
||||
│ │ ℹ Local tasks only run while your │ │
|
||||
│ │ computer is awake. │ │
|
||||
│ └──────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ Name * │
|
||||
│ ┌──────────────────────────────────────┐ │
|
||||
│ │ daily-code-review │ │
|
||||
│ └──────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ Description * │
|
||||
│ ┌──────────────────────────────────────┐ │
|
||||
│ │ Review yesterday's commits... │ │
|
||||
│ └──────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌──────────────────────────────────────┐ │
|
||||
│ │ Look at the commits from the last │ │
|
||||
│ │ 24 hours. Summarize what changed, │ │
|
||||
│ │ call out any risky patterns... │ │
|
||||
│ │ │ │
|
||||
│ │ ⚙ Ask permissions ▾ Opus 4.6 1M ▾ │ │
|
||||
│ │ [📁 Select folder] □worktree │ │
|
||||
│ └──────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ Frequency │
|
||||
│ ┌──────────────────────────────────────┐ │
|
||||
│ │ Daily ▾ │ │
|
||||
│ └──────────────────────────────────────┘ │
|
||||
│ ┌─────────┐ │
|
||||
│ │ 09:00 │ │
|
||||
│ └─────────┘ │
|
||||
│ Scheduled tasks use a randomized delay... │
|
||||
│ │
|
||||
│ [Cancel] [Create task] │
|
||||
└──────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
| 元素 | 规格 |
|
||||
|------|------|
|
||||
| 模态框宽度 | 560px |
|
||||
| 模态框圆角 | 16px |
|
||||
| 模态框阴影 | `0 8px 32px rgba(0,0,0,0.15)` |
|
||||
| 遮罩层 | `rgba(0,0,0,0.5)` |
|
||||
| 模态框 padding | 24px |
|
||||
| 标题字号 | 20px, 字重 700 |
|
||||
| Info 横幅 | 背景 `#F5F3EF`, 圆角 8px, padding 12px 16px |
|
||||
| Info 图标 | ℹ, 16px, 色 `#666666` |
|
||||
| Label 字号 | 14px, 字重 500 |
|
||||
| 必填标记 | `*`, 色 `#E53E3E` |
|
||||
| 输入框 | 高 40px, 圆角 8px, 边框 1px `#E5E3DE` |
|
||||
| Prompt 输入框 | 最小高 120px, 圆角 12px |
|
||||
| 频率下拉 | 高 40px |
|
||||
| 时间选择器 | 宽 80px, 高 36px, 圆角 8px, 边框 1px `#E5E3DE` |
|
||||
| 注释文本 | 12px, 色 `#999999` |
|
||||
| Cancel 按钮 | 背景 `#FFFFFF`, 边框 1px `#E5E3DE`, 色 `#000000` |
|
||||
| Create task 按钮 | 背景 `#3D3D3D`, 色 `#FFFFFF` |
|
||||
| 按钮间距 | 8px |
|
||||
| 按钮 padding | 8px 20px |
|
||||
| 按钮圆角 | 8px |
|
||||
|
||||
---
|
||||
|
||||
### 5.12 活跃会话标题栏
|
||||
|
||||
```
|
||||
Analyze project structure and codebase ▾ ▷ Preview ▾
|
||||
```
|
||||
|
||||
| 元素 | 规格 |
|
||||
|------|------|
|
||||
| 标题字号 | 14px, 字重 500 |
|
||||
| 标题色 | `#000000` |
|
||||
| ▾ 图标 | 12px, 色 `#999999` |
|
||||
| Preview 按钮 | 字号 13px, 边框 1px `#E5E3DE`, 圆角 6px, padding 4px 10px |
|
||||
| 区域高度 | 44px |
|
||||
| 底部分隔线 | 1px solid `#F0EDE8` |
|
||||
|
||||
---
|
||||
|
||||
## 六、交互状态规范
|
||||
|
||||
### 6.1 按钮状态
|
||||
|
||||
| 状态 | 主要按钮 | 次要按钮 |
|
||||
|------|----------|----------|
|
||||
| 默认 | bg `#3D3D3D`, fg `#FFFFFF` | bg `#FFFFFF`, border `#E5E3DE` |
|
||||
| Hover | bg `#2D2D2D` | bg `#F5F3EF` |
|
||||
| Active | bg `#1D1D1D` | bg `#E5E3DE` |
|
||||
| Disabled | bg `#D0D0D0`, fg `#999999` | bg `#F5F3EF`, fg `#CCCCCC` |
|
||||
| Loading | 显示 spinner,文字不变 | 同上 |
|
||||
|
||||
### 6.2 输入框状态
|
||||
|
||||
| 状态 | 规格 |
|
||||
|------|------|
|
||||
| 默认 | border `#E5E3DE` |
|
||||
| Focus | border `#999999`, box-shadow `0 0 0 3px rgba(153,153,153,0.1)` |
|
||||
| Error | border `rgb(171,43,63)`, box-shadow `0 0 0 3px rgba(171,43,63,0.1)` |
|
||||
| Disabled | bg `#F5F3EF`, border `#E5E3DE`, 文字色 `#CCCCCC` |
|
||||
|
||||
### 6.3 导航项状态
|
||||
|
||||
| 状态 | 规格 |
|
||||
|------|------|
|
||||
| 默认 | 透明背景, 色 `#666666` |
|
||||
| Hover | bg `#F5F3EF`, 色 `#000000` |
|
||||
| Active/选中 | bg `#F0EDE8`, 色 `#000000`, 字重 500 |
|
||||
|
||||
### 6.4 下拉菜单项状态
|
||||
|
||||
| 状态 | 规格 |
|
||||
|------|------|
|
||||
| 默认 | 透明背景 |
|
||||
| Hover | bg `#F5F3EF` |
|
||||
| 选中 | 右侧 ✓ 标记, 色 `#000000` |
|
||||
|
||||
### 6.5 会话列表项状态
|
||||
|
||||
| 状态 | 规格 |
|
||||
|------|------|
|
||||
| 默认 | 透明背景 |
|
||||
| Hover | bg `#F5F3EF` |
|
||||
| 选中 | bg `#F0EDE8`, 左侧实心圆点 |
|
||||
|
||||
---
|
||||
|
||||
## 七、动画规范
|
||||
|
||||
### 7.1 过渡动画
|
||||
|
||||
| 动画 | 属性 | 时长 | 缓动 |
|
||||
|------|------|------|------|
|
||||
| 按钮悬停 | background-color | 150ms | ease |
|
||||
| 导航项切换 | background-color | 200ms | ease-out |
|
||||
| 下拉菜单展开 | opacity + transform(Y) | 200ms | ease-out |
|
||||
| 下拉菜单收起 | opacity + transform(Y) | 150ms | ease-in |
|
||||
| 模态框弹出 | opacity + scale | 250ms | cubic-bezier(0.16,1,0.3,1) |
|
||||
| 模态框关闭 | opacity + scale | 200ms | ease-in |
|
||||
| 折叠/展开 | height | 200ms | ease-out |
|
||||
| 遮罩层 | opacity | 200ms | ease |
|
||||
|
||||
### 7.2 特效动画
|
||||
|
||||
| 动画 | 描述 |
|
||||
|------|------|
|
||||
| Clawd 吉祥物 | 像素眨眼动画,间隔 3-5s |
|
||||
| ✦ 闪烁 | opacity 0.4 ↔ 1.0,周期 1.5s |
|
||||
| Spinner | 旋转 360°,周期 1s,linear |
|
||||
| Shimmer | 品牌色 ↔ shimmer 色,周期 2s,ease-in-out |
|
||||
|
||||
---
|
||||
|
||||
## 八、页面交互流程
|
||||
|
||||
### 8.1 新会话流程
|
||||
|
||||
```
|
||||
用户点击 "New session"
|
||||
→ 侧边栏选中 "New session"
|
||||
→ 主内容区显示 Code 空状态页
|
||||
→ 用户在输入框输入 prompt
|
||||
→ 按 Enter 发送
|
||||
→ 创建新会话,侧边栏添加会话项
|
||||
→ 主内容区切换到活跃会话页
|
||||
→ 显示用户消息气泡
|
||||
→ 显示 AI 加载状态 (✦ Crafting...)
|
||||
→ AI 回复逐步流式渲染
|
||||
→ 工具调用时显示折叠块
|
||||
→ 需要权限时弹出权限请求对话框
|
||||
→ 用户批准/拒绝 → 继续/终止工具执行
|
||||
```
|
||||
|
||||
### 8.2 定时任务创建流程
|
||||
|
||||
```
|
||||
用户点击侧边栏 "Scheduled"
|
||||
→ 主内容区显示 Scheduled 页面
|
||||
→ 用户点击 "+ New task"
|
||||
→ 弹出 "New scheduled task" 模态框
|
||||
→ 填写 Name, Description, Prompt, 选择 Frequency/Time
|
||||
→ 选择权限模式和模型
|
||||
→ 点击 "Create task"
|
||||
→ 模态框关闭
|
||||
→ 任务列表中显示新任务
|
||||
```
|
||||
|
||||
### 8.3 权限模式切换流程
|
||||
|
||||
```
|
||||
用户点击权限模式图标 (⚙)
|
||||
→ 展开下拉菜单
|
||||
→ 用户选择新模式(如 "Plan mode")
|
||||
→ 下拉菜单关闭
|
||||
→ 权限图标更新为新模式图标 (📋)
|
||||
→ 如果选择 "Bypass permissions",弹出安全确认对话框
|
||||
→ 用户确认后切换
|
||||
```
|
||||
|
||||
### 8.4 模型切换流程
|
||||
|
||||
```
|
||||
用户点击模型名称 (Opus 4.6 1M)
|
||||
→ 展开下拉菜单(模型列表 + Effort 等级)
|
||||
→ 用户选择新模型
|
||||
→ 或调整 Effort 等级
|
||||
→ 下拉菜单关闭
|
||||
→ 模型选择器显示更新
|
||||
→ 后续对话使用新模型
|
||||
```
|
||||
|
||||
### 8.5 会话切换流程
|
||||
|
||||
```
|
||||
用户在侧边栏点击历史会话
|
||||
→ 该会话高亮选中
|
||||
→ 主内容区加载该会话的消息历史
|
||||
→ 滚动到最新消息
|
||||
→ 输入框恢复为空/上次未发送的草稿
|
||||
```
|
||||
|
||||
### 8.6 搜索流程
|
||||
|
||||
```
|
||||
用户点击侧边栏 "Search"
|
||||
→ 主内容区显示搜索页
|
||||
→ 用户输入关键词
|
||||
→ 100ms 防抖后发起搜索
|
||||
→ 显示匹配结果列表
|
||||
→ 用户点击结果项
|
||||
→ 显示代码上下文预览
|
||||
→ 用户可跳转到文件或插入到对话
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 九、响应式适配
|
||||
|
||||
### 9.1 窗口尺寸断点
|
||||
|
||||
| 断点 | 宽度 | 布局变化 |
|
||||
|------|------|----------|
|
||||
| 紧凑 | < 900px | 侧边栏可折叠,浮层显示 |
|
||||
| 标准 | 900-1440px | 侧边栏固定 280px |
|
||||
| 宽屏 | > 1440px | 侧边栏可拉宽至 400px |
|
||||
|
||||
### 9.2 侧边栏折叠
|
||||
|
||||
- 窗口 < 900px 时,侧边栏默认折叠
|
||||
- 可通过汉堡菜单图标展开
|
||||
- 展开时覆盖在主内容区上方
|
||||
- 点击主内容区或按 ESC 关闭
|
||||
|
||||
---
|
||||
|
||||
## 十、图标清单
|
||||
|
||||
| 用途 | 图标 | 说明 |
|
||||
|------|------|------|
|
||||
| New session | `+` | 加号,创建新会话 |
|
||||
| Search | 🔍 | 放大镜 |
|
||||
| Scheduled | ⏰ | 时钟 |
|
||||
| Dispatch | 📦 | 发送箱 |
|
||||
| Customize | 🔒 | 锁/齿轮 |
|
||||
| Ask permissions | ⚙ | 齿轮 |
|
||||
| Auto accept | `</>` | 代码符号 |
|
||||
| Plan mode | 📋 | 剪贴板 |
|
||||
| Bypass | ⚠ | 警告三角 |
|
||||
| 添加附件 | `+` | 圆形加号 |
|
||||
| 语音输入 | 🎤 | 麦克风 |
|
||||
| 停止生成 | ⏹ | 方形停止 |
|
||||
| 折叠 | ▸ | 右三角 |
|
||||
| 展开 | ▾ | 下三角 |
|
||||
| GitHub | 🐙 | GitHub logo |
|
||||
| Git 分支 | 🔀 | 分支图标 |
|
||||
| 前进 | → | 右箭头 |
|
||||
| 后退 | ← | 左箭头 |
|
||||
| 下载 | ⬇ | 下箭头 |
|
||||
| 选中 | ✓ | 勾选 |
|
||||
| 信息 | ℹ | 圆形 i |
|
||||
272
docs/ui-clone/03-server-architecture.md
Normal file
272
docs/ui-clone/03-server-architecture.md
Normal file
@ -0,0 +1,272 @@
|
||||
# Claude Code Desktop App — 服务端架构设计
|
||||
|
||||
## 一、设计原则
|
||||
|
||||
1. **非侵入性**: 不修改现有 CLI 源代码,在 `src/server/` 下新建服务层
|
||||
2. **数据一致性**: 读写与 CLI 完全相同的文件(JSONL 会话、JSON 设置、Cron 任务)
|
||||
3. **CLI/UI 互通**: UI 的操作落盘到 CLI 的文件系统,CLI 的会话在 UI 可见
|
||||
4. **渐进式**: 先实现核心 API,再扩展高级功能
|
||||
|
||||
## 二、技术栈
|
||||
|
||||
| 层 | 技术 | 理由 |
|
||||
|----|------|------|
|
||||
| HTTP Server | Bun.serve() | 原项目已用 Bun,零额外依赖 |
|
||||
| WebSocket | Bun 原生 WebSocket | 已有 `ws` 依赖,Bun 原生更高效 |
|
||||
| 验证 | Zod v4 | 已在依赖中 |
|
||||
| 测试 | bun:test | Bun 内置,无需额外依赖 |
|
||||
| API 风格 | REST + WebSocket | REST 用于 CRUD,WS 用于流式传输 |
|
||||
|
||||
## 三、目录结构
|
||||
|
||||
```
|
||||
src/server/
|
||||
├── index.ts # 服务器入口
|
||||
├── router.ts # 路由注册
|
||||
├── middleware/
|
||||
│ ├── auth.ts # API Key 鉴权
|
||||
│ ├── cors.ts # CORS 处理
|
||||
│ └── errorHandler.ts # 统一错误处理
|
||||
├── api/
|
||||
│ ├── sessions.ts # 会话管理 API
|
||||
│ ├── conversations.ts # 对话/消息 API
|
||||
│ ├── settings.ts # 设置 API
|
||||
│ ├── models.ts # 模型选择 API
|
||||
│ ├── scheduled-tasks.ts # 定时任务 API
|
||||
│ ├── search.ts # 搜索 API
|
||||
│ ├── agents.ts # Agent 管理 API
|
||||
│ ├── mcp.ts # MCP 服务器管理 API
|
||||
│ └── status.ts # 状态与诊断 API
|
||||
├── ws/
|
||||
│ ├── handler.ts # WebSocket 连接管理
|
||||
│ ├── chatStream.ts # 对话流式传输
|
||||
│ └── events.ts # 事件类型定义
|
||||
├── services/
|
||||
│ ├── sessionService.ts # 会话服务(封装 sessionStorage)
|
||||
│ ├── conversationService.ts # 对话服务(封装 query engine)
|
||||
│ ├── settingsService.ts # 设置服务(封装 settings)
|
||||
│ ├── cronService.ts # 定时任务服务(封装 cronTasks)
|
||||
│ ├── searchService.ts # 搜索服务(封装 ripgrep)
|
||||
│ ├── agentService.ts # Agent 服务
|
||||
│ └── mcpService.ts # MCP 服务
|
||||
└── __tests__/
|
||||
├── sessions.test.ts
|
||||
├── conversations.test.ts
|
||||
├── settings.test.ts
|
||||
├── scheduled-tasks.test.ts
|
||||
├── search.test.ts
|
||||
└── e2e/
|
||||
└── full-flow.test.ts
|
||||
```
|
||||
|
||||
## 四、API 设计
|
||||
|
||||
### 4.1 会话管理 (Sessions)
|
||||
|
||||
| 方法 | 路径 | 描述 |
|
||||
|------|------|------|
|
||||
| GET | `/api/sessions` | 获取会话列表(支持分页、项目过滤) |
|
||||
| GET | `/api/sessions/:id` | 获取会话详情(标题、消息数、时间) |
|
||||
| POST | `/api/sessions` | 创建新会话 |
|
||||
| DELETE | `/api/sessions/:id` | 删除会话 |
|
||||
| PATCH | `/api/sessions/:id` | 更新会话(重命名) |
|
||||
| GET | `/api/sessions/:id/messages` | 获取会话消息历史 |
|
||||
|
||||
**数据来源**: `~/.claude/projects/{proj}/{sid}.jsonl` (JSONL 格式)
|
||||
|
||||
**实现要点**:
|
||||
- 调用 `loadMessageLogs()` 获取会话列表
|
||||
- 调用 `loadTranscriptFile()` 解析 JSONL 消息
|
||||
- 使用 `getProjectDir()` 定位项目目录
|
||||
- 会话 ID 为 UUID v4
|
||||
|
||||
### 4.2 对话 (Conversations)
|
||||
|
||||
| 方法 | 路径 | 描述 |
|
||||
|------|------|------|
|
||||
| POST | `/api/sessions/:id/chat` | 发送消息(返回任务 ID) |
|
||||
| GET | `/api/sessions/:id/chat/status` | 获取对话状态 |
|
||||
| POST | `/api/sessions/:id/chat/stop` | 停止生成 |
|
||||
|
||||
**WebSocket**: `ws://host:port/ws/chat/:sessionId`
|
||||
- 发送消息 → 流式接收 AI 回复
|
||||
- 实时推送工具调用进度
|
||||
- 权限请求转发给前端
|
||||
|
||||
**WebSocket 消息格式**:
|
||||
```typescript
|
||||
// 客户端 → 服务器
|
||||
type ClientMessage =
|
||||
| { type: 'user_message'; content: string; attachments?: Attachment[] }
|
||||
| { type: 'permission_response'; requestId: string; allowed: boolean }
|
||||
| { type: 'stop_generation' }
|
||||
|
||||
// 服务器 → 客户端
|
||||
type ServerMessage =
|
||||
| { type: 'content_start'; blockType: 'text' | 'tool_use' }
|
||||
| { type: 'content_delta'; text?: string; toolInput?: string }
|
||||
| { type: 'tool_use_complete'; toolName: string; toolUseId: string }
|
||||
| { type: 'tool_result'; toolUseId: string; content: any; isError: boolean }
|
||||
| { type: 'permission_request'; requestId: string; toolName: string; input: any }
|
||||
| { type: 'message_complete'; usage: Usage }
|
||||
| { type: 'error'; message: string; code: string }
|
||||
| { type: 'status'; state: 'thinking' | 'tool_executing' | 'idle' }
|
||||
```
|
||||
|
||||
### 4.3 设置 (Settings)
|
||||
|
||||
| 方法 | 路径 | 描述 |
|
||||
|------|------|------|
|
||||
| GET | `/api/settings` | 获取合并后的设置 |
|
||||
| GET | `/api/settings/user` | 获取用户级设置 |
|
||||
| GET | `/api/settings/project` | 获取项目级设置 |
|
||||
| PUT | `/api/settings/user` | 更新用户级设置 |
|
||||
| PUT | `/api/settings/project` | 更新项目级设置 |
|
||||
| GET | `/api/permissions/mode` | 获取当前权限模式 |
|
||||
| PUT | `/api/permissions/mode` | 切换权限模式 |
|
||||
|
||||
**数据来源**: `~/.claude/settings.json` + `.claude/settings.json`
|
||||
|
||||
### 4.4 模型 (Models)
|
||||
|
||||
| 方法 | 路径 | 描述 |
|
||||
|------|------|------|
|
||||
| GET | `/api/models` | 获取可用模型列表 |
|
||||
| GET | `/api/models/current` | 获取当前选中模型 |
|
||||
| PUT | `/api/models/current` | 切换模型 |
|
||||
| GET | `/api/effort` | 获取当前 Effort 等级 |
|
||||
| PUT | `/api/effort` | 设置 Effort 等级 |
|
||||
|
||||
### 4.5 定时任务 (Scheduled Tasks)
|
||||
|
||||
| 方法 | 路径 | 描述 |
|
||||
|------|------|------|
|
||||
| GET | `/api/scheduled-tasks` | 获取定时任务列表 |
|
||||
| POST | `/api/scheduled-tasks` | 创建定时任务 |
|
||||
| PUT | `/api/scheduled-tasks/:id` | 更新定时任务 |
|
||||
| DELETE | `/api/scheduled-tasks/:id` | 删除定时任务 |
|
||||
|
||||
**数据来源**: `.claude/scheduled_tasks.json`
|
||||
|
||||
### 4.6 搜索 (Search)
|
||||
|
||||
| 方法 | 路径 | 描述 |
|
||||
|------|------|------|
|
||||
| POST | `/api/search` | 全局搜索(ripgrep) |
|
||||
| POST | `/api/search/sessions` | 搜索会话历史 |
|
||||
|
||||
### 4.7 Agent 管理
|
||||
|
||||
| 方法 | 路径 | 描述 |
|
||||
|------|------|------|
|
||||
| GET | `/api/agents` | 获取 Agent 定义列表 |
|
||||
| GET | `/api/agents/:name` | 获取 Agent 详情 |
|
||||
| POST | `/api/agents` | 创建 Agent 定义 |
|
||||
| PUT | `/api/agents/:name` | 更新 Agent 定义 |
|
||||
| DELETE | `/api/agents/:name` | 删除 Agent 定义 |
|
||||
| GET | `/api/tasks` | 获取后台任务列表 |
|
||||
| GET | `/api/tasks/:id` | 获取任务详情 |
|
||||
|
||||
### 4.8 MCP 服务器管理
|
||||
|
||||
| 方法 | 路径 | 描述 |
|
||||
|------|------|------|
|
||||
| GET | `/api/mcp/servers` | 获取 MCP 服务器列表 |
|
||||
| POST | `/api/mcp/servers` | 添加 MCP 服务器 |
|
||||
| DELETE | `/api/mcp/servers/:name` | 移除 MCP 服务器 |
|
||||
| GET | `/api/mcp/tools` | 获取 MCP 工具列表 |
|
||||
|
||||
### 4.9 状态与诊断
|
||||
|
||||
| 方法 | 路径 | 描述 |
|
||||
|------|------|------|
|
||||
| GET | `/api/status` | 服务器状态(健康检查) |
|
||||
| GET | `/api/status/diagnostics` | 系统诊断信息 |
|
||||
| GET | `/api/status/usage` | Token 用量统计 |
|
||||
| GET | `/api/status/user` | 用户信息 |
|
||||
|
||||
## 五、服务层设计
|
||||
|
||||
每个 Service 封装对现有工具函数的调用,提供统一的错误处理和数据转换:
|
||||
|
||||
```typescript
|
||||
// 示例: SessionService
|
||||
class SessionService {
|
||||
// 列出所有会话
|
||||
async listSessions(options: { project?: string; limit?: number; offset?: number }): Promise<SessionListResponse>
|
||||
|
||||
// 获取会话消息
|
||||
async getSessionMessages(sessionId: string): Promise<Message[]>
|
||||
|
||||
// 创建新会话
|
||||
async createSession(workDir: string): Promise<SessionInfo>
|
||||
|
||||
// 删除会话
|
||||
async deleteSession(sessionId: string): Promise<void>
|
||||
|
||||
// 重命名会话
|
||||
async renameSession(sessionId: string, title: string): Promise<void>
|
||||
}
|
||||
```
|
||||
|
||||
**关键实现**:
|
||||
- `SessionService` 调用 `loadMessageLogs()` 和 `loadTranscriptFile()`
|
||||
- `SettingsService` 调用 `getSettings()` 和 `updateSettingsForSource()`
|
||||
- `CronService` 调用 `readCronTasks()` 和 `writeCronTasks()`
|
||||
- `SearchService` 调用 ripgrep 流式搜索
|
||||
- `ConversationService` 使用 `queryModelWithStreaming()` + `StreamingToolExecutor`
|
||||
|
||||
## 六、WebSocket 协议
|
||||
|
||||
### 连接流程
|
||||
|
||||
```
|
||||
1. 客户端连接: ws://host:port/ws/chat/{sessionId}
|
||||
Headers: Authorization: Bearer {apiKey}
|
||||
|
||||
2. 服务器确认: { type: 'connected', sessionId: '...' }
|
||||
|
||||
3. 客户端发送消息: { type: 'user_message', content: '...' }
|
||||
|
||||
4. 服务器流式响应:
|
||||
{ type: 'status', state: 'thinking' }
|
||||
{ type: 'content_start', blockType: 'text' }
|
||||
{ type: 'content_delta', text: 'Let me...' }
|
||||
{ type: 'content_delta', text: ' help you...' }
|
||||
{ type: 'content_start', blockType: 'tool_use', toolName: 'Bash' }
|
||||
{ type: 'tool_use_complete', toolName: 'Bash', toolUseId: '...' }
|
||||
{ type: 'permission_request', requestId: '...', toolName: 'Bash', input: {...} }
|
||||
|
||||
5. 客户端批准: { type: 'permission_response', requestId: '...', allowed: true }
|
||||
|
||||
6. 服务器继续:
|
||||
{ type: 'tool_result', toolUseId: '...', content: '...', isError: false }
|
||||
{ type: 'content_delta', text: 'Done!' }
|
||||
{ type: 'message_complete', usage: { input_tokens: 1000, output_tokens: 500 } }
|
||||
{ type: 'status', state: 'idle' }
|
||||
```
|
||||
|
||||
## 七、鉴权方案
|
||||
|
||||
- 本地运行: 简单 API Key 验证(从 .env 读取)
|
||||
- Header: `Authorization: Bearer {ANTHROPIC_API_KEY}`
|
||||
- WebSocket: 首次连接时通过 URL query 或首条消息验证
|
||||
- 不做复杂的用户系统,依赖 Anthropic API Key 鉴权
|
||||
|
||||
## 八、测试策略
|
||||
|
||||
### 单元测试
|
||||
- 每个 Service 独立测试
|
||||
- Mock 文件系统操作
|
||||
- 验证输入输出格式
|
||||
|
||||
### 集成测试
|
||||
- 启动测试服务器
|
||||
- 发送 HTTP 请求验证响应
|
||||
- WebSocket 连接和消息流测试
|
||||
|
||||
### E2E 测试
|
||||
- 完整对话流程(创建会话 → 发消息 → 收回复 → 停止 → 查看历史)
|
||||
- 定时任务 CRUD
|
||||
- 设置读写
|
||||
- 搜索功能
|
||||
829
src/server/__tests__/e2e/business-flow.test.ts
Normal file
829
src/server/__tests__/e2e/business-flow.test.ts
Normal file
@ -0,0 +1,829 @@
|
||||
/**
|
||||
* Business Flow E2E Tests
|
||||
*
|
||||
* 完整的业务流程测试:涵盖定时任务、权限模式、Agent 管理、
|
||||
* WebSocket 对话、搜索、会话历史互通等所有核心业务逻辑。
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'bun:test'
|
||||
import * as fs from 'fs/promises'
|
||||
import * as path from 'path'
|
||||
import * as os from 'os'
|
||||
|
||||
let server: ReturnType<typeof Bun.serve>
|
||||
let baseUrl: string
|
||||
let wsUrl: string
|
||||
let tmpDir: string
|
||||
|
||||
async function startTestServer() {
|
||||
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-biz-'))
|
||||
process.env.CLAUDE_CONFIG_DIR = tmpDir
|
||||
await fs.mkdir(path.join(tmpDir, 'projects'), { recursive: true })
|
||||
await fs.mkdir(path.join(tmpDir, 'agents'), { recursive: true })
|
||||
|
||||
const { startServer } = await import('../../index.js')
|
||||
const port = 14000 + Math.floor(Math.random() * 1000)
|
||||
server = startServer(port, '127.0.0.1')
|
||||
baseUrl = `http://127.0.0.1:${port}`
|
||||
wsUrl = `ws://127.0.0.1:${port}`
|
||||
}
|
||||
|
||||
async function api(method: string, urlPath: string, body?: unknown) {
|
||||
const res = await fetch(`${baseUrl}${urlPath}`, {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
})
|
||||
const data = await res.json().catch(() => null)
|
||||
return { status: res.status, data }
|
||||
}
|
||||
|
||||
describe('Business Flow: Scheduled Tasks', () => {
|
||||
beforeAll(startTestServer)
|
||||
afterAll(async () => {
|
||||
server?.stop()
|
||||
await fs.rm(tmpDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
// ==========================================================================
|
||||
// 定时任务完整生命周期
|
||||
// ==========================================================================
|
||||
|
||||
it('should start with no scheduled tasks', async () => {
|
||||
const { status, data } = await api('GET', '/api/scheduled-tasks')
|
||||
expect(status).toBe(200)
|
||||
expect(data.tasks).toEqual([])
|
||||
})
|
||||
|
||||
it('should create a daily task with all fields', async () => {
|
||||
const { status, data } = await api('POST', '/api/scheduled-tasks', {
|
||||
name: 'morning-standup',
|
||||
description: 'Generate standup report from yesterday',
|
||||
cron: '0 9 * * 1-5',
|
||||
prompt: 'Look at git log from yesterday, summarize changes, list blockers',
|
||||
recurring: true,
|
||||
permissionMode: 'default',
|
||||
model: 'claude-sonnet-4-6-20250514',
|
||||
folderPath: '/Users/dev/project',
|
||||
useWorktree: true,
|
||||
})
|
||||
expect(status).toBe(201)
|
||||
expect(data.task).toBeDefined()
|
||||
expect(data.task.id).toMatch(/^[0-9a-f]{8}$/)
|
||||
expect(data.task.name).toBe('morning-standup')
|
||||
expect(data.task.cron).toBe('0 9 * * 1-5')
|
||||
expect(data.task.prompt).toContain('git log')
|
||||
expect(data.task.recurring).toBe(true)
|
||||
expect(data.task.permissionMode).toBe('default')
|
||||
expect(data.task.model).toBe('claude-sonnet-4-6-20250514')
|
||||
expect(data.task.createdAt).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('should create a second one-shot task', async () => {
|
||||
const { status, data } = await api('POST', '/api/scheduled-tasks', {
|
||||
cron: '30 14 5 4 *',
|
||||
prompt: 'Run security audit',
|
||||
recurring: false,
|
||||
})
|
||||
expect(status).toBe(201)
|
||||
expect(data.task.recurring).toBe(false)
|
||||
})
|
||||
|
||||
it('should list both tasks', async () => {
|
||||
const { data } = await api('GET', '/api/scheduled-tasks')
|
||||
expect(data.tasks.length).toBe(2)
|
||||
expect(data.tasks[0].name).toBe('morning-standup')
|
||||
})
|
||||
|
||||
it('should update task schedule', async () => {
|
||||
const { data: listData } = await api('GET', '/api/scheduled-tasks')
|
||||
const taskId = listData.tasks[0].id
|
||||
|
||||
const { status, data } = await api('PUT', `/api/scheduled-tasks/${taskId}`, {
|
||||
cron: '0 8 * * 1-5',
|
||||
description: 'Updated: earlier standup',
|
||||
})
|
||||
expect(status).toBe(200)
|
||||
expect(data.task.cron).toBe('0 8 * * 1-5')
|
||||
expect(data.task.description).toBe('Updated: earlier standup')
|
||||
// Other fields should remain unchanged
|
||||
expect(data.task.name).toBe('morning-standup')
|
||||
expect(data.task.prompt).toContain('git log')
|
||||
})
|
||||
|
||||
it('should reject creating task without cron', async () => {
|
||||
const { status, data } = await api('POST', '/api/scheduled-tasks', {
|
||||
prompt: 'missing cron field',
|
||||
})
|
||||
expect(status).toBe(400)
|
||||
expect(data.error).toBeDefined()
|
||||
})
|
||||
|
||||
it('should reject creating task without prompt', async () => {
|
||||
const { status } = await api('POST', '/api/scheduled-tasks', {
|
||||
cron: '0 * * * *',
|
||||
})
|
||||
expect(status).toBe(400)
|
||||
})
|
||||
|
||||
it('should reject updating non-existent task', async () => {
|
||||
const { status } = await api('PUT', '/api/scheduled-tasks/nonexistent', {
|
||||
cron: '0 * * * *',
|
||||
})
|
||||
expect(status).toBe(404)
|
||||
})
|
||||
|
||||
it('should reject deleting non-existent task', async () => {
|
||||
const { status } = await api('DELETE', '/api/scheduled-tasks/nonexistent')
|
||||
expect(status).toBe(404)
|
||||
})
|
||||
|
||||
it('should delete one task', async () => {
|
||||
const { data: listData } = await api('GET', '/api/scheduled-tasks')
|
||||
const taskId = listData.tasks[1].id
|
||||
|
||||
const { status } = await api('DELETE', `/api/scheduled-tasks/${taskId}`)
|
||||
expect([200, 204]).toContain(status)
|
||||
|
||||
const { data: afterDelete } = await api('GET', '/api/scheduled-tasks')
|
||||
expect(afterDelete.tasks.length).toBe(1)
|
||||
})
|
||||
|
||||
it('should persist tasks to disk', async () => {
|
||||
const filePath = path.join(tmpDir, 'scheduled_tasks.json')
|
||||
const raw = await fs.readFile(filePath, 'utf-8')
|
||||
const parsed = JSON.parse(raw)
|
||||
expect(parsed.tasks.length).toBe(1)
|
||||
expect(parsed.tasks[0].name).toBe('morning-standup')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Business Flow: Permission Modes', () => {
|
||||
beforeAll(startTestServer)
|
||||
afterAll(async () => {
|
||||
server?.stop()
|
||||
await fs.rm(tmpDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
const VALID_MODES = ['default', 'acceptEdits', 'plan', 'bypassPermissions', 'dontAsk']
|
||||
|
||||
it('should default to "default" mode', async () => {
|
||||
const { data } = await api('GET', '/api/permissions/mode')
|
||||
expect(data.mode).toBe('default')
|
||||
})
|
||||
|
||||
for (const mode of VALID_MODES) {
|
||||
it(`should switch to "${mode}" mode and persist`, async () => {
|
||||
const { status, data } = await api('PUT', '/api/permissions/mode', { mode })
|
||||
expect(status).toBe(200)
|
||||
expect(data.mode).toBe(mode)
|
||||
|
||||
// Verify it persisted
|
||||
const { data: verify } = await api('GET', '/api/permissions/mode')
|
||||
expect(verify.mode).toBe(mode)
|
||||
})
|
||||
}
|
||||
|
||||
it('should reject invalid mode "auto"', async () => {
|
||||
const { status, data } = await api('PUT', '/api/permissions/mode', { mode: 'auto' })
|
||||
expect(status).toBe(400)
|
||||
expect(data.message).toContain('Invalid permission mode')
|
||||
})
|
||||
|
||||
it('should reject missing mode field', async () => {
|
||||
const { status } = await api('PUT', '/api/permissions/mode', {})
|
||||
expect(status).toBe(400)
|
||||
})
|
||||
|
||||
it('should persist mode to settings file', async () => {
|
||||
await api('PUT', '/api/permissions/mode', { mode: 'plan' })
|
||||
const settingsPath = path.join(tmpDir, 'settings.json')
|
||||
const raw = await fs.readFile(settingsPath, 'utf-8')
|
||||
const settings = JSON.parse(raw)
|
||||
expect(settings.defaultMode).toBe('plan')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Business Flow: Agent Management', () => {
|
||||
beforeAll(startTestServer)
|
||||
afterAll(async () => {
|
||||
server?.stop()
|
||||
await fs.rm(tmpDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('should start with no agents', async () => {
|
||||
const { data } = await api('GET', '/api/agents')
|
||||
expect(data.agents).toEqual([])
|
||||
})
|
||||
|
||||
it('should create a new agent with full config', async () => {
|
||||
const { status, data } = await api('POST', '/api/agents', {
|
||||
name: 'security-auditor',
|
||||
description: 'Audits code for security vulnerabilities',
|
||||
model: 'claude-opus-4-6-20250610',
|
||||
tools: ['Read', 'Grep', 'Glob', 'Bash'],
|
||||
systemPrompt: 'You are a security expert. Focus on OWASP top 10.',
|
||||
color: 'red',
|
||||
})
|
||||
expect(status).toBe(201)
|
||||
})
|
||||
|
||||
it('should create a second agent', async () => {
|
||||
const { status } = await api('POST', '/api/agents', {
|
||||
name: 'test-writer',
|
||||
description: 'Writes unit tests',
|
||||
model: 'claude-sonnet-4-6-20250514',
|
||||
tools: ['Read', 'Write', 'Bash'],
|
||||
})
|
||||
expect(status).toBe(201)
|
||||
})
|
||||
|
||||
it('should list both agents', async () => {
|
||||
const { data } = await api('GET', '/api/agents')
|
||||
expect(data.agents.length).toBe(2)
|
||||
const names = data.agents.map((a: any) => a.name)
|
||||
expect(names).toContain('security-auditor')
|
||||
expect(names).toContain('test-writer')
|
||||
})
|
||||
|
||||
it('should get agent details', async () => {
|
||||
const { data } = await api('GET', '/api/agents/security-auditor')
|
||||
expect(data.agent.name).toBe('security-auditor')
|
||||
expect(data.agent.description).toContain('security')
|
||||
expect(data.agent.model).toBe('claude-opus-4-6-20250610')
|
||||
expect(data.agent.systemPrompt).toContain('OWASP')
|
||||
})
|
||||
|
||||
it('should update agent tools', async () => {
|
||||
const { status, data } = await api('PUT', '/api/agents/security-auditor', {
|
||||
tools: ['Read', 'Grep', 'Glob', 'Bash', 'WebFetch'],
|
||||
description: 'Updated: now with web access',
|
||||
})
|
||||
expect(status).toBe(200)
|
||||
expect(data.agent).toBeDefined()
|
||||
expect(data.agent.name).toBe('security-auditor')
|
||||
expect(data.agent.description).toBe('Updated: now with web access')
|
||||
})
|
||||
|
||||
it('should reject creating duplicate agent', async () => {
|
||||
const { status, data } = await api('POST', '/api/agents', {
|
||||
name: 'security-auditor',
|
||||
description: 'duplicate',
|
||||
})
|
||||
expect(status).toBe(409)
|
||||
expect(data.error).toBe('CONFLICT')
|
||||
})
|
||||
|
||||
it('should reject getting non-existent agent', async () => {
|
||||
const { status } = await api('GET', '/api/agents/nonexistent')
|
||||
expect(status).toBe(404)
|
||||
})
|
||||
|
||||
it('should delete an agent', async () => {
|
||||
const { status } = await api('DELETE', '/api/agents/test-writer')
|
||||
expect([200, 204]).toContain(status)
|
||||
|
||||
const { data } = await api('GET', '/api/agents')
|
||||
expect(data.agents.length).toBe(1)
|
||||
})
|
||||
|
||||
it('should persist agent to YAML file on disk', async () => {
|
||||
const filePath = path.join(tmpDir, 'agents', 'security-auditor.yaml')
|
||||
const raw = await fs.readFile(filePath, 'utf-8')
|
||||
expect(raw).toContain('security-auditor')
|
||||
expect(raw).toContain('OWASP')
|
||||
})
|
||||
|
||||
it('should reject deleting non-existent agent', async () => {
|
||||
const { status } = await api('DELETE', '/api/agents/nonexistent')
|
||||
expect(status).toBe(404)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Business Flow: Models & Effort', () => {
|
||||
beforeAll(startTestServer)
|
||||
afterAll(async () => {
|
||||
server?.stop()
|
||||
await fs.rm(tmpDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('should return 4 available models', async () => {
|
||||
const { data } = await api('GET', '/api/models')
|
||||
expect(data.models.length).toBe(4)
|
||||
const names = data.models.map((m: any) => m.name)
|
||||
expect(names).toContain('Opus 4.6')
|
||||
expect(names).toContain('Opus 4.6 1M')
|
||||
expect(names).toContain('Sonnet 4.6')
|
||||
expect(names).toContain('Haiku 4.5')
|
||||
})
|
||||
|
||||
it('should default to Sonnet model', async () => {
|
||||
const { data } = await api('GET', '/api/models/current')
|
||||
expect(data.model.id).toBe('claude-sonnet-4-6-20250514')
|
||||
})
|
||||
|
||||
it('should switch to Opus 4.6', async () => {
|
||||
const { status } = await api('PUT', '/api/models/current', {
|
||||
modelId: 'claude-opus-4-6-20250610',
|
||||
})
|
||||
expect(status).toBe(200)
|
||||
|
||||
const { data } = await api('GET', '/api/models/current')
|
||||
expect(data.model.id).toBe('claude-opus-4-6-20250610')
|
||||
expect(data.model.name).toBe('Opus 4.6')
|
||||
})
|
||||
|
||||
it('should switch to Haiku 4.5', async () => {
|
||||
await api('PUT', '/api/models/current', { modelId: 'claude-haiku-4-5-20251001' })
|
||||
const { data } = await api('GET', '/api/models/current')
|
||||
expect(data.model.name).toBe('Haiku 4.5')
|
||||
})
|
||||
|
||||
it('should reject empty model ID', async () => {
|
||||
const { status } = await api('PUT', '/api/models/current', { modelId: '' })
|
||||
expect(status).toBe(400)
|
||||
})
|
||||
|
||||
it('should reject missing model ID', async () => {
|
||||
const { status } = await api('PUT', '/api/models/current', {})
|
||||
expect(status).toBe(400)
|
||||
})
|
||||
|
||||
it('should default effort to medium', async () => {
|
||||
const { data } = await api('GET', '/api/effort')
|
||||
expect(data.level).toBe('medium')
|
||||
expect(data.available).toEqual(['low', 'medium', 'high', 'max'])
|
||||
})
|
||||
|
||||
it('should set effort to max', async () => {
|
||||
const { status, data } = await api('PUT', '/api/effort', { level: 'max' })
|
||||
expect(status).toBe(200)
|
||||
expect(data.level).toBe('max')
|
||||
|
||||
const { data: verify } = await api('GET', '/api/effort')
|
||||
expect(verify.level).toBe('max')
|
||||
})
|
||||
|
||||
it('should set effort to low', async () => {
|
||||
await api('PUT', '/api/effort', { level: 'low' })
|
||||
const { data } = await api('GET', '/api/effort')
|
||||
expect(data.level).toBe('low')
|
||||
})
|
||||
|
||||
it('should reject invalid effort level', async () => {
|
||||
const { status, data } = await api('PUT', '/api/effort', { level: 'extreme' })
|
||||
expect(status).toBe(400)
|
||||
expect(data.message).toContain('Invalid effort level')
|
||||
})
|
||||
|
||||
it('should persist model and effort to settings file', async () => {
|
||||
await api('PUT', '/api/models/current', { modelId: 'claude-opus-4-6-20250610' })
|
||||
await api('PUT', '/api/effort', { level: 'high' })
|
||||
|
||||
const settingsPath = path.join(tmpDir, 'settings.json')
|
||||
const raw = await fs.readFile(settingsPath, 'utf-8')
|
||||
const settings = JSON.parse(raw)
|
||||
expect(settings.model).toBe('claude-opus-4-6-20250610')
|
||||
expect(settings.effort).toBe('high')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Business Flow: Sessions & CLI Interop', () => {
|
||||
beforeAll(startTestServer)
|
||||
afterAll(async () => {
|
||||
server?.stop()
|
||||
await fs.rm(tmpDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
let sessionId: string
|
||||
|
||||
it('should create a session', async () => {
|
||||
const { status, data } = await api('POST', '/api/sessions', {
|
||||
workDir: '/Users/dev/my-project',
|
||||
})
|
||||
expect(status).toBe(201)
|
||||
expect(data.sessionId).toMatch(/^[0-9a-f-]{36}$/)
|
||||
sessionId = data.sessionId
|
||||
})
|
||||
|
||||
it('should create session JSONL file on disk (CLI compatible)', async () => {
|
||||
const projectDir = path.join(tmpDir, 'projects')
|
||||
const dirs = await fs.readdir(projectDir)
|
||||
expect(dirs.length).toBeGreaterThan(0)
|
||||
|
||||
// Find the session file
|
||||
let found = false
|
||||
for (const dir of dirs) {
|
||||
const files = await fs.readdir(path.join(projectDir, dir))
|
||||
if (files.some((f) => f === `${sessionId}.jsonl`)) {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
expect(found).toBe(true)
|
||||
})
|
||||
|
||||
it('should simulate CLI writing messages (JSONL format)', async () => {
|
||||
// Simulate what CLI does: append JSONL entries
|
||||
const projectDir = path.join(tmpDir, 'projects')
|
||||
const dirs = await fs.readdir(projectDir)
|
||||
let sessionFile = ''
|
||||
for (const dir of dirs) {
|
||||
const candidate = path.join(projectDir, dir, `${sessionId}.jsonl`)
|
||||
try {
|
||||
await fs.access(candidate)
|
||||
sessionFile = candidate
|
||||
break
|
||||
} catch {}
|
||||
}
|
||||
expect(sessionFile).not.toBe('')
|
||||
|
||||
// Append user message (mimicking CLI JSONL format - must include message.role)
|
||||
const userEntry = {
|
||||
type: 'user',
|
||||
uuid: 'msg-001',
|
||||
message: { role: 'user', content: [{ type: 'text', text: 'Hello from CLI' }] },
|
||||
timestamp: new Date().toISOString(),
|
||||
sessionId,
|
||||
}
|
||||
await fs.appendFile(sessionFile, JSON.stringify(userEntry) + '\n')
|
||||
|
||||
// Append assistant message
|
||||
const assistantEntry = {
|
||||
type: 'assistant',
|
||||
uuid: 'msg-002',
|
||||
message: { role: 'assistant', content: [{ type: 'text', text: 'Hello! How can I help you today?' }] },
|
||||
timestamp: new Date().toISOString(),
|
||||
sessionId,
|
||||
parentUuid: 'msg-001',
|
||||
}
|
||||
await fs.appendFile(sessionFile, JSON.stringify(assistantEntry) + '\n')
|
||||
})
|
||||
|
||||
it('should read CLI-written messages via API', async () => {
|
||||
const { status, data } = await api('GET', `/api/sessions/${sessionId}/messages`)
|
||||
expect(status).toBe(200)
|
||||
expect(data.messages.length).toBe(2)
|
||||
expect(data.messages[0].type).toBe('user')
|
||||
expect(data.messages[0].content).toBeDefined()
|
||||
expect(data.messages[1].type).toBe('assistant')
|
||||
})
|
||||
|
||||
it('should show CLI messages in session list', async () => {
|
||||
const { data } = await api('GET', '/api/sessions')
|
||||
const session = data.sessions.find((s: any) => s.id === sessionId)
|
||||
expect(session).toBeDefined()
|
||||
expect(session.messageCount).toBeGreaterThanOrEqual(2)
|
||||
expect(session.title).toContain('Hello from CLI')
|
||||
})
|
||||
|
||||
it('should rename session and verify', async () => {
|
||||
await api('PATCH', `/api/sessions/${sessionId}`, { title: 'CLI Test Session' })
|
||||
const { data } = await api('GET', `/api/sessions/${sessionId}`)
|
||||
expect(data.title).toBe('CLI Test Session')
|
||||
})
|
||||
|
||||
it('should rename be persisted as JSONL entry (CLI compatible)', async () => {
|
||||
const projectDir = path.join(tmpDir, 'projects')
|
||||
const dirs = await fs.readdir(projectDir)
|
||||
let sessionFile = ''
|
||||
for (const dir of dirs) {
|
||||
const candidate = path.join(projectDir, dir, `${sessionId}.jsonl`)
|
||||
try {
|
||||
await fs.access(candidate)
|
||||
sessionFile = candidate
|
||||
break
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const raw = await fs.readFile(sessionFile, 'utf-8')
|
||||
const lines = raw.trim().split('\n')
|
||||
const lastEntry = JSON.parse(lines[lines.length - 1])
|
||||
expect(lastEntry.type).toBe('custom-title')
|
||||
expect(lastEntry.customTitle).toBe('CLI Test Session')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Business Flow: Search', () => {
|
||||
beforeAll(async () => {
|
||||
await startTestServer()
|
||||
// Create test files to search
|
||||
const testDir = path.join(tmpDir, 'test-workspace')
|
||||
await fs.mkdir(testDir, { recursive: true })
|
||||
await fs.writeFile(path.join(testDir, 'main.ts'), 'export function startServer() {\n console.log("starting")\n}\n')
|
||||
await fs.writeFile(path.join(testDir, 'utils.ts'), 'export function helper() { return 42 }\n')
|
||||
await fs.writeFile(path.join(testDir, 'config.json'), '{"port": 3456}\n')
|
||||
})
|
||||
afterAll(async () => {
|
||||
server?.stop()
|
||||
await fs.rm(tmpDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('should find matches in workspace files', async () => {
|
||||
const { status, data } = await api('POST', '/api/search', {
|
||||
query: 'startServer',
|
||||
cwd: path.join(tmpDir, 'test-workspace'),
|
||||
})
|
||||
expect(status).toBe(200)
|
||||
expect(data.results.length).toBeGreaterThan(0)
|
||||
expect(data.results[0].text).toContain('startServer')
|
||||
})
|
||||
|
||||
it('should respect maxResults', async () => {
|
||||
const { data } = await api('POST', '/api/search', {
|
||||
query: 'export',
|
||||
cwd: path.join(tmpDir, 'test-workspace'),
|
||||
maxResults: 1,
|
||||
})
|
||||
expect(data.results.length).toBeLessThanOrEqual(1)
|
||||
})
|
||||
|
||||
it('should return empty for non-matching query', async () => {
|
||||
const { data } = await api('POST', '/api/search', {
|
||||
query: 'nonexistent_string_xyz123',
|
||||
cwd: path.join(tmpDir, 'test-workspace'),
|
||||
})
|
||||
expect(data.results.length).toBe(0)
|
||||
})
|
||||
|
||||
it('should reject empty query', async () => {
|
||||
const { status } = await api('POST', '/api/search', {
|
||||
query: '',
|
||||
cwd: tmpDir,
|
||||
})
|
||||
expect(status).toBe(400)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Business Flow: WebSocket Chat', () => {
|
||||
beforeAll(startTestServer)
|
||||
afterAll(async () => {
|
||||
server?.stop()
|
||||
await fs.rm(tmpDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('should establish WebSocket connection and receive connected event', async () => {
|
||||
const messages: any[] = []
|
||||
const ws = new WebSocket(`${wsUrl}/ws/ws-test-1`)
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
ws.onmessage = (event) => {
|
||||
messages.push(JSON.parse(event.data as string))
|
||||
if (messages.length >= 1) {
|
||||
ws.close()
|
||||
resolve()
|
||||
}
|
||||
}
|
||||
ws.onerror = () => { ws.close(); resolve() }
|
||||
setTimeout(() => { ws.close(); resolve() }, 3000)
|
||||
})
|
||||
|
||||
expect(messages[0].type).toBe('connected')
|
||||
expect(messages[0].sessionId).toBe('ws-test-1')
|
||||
})
|
||||
|
||||
it('should echo message and transition through states', async () => {
|
||||
const messages: any[] = []
|
||||
const ws = new WebSocket(`${wsUrl}/ws/ws-test-2`)
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
ws.onopen = () => {}
|
||||
ws.onmessage = (event) => {
|
||||
const msg = JSON.parse(event.data as string)
|
||||
messages.push(msg)
|
||||
if (msg.type === 'connected') {
|
||||
ws.send(JSON.stringify({ type: 'user_message', content: 'test message' }))
|
||||
}
|
||||
if (msg.type === 'status' && msg.state === 'idle' && messages.length > 3) {
|
||||
ws.close()
|
||||
resolve()
|
||||
}
|
||||
}
|
||||
ws.onerror = () => { ws.close(); resolve() }
|
||||
setTimeout(() => { ws.close(); resolve() }, 5000)
|
||||
})
|
||||
|
||||
const types = messages.map((m) => m.type)
|
||||
expect(types).toContain('connected')
|
||||
expect(types).toContain('status')
|
||||
expect(types).toContain('content_start')
|
||||
expect(types).toContain('content_delta')
|
||||
expect(types).toContain('message_complete')
|
||||
|
||||
// Should have thinking state first
|
||||
const statusMsgs = messages.filter((m) => m.type === 'status')
|
||||
expect(statusMsgs[0].state).toBe('thinking')
|
||||
})
|
||||
|
||||
it('should handle ping/pong', async () => {
|
||||
const messages: any[] = []
|
||||
const ws = new WebSocket(`${wsUrl}/ws/ws-test-3`)
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
ws.onmessage = (event) => {
|
||||
const msg = JSON.parse(event.data as string)
|
||||
messages.push(msg)
|
||||
if (msg.type === 'connected') {
|
||||
ws.send(JSON.stringify({ type: 'ping' }))
|
||||
}
|
||||
if (msg.type === 'pong') {
|
||||
ws.close()
|
||||
resolve()
|
||||
}
|
||||
}
|
||||
setTimeout(() => { ws.close(); resolve() }, 3000)
|
||||
})
|
||||
|
||||
expect(messages.some((m) => m.type === 'pong')).toBe(true)
|
||||
})
|
||||
|
||||
it('should handle stop_generation', async () => {
|
||||
const messages: any[] = []
|
||||
const ws = new WebSocket(`${wsUrl}/ws/ws-test-4`)
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
ws.onmessage = (event) => {
|
||||
const msg = JSON.parse(event.data as string)
|
||||
messages.push(msg)
|
||||
if (msg.type === 'connected') {
|
||||
ws.send(JSON.stringify({ type: 'stop_generation' }))
|
||||
}
|
||||
if (msg.type === 'status' && msg.state === 'idle') {
|
||||
ws.close()
|
||||
resolve()
|
||||
}
|
||||
}
|
||||
setTimeout(() => { ws.close(); resolve() }, 3000)
|
||||
})
|
||||
|
||||
const idleStatus = messages.find((m) => m.type === 'status' && m.state === 'idle')
|
||||
expect(idleStatus).toBeDefined()
|
||||
})
|
||||
|
||||
it('should handle invalid message gracefully', async () => {
|
||||
const messages: any[] = []
|
||||
const ws = new WebSocket(`${wsUrl}/ws/ws-test-5`)
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
ws.onmessage = (event) => {
|
||||
const msg = JSON.parse(event.data as string)
|
||||
messages.push(msg)
|
||||
if (msg.type === 'connected') {
|
||||
ws.send('not valid json {{{')
|
||||
}
|
||||
if (msg.type === 'error') {
|
||||
ws.close()
|
||||
resolve()
|
||||
}
|
||||
}
|
||||
setTimeout(() => { ws.close(); resolve() }, 3000)
|
||||
})
|
||||
|
||||
const errorMsg = messages.find((m) => m.type === 'error')
|
||||
expect(errorMsg).toBeDefined()
|
||||
expect(errorMsg.code).toBe('PARSE_ERROR')
|
||||
})
|
||||
|
||||
it('should reject invalid session ID in WebSocket URL', async () => {
|
||||
// Path traversal gets resolved by URL parser, so test with special chars
|
||||
const res = await fetch(`${baseUrl}/ws/invalid session!@#`, {
|
||||
headers: { 'Upgrade': 'websocket', 'Connection': 'Upgrade' },
|
||||
})
|
||||
// URL with special chars either returns 400 (invalid ID) or 404 (path resolution)
|
||||
expect([400, 404]).toContain(res.status)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Business Flow: Settings Persistence', () => {
|
||||
beforeAll(startTestServer)
|
||||
afterAll(async () => {
|
||||
server?.stop()
|
||||
await fs.rm(tmpDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('should write and read complex settings', async () => {
|
||||
const settings = {
|
||||
theme: 'dark',
|
||||
model: 'claude-opus-4-6-20250610',
|
||||
effort: 'high',
|
||||
outputStyle: 'verbose',
|
||||
permissions: {
|
||||
allow: ['Bash(npm test)', 'Bash(npm run build)', 'Read'],
|
||||
deny: ['Bash(rm -rf /)'],
|
||||
},
|
||||
}
|
||||
|
||||
await api('PUT', '/api/settings/user', settings)
|
||||
const { data } = await api('GET', '/api/settings/user')
|
||||
|
||||
expect(data.theme).toBe('dark')
|
||||
expect(data.model).toBe('claude-opus-4-6-20250610')
|
||||
expect(data.permissions.allow).toContain('Read')
|
||||
expect(data.permissions.deny).toContain('Bash(rm -rf /)')
|
||||
})
|
||||
|
||||
it('should merge settings (not overwrite)', async () => {
|
||||
// First write
|
||||
await api('PUT', '/api/settings/user', { theme: 'dark' })
|
||||
// Second write (should merge, not overwrite)
|
||||
await api('PUT', '/api/settings/user', { outputStyle: 'concise' })
|
||||
|
||||
const { data } = await api('GET', '/api/settings/user')
|
||||
expect(data.theme).toBe('dark') // Should still be there
|
||||
expect(data.outputStyle).toBe('concise')
|
||||
})
|
||||
|
||||
it('should support project-level settings', async () => {
|
||||
const projectRoot = path.join(tmpDir, 'test-project')
|
||||
await fs.mkdir(path.join(projectRoot, '.claude'), { recursive: true })
|
||||
|
||||
await api('PUT', `/api/settings/project?projectRoot=${encodeURIComponent(projectRoot)}`, {
|
||||
permissions: { allow: ['Bash(make)'] },
|
||||
})
|
||||
|
||||
const { data } = await api('GET', `/api/settings/project?projectRoot=${encodeURIComponent(projectRoot)}`)
|
||||
expect(data.permissions.allow).toContain('Bash(make)')
|
||||
})
|
||||
|
||||
it('should merge user and project settings', async () => {
|
||||
await api('PUT', '/api/settings/user', { theme: 'light', model: 'claude-sonnet-4-6-20250514' })
|
||||
|
||||
const { data } = await api('GET', '/api/settings')
|
||||
expect(data.theme).toBeDefined()
|
||||
expect(data.model).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Business Flow: Status & Diagnostics', () => {
|
||||
beforeAll(startTestServer)
|
||||
afterAll(async () => {
|
||||
server?.stop()
|
||||
await fs.rm(tmpDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('should return health with uptime', async () => {
|
||||
const { data } = await api('GET', '/api/status')
|
||||
expect(data.status).toBe('ok')
|
||||
expect(data.uptime).toBeGreaterThanOrEqual(0)
|
||||
})
|
||||
|
||||
it('should return diagnostics with system info', async () => {
|
||||
const { data } = await api('GET', '/api/status/diagnostics')
|
||||
expect(data.platform).toBe(process.platform)
|
||||
expect(data.arch).toBe(process.arch)
|
||||
expect(data.configDir).toBe(tmpDir)
|
||||
expect(data.memory.rss).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('should return usage stats', async () => {
|
||||
const { data } = await api('GET', '/api/status/usage')
|
||||
expect(data).toHaveProperty('totalInputTokens')
|
||||
expect(data).toHaveProperty('totalOutputTokens')
|
||||
expect(data).toHaveProperty('totalCost')
|
||||
})
|
||||
|
||||
it('should return user info with project list', async () => {
|
||||
const { data } = await api('GET', '/api/status/user')
|
||||
expect(data.configDir).toBe(tmpDir)
|
||||
expect(Array.isArray(data.projects)).toBe(true)
|
||||
})
|
||||
|
||||
it('should reject non-GET methods', async () => {
|
||||
const { status } = await api('POST', '/api/status')
|
||||
expect(status).toBe(405)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Business Flow: Error Handling', () => {
|
||||
beforeAll(startTestServer)
|
||||
afterAll(async () => {
|
||||
server?.stop()
|
||||
await fs.rm(tmpDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('should return 404 for unknown API resource', async () => {
|
||||
const { status, data } = await api('GET', '/api/unknown')
|
||||
expect(status).toBe(404)
|
||||
expect(data.error).toBeDefined()
|
||||
})
|
||||
|
||||
it('should return 404 for unknown session', async () => {
|
||||
const { status } = await api('GET', '/api/sessions/00000000-0000-0000-0000-000000000000')
|
||||
expect(status).toBe(404)
|
||||
})
|
||||
|
||||
it('should handle malformed JSON body gracefully', async () => {
|
||||
const res = await fetch(`${baseUrl}/api/sessions`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: '{invalid json',
|
||||
})
|
||||
expect(res.status).toBe(400)
|
||||
})
|
||||
|
||||
it('should return proper error structure', async () => {
|
||||
const { data } = await api('GET', '/api/sessions/nonexistent')
|
||||
expect(data).toHaveProperty('error')
|
||||
expect(data).toHaveProperty('message')
|
||||
})
|
||||
})
|
||||
351
src/server/__tests__/e2e/full-flow.test.ts
Normal file
351
src/server/__tests__/e2e/full-flow.test.ts
Normal file
@ -0,0 +1,351 @@
|
||||
/**
|
||||
* E2E Test — 完整流程测试
|
||||
*
|
||||
* 启动真实服务器,模拟 UI 前端的完整操作流程。
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'bun:test'
|
||||
import * as fs from 'fs/promises'
|
||||
import * as path from 'path'
|
||||
import * as os from 'os'
|
||||
|
||||
let server: ReturnType<typeof Bun.serve>
|
||||
let baseUrl: string
|
||||
let tmpDir: string
|
||||
|
||||
// Use dynamic import to avoid bundling issues
|
||||
async function startTestServer() {
|
||||
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-e2e-'))
|
||||
process.env.CLAUDE_CONFIG_DIR = tmpDir
|
||||
|
||||
// Create required directories
|
||||
await fs.mkdir(path.join(tmpDir, 'projects'), { recursive: true })
|
||||
|
||||
const { startServer } = await import('../../index.js')
|
||||
const port = 13456 + Math.floor(Math.random() * 1000)
|
||||
server = startServer(port, '127.0.0.1')
|
||||
baseUrl = `http://127.0.0.1:${port}`
|
||||
}
|
||||
|
||||
async function api(method: string, path: string, body?: unknown): Promise<{ status: number; data: any }> {
|
||||
const res = await fetch(`${baseUrl}${path}`, {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
})
|
||||
const data = await res.json().catch(() => null)
|
||||
return { status: res.status, data }
|
||||
}
|
||||
|
||||
describe('E2E: Full Flow', () => {
|
||||
beforeAll(async () => {
|
||||
await startTestServer()
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
server?.stop()
|
||||
await fs.rm(tmpDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
// =============================================
|
||||
// 1. Health & Status
|
||||
// =============================================
|
||||
|
||||
it('should return healthy status', async () => {
|
||||
const res = await fetch(`${baseUrl}/health`)
|
||||
const data = await res.json()
|
||||
expect(data.status).toBe('ok')
|
||||
})
|
||||
|
||||
it('should return server status', async () => {
|
||||
const { data } = await api('GET', '/api/status')
|
||||
expect(data.status).toBe('ok')
|
||||
expect(data.version).toBeDefined()
|
||||
})
|
||||
|
||||
it('should return diagnostics', async () => {
|
||||
const { data } = await api('GET', '/api/status/diagnostics')
|
||||
expect(data.platform).toBe('darwin')
|
||||
expect(data.configDir).toBe(tmpDir)
|
||||
})
|
||||
|
||||
// =============================================
|
||||
// 2. Sessions CRUD
|
||||
// =============================================
|
||||
|
||||
let sessionId: string
|
||||
|
||||
it('should start with empty session list', async () => {
|
||||
const { data } = await api('GET', '/api/sessions')
|
||||
expect(data.sessions).toEqual([])
|
||||
expect(data.total).toBe(0)
|
||||
})
|
||||
|
||||
it('should create a new session', async () => {
|
||||
const { status, data } = await api('POST', '/api/sessions', { workDir: tmpDir })
|
||||
expect(status).toBe(201)
|
||||
expect(data.sessionId).toBeDefined()
|
||||
expect(data.sessionId).toMatch(/^[0-9a-f-]{36}$/)
|
||||
sessionId = data.sessionId
|
||||
})
|
||||
|
||||
it('should list the created session', async () => {
|
||||
const { data } = await api('GET', '/api/sessions')
|
||||
expect(data.sessions.length).toBe(1)
|
||||
expect(data.sessions[0].id).toBe(sessionId)
|
||||
})
|
||||
|
||||
it('should get session detail', async () => {
|
||||
const { status, data } = await api('GET', `/api/sessions/${sessionId}`)
|
||||
expect(status).toBe(200)
|
||||
expect(data.id).toBe(sessionId)
|
||||
})
|
||||
|
||||
it('should rename session', async () => {
|
||||
const { status } = await api('PATCH', `/api/sessions/${sessionId}`, { title: 'My Test Session' })
|
||||
expect(status).toBe(200)
|
||||
|
||||
const { data } = await api('GET', `/api/sessions/${sessionId}`)
|
||||
expect(data.title).toBe('My Test Session')
|
||||
})
|
||||
|
||||
it('should get session messages', async () => {
|
||||
const { status, data } = await api('GET', `/api/sessions/${sessionId}/messages`)
|
||||
expect(status).toBe(200)
|
||||
expect(Array.isArray(data.messages)).toBe(true)
|
||||
})
|
||||
|
||||
it('should delete session', async () => {
|
||||
const { status } = await api('DELETE', `/api/sessions/${sessionId}`)
|
||||
expect(status).toBe(200)
|
||||
|
||||
const { data } = await api('GET', '/api/sessions')
|
||||
expect(data.sessions.length).toBe(0)
|
||||
})
|
||||
|
||||
// =============================================
|
||||
// 3. Settings
|
||||
// =============================================
|
||||
|
||||
it('should get empty settings initially', async () => {
|
||||
const { data } = await api('GET', '/api/settings/user')
|
||||
expect(data).toEqual({})
|
||||
})
|
||||
|
||||
it('should update and read user settings', async () => {
|
||||
await api('PUT', '/api/settings/user', { theme: 'dark', model: 'claude-sonnet-4-6-20250514' })
|
||||
|
||||
const { data } = await api('GET', '/api/settings/user')
|
||||
expect(data.theme).toBe('dark')
|
||||
expect(data.model).toBe('claude-sonnet-4-6-20250514')
|
||||
})
|
||||
|
||||
it('should get and set permission mode', async () => {
|
||||
await api('PUT', '/api/permissions/mode', { mode: 'plan' })
|
||||
|
||||
const { data } = await api('GET', '/api/permissions/mode')
|
||||
expect(data.mode).toBe('plan')
|
||||
})
|
||||
|
||||
it('should reject invalid permission mode', async () => {
|
||||
const { status } = await api('PUT', '/api/permissions/mode', { mode: 'invalid' })
|
||||
expect(status).toBe(400)
|
||||
})
|
||||
|
||||
// =============================================
|
||||
// 4. Models
|
||||
// =============================================
|
||||
|
||||
it('should list available models', async () => {
|
||||
const { data } = await api('GET', '/api/models')
|
||||
expect(data.models.length).toBe(4)
|
||||
expect(data.models[0].name).toBe('Opus 4.6')
|
||||
})
|
||||
|
||||
it('should switch model', async () => {
|
||||
await api('PUT', '/api/models/current', { modelId: 'claude-haiku-4-5-20251001' })
|
||||
|
||||
const { data } = await api('GET', '/api/models/current')
|
||||
expect(data.model.id).toBe('claude-haiku-4-5-20251001')
|
||||
})
|
||||
|
||||
it('should get and set effort level', async () => {
|
||||
await api('PUT', '/api/effort', { level: 'high' })
|
||||
|
||||
const { data } = await api('GET', '/api/effort')
|
||||
expect(data.level).toBe('high')
|
||||
})
|
||||
|
||||
// =============================================
|
||||
// 5. Scheduled Tasks
|
||||
// =============================================
|
||||
|
||||
let taskId: string
|
||||
|
||||
it('should start with empty task list', async () => {
|
||||
const { data } = await api('GET', '/api/scheduled-tasks')
|
||||
expect(data.tasks).toEqual([])
|
||||
})
|
||||
|
||||
it('should create a scheduled task', async () => {
|
||||
const { status, data } = await api('POST', '/api/scheduled-tasks', {
|
||||
cron: '0 9 * * *',
|
||||
prompt: 'Review commits from last 24h',
|
||||
recurring: true,
|
||||
name: 'daily-review',
|
||||
description: 'Daily code review',
|
||||
})
|
||||
expect(status).toBe(201)
|
||||
expect(data.task.id).toBeDefined()
|
||||
expect(data.task.cron).toBe('0 9 * * *')
|
||||
taskId = data.task.id
|
||||
})
|
||||
|
||||
it('should list the created task', async () => {
|
||||
const { data } = await api('GET', '/api/scheduled-tasks')
|
||||
expect(data.tasks.length).toBe(1)
|
||||
expect(data.tasks[0].id).toBe(taskId)
|
||||
})
|
||||
|
||||
it('should update a task', async () => {
|
||||
const { status, data } = await api('PUT', `/api/scheduled-tasks/${taskId}`, {
|
||||
cron: '0 10 * * 1-5',
|
||||
})
|
||||
expect(status).toBe(200)
|
||||
expect(data.task.cron).toBe('0 10 * * 1-5')
|
||||
})
|
||||
|
||||
it('should delete a task', async () => {
|
||||
const { status } = await api('DELETE', `/api/scheduled-tasks/${taskId}`)
|
||||
expect([200, 204]).toContain(status)
|
||||
|
||||
const { data } = await api('GET', '/api/scheduled-tasks')
|
||||
expect(data.tasks).toEqual([])
|
||||
})
|
||||
|
||||
// =============================================
|
||||
// 6. Search
|
||||
// =============================================
|
||||
|
||||
it('should search workspace', async () => {
|
||||
// Create a test file to search
|
||||
await fs.writeFile(path.join(tmpDir, 'test-search.txt'), 'Hello World\nFoo Bar Baz\n')
|
||||
|
||||
const { status, data } = await api('POST', '/api/search', {
|
||||
query: 'Hello',
|
||||
cwd: tmpDir,
|
||||
})
|
||||
expect(status).toBe(200)
|
||||
expect(data.results.length).toBeGreaterThan(0)
|
||||
expect(data.results[0].text).toContain('Hello')
|
||||
})
|
||||
|
||||
// =============================================
|
||||
// 7. Agents
|
||||
// =============================================
|
||||
|
||||
it('should start with empty agent list', async () => {
|
||||
const { data } = await api('GET', '/api/agents')
|
||||
expect(data.agents).toEqual([])
|
||||
})
|
||||
|
||||
it('should create an agent', async () => {
|
||||
const { status } = await api('POST', '/api/agents', {
|
||||
name: 'test-agent',
|
||||
description: 'A test agent',
|
||||
model: 'claude-sonnet-4-6-20250514',
|
||||
})
|
||||
expect(status).toBe(201)
|
||||
})
|
||||
|
||||
it('should list the created agent', async () => {
|
||||
const { data } = await api('GET', '/api/agents')
|
||||
expect(data.agents.length).toBe(1)
|
||||
expect(data.agents[0].name).toBe('test-agent')
|
||||
})
|
||||
|
||||
it('should delete an agent', async () => {
|
||||
const { status } = await api('DELETE', '/api/agents/test-agent')
|
||||
expect([200, 204]).toContain(status)
|
||||
})
|
||||
|
||||
// =============================================
|
||||
// 8. WebSocket Chat
|
||||
// =============================================
|
||||
|
||||
it('should connect via WebSocket', async () => {
|
||||
const wsUrl = baseUrl.replace('http://', 'ws://') + '/ws/test-ws-session'
|
||||
|
||||
const messages: any[] = []
|
||||
const ws = new WebSocket(wsUrl)
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
ws.onopen = () => {
|
||||
// Should receive connected message
|
||||
}
|
||||
ws.onmessage = (event) => {
|
||||
const msg = JSON.parse(event.data as string)
|
||||
messages.push(msg)
|
||||
if (msg.type === 'connected') {
|
||||
// Send a test message
|
||||
ws.send(JSON.stringify({ type: 'user_message', content: 'Hello' }))
|
||||
}
|
||||
if (msg.type === 'status' && msg.state === 'idle' && messages.length > 2) {
|
||||
ws.close()
|
||||
resolve()
|
||||
}
|
||||
}
|
||||
ws.onerror = reject
|
||||
setTimeout(() => {
|
||||
ws.close()
|
||||
resolve()
|
||||
}, 3000)
|
||||
})
|
||||
|
||||
expect(messages[0].type).toBe('connected')
|
||||
expect(messages[0].sessionId).toBe('test-ws-session')
|
||||
})
|
||||
|
||||
// =============================================
|
||||
// 9. Conversation Status
|
||||
// =============================================
|
||||
|
||||
it('should get chat status', async () => {
|
||||
// Create a session first
|
||||
const { data: created } = await api('POST', '/api/sessions', { workDir: tmpDir })
|
||||
|
||||
const { status, data } = await api('GET', `/api/sessions/${created.sessionId}/chat/status`)
|
||||
expect(status).toBe(200)
|
||||
expect(data.state).toBe('idle')
|
||||
|
||||
// Cleanup
|
||||
await api('DELETE', `/api/sessions/${created.sessionId}`)
|
||||
})
|
||||
|
||||
// =============================================
|
||||
// 10. CORS
|
||||
// =============================================
|
||||
|
||||
it('should handle CORS preflight', async () => {
|
||||
const res = await fetch(`${baseUrl}/api/status`, {
|
||||
method: 'OPTIONS',
|
||||
headers: { 'Origin': 'http://localhost:3000' },
|
||||
})
|
||||
expect(res.status).toBe(204)
|
||||
expect(res.headers.get('Access-Control-Allow-Origin')).toBe('http://localhost:3000')
|
||||
})
|
||||
|
||||
// =============================================
|
||||
// 11. Error Handling
|
||||
// =============================================
|
||||
|
||||
it('should return 404 for unknown API', async () => {
|
||||
const { status } = await api('GET', '/api/nonexistent')
|
||||
expect(status).toBe(404)
|
||||
})
|
||||
|
||||
it('should return 404 for unknown session', async () => {
|
||||
const { status } = await api('GET', '/api/sessions/00000000-0000-0000-0000-000000000000')
|
||||
expect(status).toBe(404)
|
||||
})
|
||||
})
|
||||
336
src/server/__tests__/scheduled-tasks.test.ts
Normal file
336
src/server/__tests__/scheduled-tasks.test.ts
Normal file
@ -0,0 +1,336 @@
|
||||
/**
|
||||
* Unit tests for CronService, SearchService, and Scheduled Tasks API
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'bun:test'
|
||||
import * as fs from 'fs/promises'
|
||||
import * as path from 'path'
|
||||
import * as os from 'os'
|
||||
import { CronService } from '../services/cronService.js'
|
||||
import { SearchService } from '../services/searchService.js'
|
||||
|
||||
// ─── Test helpers ───────────────────────────────────────────────────────────
|
||||
|
||||
let tmpDir: string
|
||||
const originalConfigDir = process.env.CLAUDE_CONFIG_DIR
|
||||
|
||||
async function createTmpDir(): Promise<string> {
|
||||
const dir = path.join(os.tmpdir(), `claude-test-${Date.now()}-${Math.random().toString(36).slice(2)}`)
|
||||
await fs.mkdir(dir, { recursive: true })
|
||||
return dir
|
||||
}
|
||||
|
||||
async function cleanupTmpDir(dir: string): Promise<void> {
|
||||
try {
|
||||
await fs.rm(dir, { recursive: true, force: true })
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
// ─── CronService tests ─────────────────────────────────────────────────────
|
||||
|
||||
describe('CronService', () => {
|
||||
let service: CronService
|
||||
|
||||
beforeEach(async () => {
|
||||
tmpDir = await createTmpDir()
|
||||
process.env.CLAUDE_CONFIG_DIR = tmpDir
|
||||
service = new CronService()
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
if (originalConfigDir) {
|
||||
process.env.CLAUDE_CONFIG_DIR = originalConfigDir
|
||||
} else {
|
||||
delete process.env.CLAUDE_CONFIG_DIR
|
||||
}
|
||||
await cleanupTmpDir(tmpDir)
|
||||
})
|
||||
|
||||
it('should return empty list when no tasks file exists', async () => {
|
||||
const tasks = await service.listTasks()
|
||||
expect(tasks).toEqual([])
|
||||
})
|
||||
|
||||
it('should create a task with generated id and createdAt', async () => {
|
||||
const task = await service.createTask({
|
||||
cron: '0 9 * * *',
|
||||
prompt: 'Review commits',
|
||||
recurring: true,
|
||||
})
|
||||
|
||||
expect(task.id).toBeDefined()
|
||||
expect(task.id).toHaveLength(8) // 4 bytes hex
|
||||
expect(task.cron).toBe('0 9 * * *')
|
||||
expect(task.prompt).toBe('Review commits')
|
||||
expect(task.recurring).toBe(true)
|
||||
expect(task.createdAt).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('should persist tasks to file', async () => {
|
||||
await service.createTask({ cron: '0 9 * * *', prompt: 'Task 1' })
|
||||
await service.createTask({ cron: '30 18 * * 5', prompt: 'Task 2' })
|
||||
|
||||
const tasks = await service.listTasks()
|
||||
expect(tasks).toHaveLength(2)
|
||||
expect(tasks[0].prompt).toBe('Task 1')
|
||||
expect(tasks[1].prompt).toBe('Task 2')
|
||||
})
|
||||
|
||||
it('should update an existing task', async () => {
|
||||
const created = await service.createTask({
|
||||
cron: '0 9 * * *',
|
||||
prompt: 'Original prompt',
|
||||
})
|
||||
|
||||
const updated = await service.updateTask(created.id, {
|
||||
prompt: 'Updated prompt',
|
||||
recurring: true,
|
||||
})
|
||||
|
||||
expect(updated.id).toBe(created.id)
|
||||
expect(updated.prompt).toBe('Updated prompt')
|
||||
expect(updated.recurring).toBe(true)
|
||||
expect(updated.createdAt).toBe(created.createdAt)
|
||||
})
|
||||
|
||||
it('should throw when updating a non-existent task', async () => {
|
||||
await expect(
|
||||
service.updateTask('nonexistent', { prompt: 'x' }),
|
||||
).rejects.toThrow('Task not found')
|
||||
})
|
||||
|
||||
it('should delete a task', async () => {
|
||||
const created = await service.createTask({
|
||||
cron: '0 9 * * *',
|
||||
prompt: 'To delete',
|
||||
})
|
||||
|
||||
await service.deleteTask(created.id)
|
||||
const tasks = await service.listTasks()
|
||||
expect(tasks).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('should throw when deleting a non-existent task', async () => {
|
||||
await expect(service.deleteTask('nonexistent')).rejects.toThrow(
|
||||
'Task not found',
|
||||
)
|
||||
})
|
||||
|
||||
it('should generate unique IDs', async () => {
|
||||
const ids = new Set<string>()
|
||||
for (let i = 0; i < 20; i++) {
|
||||
const task = await service.createTask({
|
||||
cron: '* * * * *',
|
||||
prompt: `Task ${i}`,
|
||||
})
|
||||
ids.add(task.id)
|
||||
}
|
||||
expect(ids.size).toBe(20)
|
||||
})
|
||||
|
||||
it('should reject create when cron or prompt is missing', async () => {
|
||||
await expect(
|
||||
service.createTask({ cron: '', prompt: 'something' }),
|
||||
).rejects.toThrow()
|
||||
|
||||
await expect(
|
||||
service.createTask({ cron: '* * * * *', prompt: '' }),
|
||||
).rejects.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
// ─── SearchService tests ────────────────────────────────────────────────────
|
||||
|
||||
describe('SearchService', () => {
|
||||
let service: SearchService
|
||||
let searchDir: string
|
||||
|
||||
beforeEach(async () => {
|
||||
tmpDir = await createTmpDir()
|
||||
process.env.CLAUDE_CONFIG_DIR = tmpDir
|
||||
service = new SearchService()
|
||||
|
||||
// 创建搜索用的临时文件
|
||||
searchDir = path.join(tmpDir, 'workspace')
|
||||
await fs.mkdir(searchDir, { recursive: true })
|
||||
await fs.writeFile(
|
||||
path.join(searchDir, 'hello.txt'),
|
||||
'Hello World\nThis is a test\nAnother line\n',
|
||||
)
|
||||
await fs.writeFile(
|
||||
path.join(searchDir, 'code.ts'),
|
||||
'function greet() {\n return "hello"\n}\n',
|
||||
)
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
if (originalConfigDir) {
|
||||
process.env.CLAUDE_CONFIG_DIR = originalConfigDir
|
||||
} else {
|
||||
delete process.env.CLAUDE_CONFIG_DIR
|
||||
}
|
||||
await cleanupTmpDir(tmpDir)
|
||||
})
|
||||
|
||||
it('should find matches in workspace files', async () => {
|
||||
const results = await service.searchWorkspace('Hello', { cwd: searchDir })
|
||||
expect(results.length).toBeGreaterThan(0)
|
||||
expect(results[0].text).toContain('Hello')
|
||||
})
|
||||
|
||||
it('should return empty results when nothing matches', async () => {
|
||||
const results = await service.searchWorkspace('ZZZZNONEXISTENT', {
|
||||
cwd: searchDir,
|
||||
})
|
||||
expect(results).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('should respect maxResults limit', async () => {
|
||||
// 写入多行匹配
|
||||
const lines = Array.from({ length: 50 }, (_, i) => `match line ${i}`).join(
|
||||
'\n',
|
||||
)
|
||||
await fs.writeFile(path.join(searchDir, 'many.txt'), lines)
|
||||
|
||||
const results = await service.searchWorkspace('match', {
|
||||
cwd: searchDir,
|
||||
maxResults: 5,
|
||||
})
|
||||
expect(results.length).toBeLessThanOrEqual(5)
|
||||
})
|
||||
|
||||
it('should reject empty query', async () => {
|
||||
await expect(service.searchWorkspace('')).rejects.toThrow()
|
||||
})
|
||||
|
||||
it('should return empty session results when no projects dir exists', async () => {
|
||||
const results = await service.searchSessions('test')
|
||||
expect(results).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
// ─── Scheduled Tasks API integration ────────────────────────────────────────
|
||||
|
||||
describe('Scheduled Tasks API', () => {
|
||||
// 直接测试 handler 函数,不需要启动完整服务器
|
||||
let handleScheduledTasksApi: (
|
||||
req: Request,
|
||||
url: URL,
|
||||
segments: string[],
|
||||
) => Promise<Response>
|
||||
|
||||
beforeEach(async () => {
|
||||
tmpDir = await createTmpDir()
|
||||
process.env.CLAUDE_CONFIG_DIR = tmpDir
|
||||
|
||||
// 动态导入以获取最新的环境变量
|
||||
const mod = await import('../api/scheduled-tasks.js')
|
||||
handleScheduledTasksApi = mod.handleScheduledTasksApi
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
if (originalConfigDir) {
|
||||
process.env.CLAUDE_CONFIG_DIR = originalConfigDir
|
||||
} else {
|
||||
delete process.env.CLAUDE_CONFIG_DIR
|
||||
}
|
||||
await cleanupTmpDir(tmpDir)
|
||||
})
|
||||
|
||||
it('should list empty tasks via GET', async () => {
|
||||
const req = new Request('http://localhost/api/scheduled-tasks', {
|
||||
method: 'GET',
|
||||
})
|
||||
const url = new URL(req.url)
|
||||
const resp = await handleScheduledTasksApi(req, url, [
|
||||
'api',
|
||||
'scheduled-tasks',
|
||||
])
|
||||
const body = (await resp.json()) as { tasks: unknown[] }
|
||||
expect(resp.status).toBe(200)
|
||||
expect(body.tasks).toEqual([])
|
||||
})
|
||||
|
||||
it('should create a task via POST', async () => {
|
||||
const req = new Request('http://localhost/api/scheduled-tasks', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
cron: '0 9 * * *',
|
||||
prompt: 'Daily review',
|
||||
recurring: true,
|
||||
}),
|
||||
})
|
||||
const url = new URL(req.url)
|
||||
const resp = await handleScheduledTasksApi(req, url, [
|
||||
'api',
|
||||
'scheduled-tasks',
|
||||
])
|
||||
const body = (await resp.json()) as { task: { id: string; prompt: string } }
|
||||
expect(resp.status).toBe(201)
|
||||
expect(body.task.id).toBeDefined()
|
||||
expect(body.task.prompt).toBe('Daily review')
|
||||
})
|
||||
|
||||
it('should CRUD a full lifecycle', async () => {
|
||||
// Create
|
||||
const createReq = new Request('http://localhost/api/scheduled-tasks', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ cron: '0 9 * * *', prompt: 'Test task' }),
|
||||
})
|
||||
const createResp = await handleScheduledTasksApi(
|
||||
createReq,
|
||||
new URL(createReq.url),
|
||||
['api', 'scheduled-tasks'],
|
||||
)
|
||||
const { task } = (await createResp.json()) as {
|
||||
task: { id: string; prompt: string }
|
||||
}
|
||||
|
||||
// Update
|
||||
const updateReq = new Request(
|
||||
`http://localhost/api/scheduled-tasks/${task.id}`,
|
||||
{
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ prompt: 'Updated task' }),
|
||||
},
|
||||
)
|
||||
const updateResp = await handleScheduledTasksApi(
|
||||
updateReq,
|
||||
new URL(updateReq.url),
|
||||
['api', 'scheduled-tasks', task.id],
|
||||
)
|
||||
const updated = (await updateResp.json()) as {
|
||||
task: { id: string; prompt: string }
|
||||
}
|
||||
expect(updated.task.prompt).toBe('Updated task')
|
||||
|
||||
// Delete
|
||||
const deleteReq = new Request(
|
||||
`http://localhost/api/scheduled-tasks/${task.id}`,
|
||||
{ method: 'DELETE' },
|
||||
)
|
||||
const deleteResp = await handleScheduledTasksApi(
|
||||
deleteReq,
|
||||
new URL(deleteReq.url),
|
||||
['api', 'scheduled-tasks', task.id],
|
||||
)
|
||||
expect(deleteResp.status).toBe(200)
|
||||
|
||||
// Verify empty
|
||||
const listReq = new Request('http://localhost/api/scheduled-tasks', {
|
||||
method: 'GET',
|
||||
})
|
||||
const listResp = await handleScheduledTasksApi(
|
||||
listReq,
|
||||
new URL(listReq.url),
|
||||
['api', 'scheduled-tasks'],
|
||||
)
|
||||
const list = (await listResp.json()) as { tasks: unknown[] }
|
||||
expect(list.tasks).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
548
src/server/__tests__/sessions.test.ts
Normal file
548
src/server/__tests__/sessions.test.ts
Normal file
@ -0,0 +1,548 @@
|
||||
/**
|
||||
* Unit tests for SessionService and Sessions API
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'bun:test'
|
||||
import * as fs from 'node:fs/promises'
|
||||
import * as path from 'node:path'
|
||||
import * as os from 'node:os'
|
||||
import { SessionService } from '../services/sessionService.js'
|
||||
|
||||
// ============================================================================
|
||||
// Test helpers
|
||||
// ============================================================================
|
||||
|
||||
let tmpDir: string
|
||||
let service: SessionService
|
||||
|
||||
/** Create a temporary config dir and configure the service to use it. */
|
||||
async function setupTmpConfigDir(): Promise<string> {
|
||||
tmpDir = path.join(os.tmpdir(), `claude-test-${Date.now()}-${Math.random().toString(36).slice(2)}`)
|
||||
await fs.mkdir(path.join(tmpDir, 'projects'), { recursive: true })
|
||||
process.env.CLAUDE_CONFIG_DIR = tmpDir
|
||||
return tmpDir
|
||||
}
|
||||
|
||||
async function cleanupTmpDir(): Promise<void> {
|
||||
if (tmpDir) {
|
||||
await fs.rm(tmpDir, { recursive: true, force: true })
|
||||
}
|
||||
delete process.env.CLAUDE_CONFIG_DIR
|
||||
}
|
||||
|
||||
/** Write a JSONL session file with given entries. */
|
||||
async function writeSessionFile(
|
||||
projectDir: string,
|
||||
sessionId: string,
|
||||
entries: Record<string, unknown>[]
|
||||
): Promise<string> {
|
||||
const dir = path.join(tmpDir, 'projects', projectDir)
|
||||
await fs.mkdir(dir, { recursive: true })
|
||||
const filePath = path.join(dir, `${sessionId}.jsonl`)
|
||||
const content = entries.map((e) => JSON.stringify(e)).join('\n') + '\n'
|
||||
await fs.writeFile(filePath, content, 'utf-8')
|
||||
return filePath
|
||||
}
|
||||
|
||||
// Sample entries matching real CLI format
|
||||
function makeSnapshotEntry(): Record<string, unknown> {
|
||||
return {
|
||||
type: 'file-history-snapshot',
|
||||
messageId: crypto.randomUUID(),
|
||||
snapshot: {
|
||||
messageId: crypto.randomUUID(),
|
||||
trackedFileBackups: {},
|
||||
timestamp: '2026-01-01T00:00:00.000Z',
|
||||
},
|
||||
isSnapshotUpdate: false,
|
||||
}
|
||||
}
|
||||
|
||||
function makeUserEntry(content: string, uuid?: string): Record<string, unknown> {
|
||||
return {
|
||||
parentUuid: null,
|
||||
isSidechain: false,
|
||||
type: 'user',
|
||||
message: { role: 'user', content },
|
||||
uuid: uuid || crypto.randomUUID(),
|
||||
timestamp: '2026-01-01T00:01:00.000Z',
|
||||
userType: 'external',
|
||||
cwd: '/tmp/test',
|
||||
sessionId: 'test-session',
|
||||
}
|
||||
}
|
||||
|
||||
function makeAssistantEntry(content: string, parentUuid?: string): Record<string, unknown> {
|
||||
return {
|
||||
parentUuid: parentUuid || null,
|
||||
isSidechain: false,
|
||||
type: 'assistant',
|
||||
message: {
|
||||
model: 'claude-opus-4-6',
|
||||
id: `msg_${crypto.randomUUID().slice(0, 20)}`,
|
||||
type: 'message',
|
||||
role: 'assistant',
|
||||
content: [{ type: 'text', text: content }],
|
||||
},
|
||||
uuid: crypto.randomUUID(),
|
||||
timestamp: '2026-01-01T00:02:00.000Z',
|
||||
}
|
||||
}
|
||||
|
||||
function makeMetaUserEntry(): Record<string, unknown> {
|
||||
return {
|
||||
parentUuid: null,
|
||||
isSidechain: false,
|
||||
type: 'user',
|
||||
message: { role: 'user', content: '<local-command-caveat>internal</local-command-caveat>' },
|
||||
isMeta: true,
|
||||
uuid: crypto.randomUUID(),
|
||||
timestamp: '2026-01-01T00:00:30.000Z',
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// SessionService tests
|
||||
// ============================================================================
|
||||
|
||||
describe('SessionService', () => {
|
||||
beforeEach(async () => {
|
||||
await setupTmpConfigDir()
|
||||
service = new SessionService()
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await cleanupTmpDir()
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// listSessions
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
it('should return empty list when no sessions exist', async () => {
|
||||
const result = await service.listSessions()
|
||||
expect(result.sessions).toEqual([])
|
||||
expect(result.total).toBe(0)
|
||||
})
|
||||
|
||||
it('should list sessions from JSONL files', async () => {
|
||||
const sessionId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'
|
||||
await writeSessionFile('-tmp-testproject', sessionId, [
|
||||
makeSnapshotEntry(),
|
||||
makeUserEntry('Hello Claude'),
|
||||
makeAssistantEntry('Hi there!'),
|
||||
])
|
||||
|
||||
const result = await service.listSessions()
|
||||
expect(result.total).toBe(1)
|
||||
expect(result.sessions).toHaveLength(1)
|
||||
|
||||
const session = result.sessions[0]!
|
||||
expect(session.id).toBe(sessionId)
|
||||
expect(session.title).toBe('Hello Claude')
|
||||
expect(session.messageCount).toBe(2) // 1 user + 1 assistant
|
||||
expect(session.projectPath).toBe('-tmp-testproject')
|
||||
})
|
||||
|
||||
it('should paginate results with limit and offset', async () => {
|
||||
// Create 3 sessions
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const id = `0000000${i}-bbbb-cccc-dddd-eeeeeeeeeeee`
|
||||
await writeSessionFile('-tmp-test', id, [
|
||||
makeSnapshotEntry(),
|
||||
makeUserEntry(`Message ${i}`),
|
||||
])
|
||||
}
|
||||
|
||||
const page1 = await service.listSessions({ limit: 2, offset: 0 })
|
||||
expect(page1.total).toBe(3)
|
||||
expect(page1.sessions).toHaveLength(2)
|
||||
|
||||
const page2 = await service.listSessions({ limit: 2, offset: 2 })
|
||||
expect(page2.total).toBe(3)
|
||||
expect(page2.sessions).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('should filter sessions by project', async () => {
|
||||
const id1 = 'aaaaaaaa-1111-cccc-dddd-eeeeeeeeeeee'
|
||||
const id2 = 'aaaaaaaa-2222-cccc-dddd-eeeeeeeeeeee'
|
||||
|
||||
await writeSessionFile('-project-a', id1, [makeSnapshotEntry(), makeUserEntry('In A')])
|
||||
await writeSessionFile('-project-b', id2, [makeSnapshotEntry(), makeUserEntry('In B')])
|
||||
|
||||
const resultA = await service.listSessions({ project: '/project/a' })
|
||||
expect(resultA.total).toBe(1)
|
||||
expect(resultA.sessions[0]!.id).toBe(id1)
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// getSession
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
it('should return null for non-existent session', async () => {
|
||||
const result = await service.getSession('00000000-0000-0000-0000-000000000000')
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it('should return session detail with messages', async () => {
|
||||
const sessionId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'
|
||||
const userUuid = crypto.randomUUID()
|
||||
await writeSessionFile('-tmp-project', sessionId, [
|
||||
makeSnapshotEntry(),
|
||||
makeUserEntry('Tell me a joke', userUuid),
|
||||
makeAssistantEntry('Why did the chicken cross the road?', userUuid),
|
||||
])
|
||||
|
||||
const detail = await service.getSession(sessionId)
|
||||
expect(detail).not.toBeNull()
|
||||
expect(detail!.id).toBe(sessionId)
|
||||
expect(detail!.title).toBe('Tell me a joke')
|
||||
expect(detail!.messages).toHaveLength(2)
|
||||
expect(detail!.messages[0]!.type).toBe('user')
|
||||
expect(detail!.messages[1]!.type).toBe('assistant')
|
||||
})
|
||||
|
||||
it('should skip meta entries in messages', async () => {
|
||||
const sessionId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'
|
||||
await writeSessionFile('-tmp-project', sessionId, [
|
||||
makeSnapshotEntry(),
|
||||
makeMetaUserEntry(),
|
||||
makeUserEntry('Real message'),
|
||||
])
|
||||
|
||||
const detail = await service.getSession(sessionId)
|
||||
expect(detail!.messages).toHaveLength(1)
|
||||
expect(detail!.messages[0]!.content).toBe('Real message')
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// getSessionMessages
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
it('should throw for non-existent session messages', async () => {
|
||||
expect(
|
||||
service.getSessionMessages('00000000-0000-0000-0000-000000000000')
|
||||
).rejects.toThrow('Session not found')
|
||||
})
|
||||
|
||||
it('should return messages only', async () => {
|
||||
const sessionId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'
|
||||
await writeSessionFile('-tmp-project', sessionId, [
|
||||
makeSnapshotEntry(),
|
||||
makeUserEntry('Hello'),
|
||||
makeAssistantEntry('World'),
|
||||
])
|
||||
|
||||
const messages = await service.getSessionMessages(sessionId)
|
||||
expect(messages).toHaveLength(2)
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// createSession
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
it('should create a new session file', async () => {
|
||||
const { sessionId } = await service.createSession('/tmp/my-project')
|
||||
expect(sessionId).toMatch(
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/
|
||||
)
|
||||
|
||||
// Verify the file was created
|
||||
const filePath = path.join(tmpDir, 'projects', '-tmp-my-project', `${sessionId}.jsonl`)
|
||||
const stat = await fs.stat(filePath)
|
||||
expect(stat.isFile()).toBe(true)
|
||||
|
||||
// Verify the file contains the initial snapshot entry
|
||||
const content = await fs.readFile(filePath, 'utf-8')
|
||||
const entry = JSON.parse(content.trim())
|
||||
expect(entry.type).toBe('file-history-snapshot')
|
||||
})
|
||||
|
||||
it('should throw when workDir is missing', async () => {
|
||||
expect(service.createSession('')).rejects.toThrow('workDir is required')
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// deleteSession
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
it('should delete an existing session', async () => {
|
||||
const sessionId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'
|
||||
const filePath = await writeSessionFile('-tmp-project', sessionId, [makeSnapshotEntry()])
|
||||
|
||||
await service.deleteSession(sessionId)
|
||||
|
||||
// File should no longer exist
|
||||
expect(fs.access(filePath)).rejects.toThrow()
|
||||
})
|
||||
|
||||
it('should throw when deleting non-existent session', async () => {
|
||||
expect(
|
||||
service.deleteSession('00000000-0000-0000-0000-000000000000')
|
||||
).rejects.toThrow('Session not found')
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// renameSession
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
it('should rename a session by appending custom-title entry', async () => {
|
||||
const sessionId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'
|
||||
const filePath = await writeSessionFile('-tmp-project', sessionId, [
|
||||
makeSnapshotEntry(),
|
||||
makeUserEntry('Original message'),
|
||||
])
|
||||
|
||||
await service.renameSession(sessionId, 'My Custom Title')
|
||||
|
||||
// Read the file and check the last entry
|
||||
const content = await fs.readFile(filePath, 'utf-8')
|
||||
const lines = content.trim().split('\n')
|
||||
const lastEntry = JSON.parse(lines[lines.length - 1]!)
|
||||
expect(lastEntry.type).toBe('custom-title')
|
||||
expect(lastEntry.customTitle).toBe('My Custom Title')
|
||||
|
||||
// Verify the title is now returned in list
|
||||
const detail = await service.getSession(sessionId)
|
||||
expect(detail!.title).toBe('My Custom Title')
|
||||
})
|
||||
|
||||
it('should throw when renaming non-existent session', async () => {
|
||||
expect(
|
||||
service.renameSession('00000000-0000-0000-0000-000000000000', 'Title')
|
||||
).rejects.toThrow('Session not found')
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Title extraction
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
it('should use first user message as title when no custom title', async () => {
|
||||
const sessionId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'
|
||||
await writeSessionFile('-tmp-project', sessionId, [
|
||||
makeSnapshotEntry(),
|
||||
makeMetaUserEntry(),
|
||||
makeUserEntry('This is my first real question'),
|
||||
])
|
||||
|
||||
const detail = await service.getSession(sessionId)
|
||||
expect(detail!.title).toBe('This is my first real question')
|
||||
})
|
||||
|
||||
it('should truncate long titles to 80 chars', async () => {
|
||||
const sessionId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'
|
||||
const longMessage = 'A'.repeat(120)
|
||||
await writeSessionFile('-tmp-project', sessionId, [
|
||||
makeSnapshotEntry(),
|
||||
makeUserEntry(longMessage),
|
||||
])
|
||||
|
||||
const detail = await service.getSession(sessionId)
|
||||
expect(detail!.title.length).toBe(83) // 80 + '...'
|
||||
expect(detail!.title.endsWith('...')).toBe(true)
|
||||
})
|
||||
|
||||
it('should fall back to "Untitled Session" when no user message', async () => {
|
||||
const sessionId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'
|
||||
await writeSessionFile('-tmp-project', sessionId, [makeSnapshotEntry()])
|
||||
|
||||
const detail = await service.getSession(sessionId)
|
||||
expect(detail!.title).toBe('Untitled Session')
|
||||
})
|
||||
})
|
||||
|
||||
// ============================================================================
|
||||
// Sessions API integration tests
|
||||
// ============================================================================
|
||||
|
||||
describe('Sessions API', () => {
|
||||
let baseUrl: string
|
||||
let server: ReturnType<typeof Bun.serve> | null = null
|
||||
|
||||
beforeEach(async () => {
|
||||
await setupTmpConfigDir()
|
||||
service = new SessionService()
|
||||
|
||||
// Import and start a minimal test server
|
||||
const { handleSessionsApi } = await import('../api/sessions.js')
|
||||
const { handleConversationsApi } = await import('../api/conversations.js')
|
||||
|
||||
const port = 30000 + Math.floor(Math.random() * 10000)
|
||||
baseUrl = `http://127.0.0.1:${port}`
|
||||
|
||||
server = Bun.serve({
|
||||
port,
|
||||
hostname: '127.0.0.1',
|
||||
|
||||
async fetch(req) {
|
||||
const url = new URL(req.url)
|
||||
const segments = url.pathname.split('/').filter(Boolean)
|
||||
|
||||
if (segments[0] === 'api' && segments[1] === 'sessions') {
|
||||
// Route chat sub-resource to conversations handler
|
||||
if (segments[3] === 'chat') {
|
||||
return handleConversationsApi(req, url, segments)
|
||||
}
|
||||
return handleSessionsApi(req, url, segments)
|
||||
}
|
||||
|
||||
return new Response('Not Found', { status: 404 })
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
if (server) {
|
||||
server.stop(true)
|
||||
server = null
|
||||
}
|
||||
await cleanupTmpDir()
|
||||
})
|
||||
|
||||
it('GET /api/sessions should return empty list', async () => {
|
||||
const res = await fetch(`${baseUrl}/api/sessions`)
|
||||
expect(res.status).toBe(200)
|
||||
|
||||
const body = (await res.json()) as { sessions: unknown[]; total: number }
|
||||
expect(body.sessions).toEqual([])
|
||||
expect(body.total).toBe(0)
|
||||
})
|
||||
|
||||
it('POST /api/sessions should create a session', async () => {
|
||||
const res = await fetch(`${baseUrl}/api/sessions`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ workDir: '/tmp/test-project' }),
|
||||
})
|
||||
expect(res.status).toBe(201)
|
||||
|
||||
const body = (await res.json()) as { sessionId: string }
|
||||
expect(body.sessionId).toMatch(
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/
|
||||
)
|
||||
})
|
||||
|
||||
it('POST /api/sessions should reject missing workDir', async () => {
|
||||
const res = await fetch(`${baseUrl}/api/sessions`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({}),
|
||||
})
|
||||
expect(res.status).toBe(400)
|
||||
})
|
||||
|
||||
it('GET /api/sessions/:id should return session detail', async () => {
|
||||
// Create a session file
|
||||
const sessionId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'
|
||||
await writeSessionFile('-tmp-api-test', sessionId, [
|
||||
makeSnapshotEntry(),
|
||||
makeUserEntry('API test message'),
|
||||
makeAssistantEntry('API test response'),
|
||||
])
|
||||
|
||||
const res = await fetch(`${baseUrl}/api/sessions/${sessionId}`)
|
||||
expect(res.status).toBe(200)
|
||||
|
||||
const body = (await res.json()) as { id: string; title: string; messages: unknown[] }
|
||||
expect(body.id).toBe(sessionId)
|
||||
expect(body.title).toBe('API test message')
|
||||
expect(body.messages).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('GET /api/sessions/:id should 404 for unknown session', async () => {
|
||||
const res = await fetch(`${baseUrl}/api/sessions/00000000-0000-0000-0000-000000000000`)
|
||||
expect(res.status).toBe(404)
|
||||
})
|
||||
|
||||
it('GET /api/sessions/:id/messages should return messages', async () => {
|
||||
const sessionId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'
|
||||
await writeSessionFile('-tmp-api-test', sessionId, [
|
||||
makeSnapshotEntry(),
|
||||
makeUserEntry('Hello'),
|
||||
makeAssistantEntry('World'),
|
||||
])
|
||||
|
||||
const res = await fetch(`${baseUrl}/api/sessions/${sessionId}/messages`)
|
||||
expect(res.status).toBe(200)
|
||||
|
||||
const body = (await res.json()) as { messages: unknown[] }
|
||||
expect(body.messages).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('DELETE /api/sessions/:id should delete the session', async () => {
|
||||
const sessionId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'
|
||||
await writeSessionFile('-tmp-api-test', sessionId, [makeSnapshotEntry()])
|
||||
|
||||
const res = await fetch(`${baseUrl}/api/sessions/${sessionId}`, { method: 'DELETE' })
|
||||
expect(res.status).toBe(200)
|
||||
|
||||
// Verify it's gone
|
||||
const res2 = await fetch(`${baseUrl}/api/sessions/${sessionId}`)
|
||||
expect(res2.status).toBe(404)
|
||||
})
|
||||
|
||||
it('PATCH /api/sessions/:id should rename the session', async () => {
|
||||
const sessionId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'
|
||||
await writeSessionFile('-tmp-api-test', sessionId, [
|
||||
makeSnapshotEntry(),
|
||||
makeUserEntry('Old title message'),
|
||||
])
|
||||
|
||||
const res = await fetch(`${baseUrl}/api/sessions/${sessionId}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ title: 'New Custom Title' }),
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
|
||||
// Verify new title
|
||||
const detailRes = await fetch(`${baseUrl}/api/sessions/${sessionId}`)
|
||||
const detail = (await detailRes.json()) as { title: string }
|
||||
expect(detail.title).toBe('New Custom Title')
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Conversations API via /api/sessions/:id/chat
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
it('GET /api/sessions/:id/chat/status should return idle by default', async () => {
|
||||
const sessionId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'
|
||||
const res = await fetch(`${baseUrl}/api/sessions/${sessionId}/chat/status`)
|
||||
expect(res.status).toBe(200)
|
||||
|
||||
const body = (await res.json()) as { state: string }
|
||||
expect(body.state).toBe('idle')
|
||||
})
|
||||
|
||||
it('POST /api/sessions/:id/chat should queue a message', async () => {
|
||||
const sessionId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'
|
||||
await writeSessionFile('-tmp-api-test', sessionId, [
|
||||
makeSnapshotEntry(),
|
||||
makeUserEntry('Previous'),
|
||||
])
|
||||
|
||||
const res = await fetch(`${baseUrl}/api/sessions/${sessionId}/chat`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ content: 'New question' }),
|
||||
})
|
||||
expect(res.status).toBe(202)
|
||||
|
||||
const body = (await res.json()) as { messageId: string; status: string }
|
||||
expect(body.status).toBe('queued')
|
||||
expect(body.messageId).toBeTruthy()
|
||||
})
|
||||
|
||||
it('POST /api/sessions/:id/chat/stop should reset state to idle', async () => {
|
||||
const sessionId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'
|
||||
const res = await fetch(`${baseUrl}/api/sessions/${sessionId}/chat/stop`, {
|
||||
method: 'POST',
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
|
||||
// Verify state is idle
|
||||
const statusRes = await fetch(`${baseUrl}/api/sessions/${sessionId}/chat/status`)
|
||||
const status = (await statusRes.json()) as { state: string }
|
||||
expect(status.state).toBe('idle')
|
||||
})
|
||||
})
|
||||
376
src/server/__tests__/settings.test.ts
Normal file
376
src/server/__tests__/settings.test.ts
Normal file
@ -0,0 +1,376 @@
|
||||
/**
|
||||
* Unit tests for Settings, Models, and Status APIs
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'bun:test'
|
||||
import * as fs from 'fs/promises'
|
||||
import * as path from 'path'
|
||||
import * as os from 'os'
|
||||
import { SettingsService } from '../services/settingsService.js'
|
||||
import { handleSettingsApi } from '../api/settings.js'
|
||||
import { handleModelsApi } from '../api/models.js'
|
||||
import { handleStatusApi, resetUsage, addUsage } from '../api/status.js'
|
||||
|
||||
// ─── Test helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
let tmpDir: string
|
||||
let originalConfigDir: string | undefined
|
||||
|
||||
async function setup() {
|
||||
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-test-'))
|
||||
originalConfigDir = process.env.CLAUDE_CONFIG_DIR
|
||||
process.env.CLAUDE_CONFIG_DIR = tmpDir
|
||||
}
|
||||
|
||||
async function teardown() {
|
||||
if (originalConfigDir !== undefined) {
|
||||
process.env.CLAUDE_CONFIG_DIR = originalConfigDir
|
||||
} else {
|
||||
delete process.env.CLAUDE_CONFIG_DIR
|
||||
}
|
||||
await fs.rm(tmpDir, { recursive: true, force: true })
|
||||
}
|
||||
|
||||
/** 创建一个模拟 Request */
|
||||
function makeRequest(
|
||||
method: string,
|
||||
urlStr: string,
|
||||
body?: Record<string, unknown>,
|
||||
): { req: Request; url: URL; segments: string[] } {
|
||||
const url = new URL(urlStr, 'http://localhost:3456')
|
||||
const init: RequestInit = { method }
|
||||
if (body) {
|
||||
init.headers = { 'Content-Type': 'application/json' }
|
||||
init.body = JSON.stringify(body)
|
||||
}
|
||||
const req = new Request(url.toString(), init)
|
||||
const segments = url.pathname.split('/').filter(Boolean)
|
||||
return { req, url, segments }
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// SettingsService
|
||||
// =============================================================================
|
||||
|
||||
describe('SettingsService', () => {
|
||||
beforeEach(setup)
|
||||
afterEach(teardown)
|
||||
|
||||
it('should return empty object when settings file does not exist', async () => {
|
||||
const svc = new SettingsService()
|
||||
const settings = await svc.getUserSettings()
|
||||
expect(settings).toEqual({})
|
||||
})
|
||||
|
||||
it('should write and read user settings', async () => {
|
||||
const svc = new SettingsService()
|
||||
await svc.updateUserSettings({ theme: 'dark', model: 'claude-opus-4-6-20250610' })
|
||||
|
||||
const settings = await svc.getUserSettings()
|
||||
expect(settings.theme).toBe('dark')
|
||||
expect(settings.model).toBe('claude-opus-4-6-20250610')
|
||||
})
|
||||
|
||||
it('should merge settings on update (shallow merge)', async () => {
|
||||
const svc = new SettingsService()
|
||||
await svc.updateUserSettings({ theme: 'dark' })
|
||||
await svc.updateUserSettings({ model: 'claude-haiku-4-5-20251001' })
|
||||
|
||||
const settings = await svc.getUserSettings()
|
||||
expect(settings.theme).toBe('dark')
|
||||
expect(settings.model).toBe('claude-haiku-4-5-20251001')
|
||||
})
|
||||
|
||||
it('should read and write project settings', async () => {
|
||||
const projectRoot = path.join(tmpDir, 'myproject')
|
||||
await fs.mkdir(path.join(projectRoot, '.claude'), { recursive: true })
|
||||
|
||||
const svc = new SettingsService(projectRoot)
|
||||
await svc.updateProjectSettings({ outputStyle: 'verbose' })
|
||||
|
||||
const settings = await svc.getProjectSettings()
|
||||
expect(settings.outputStyle).toBe('verbose')
|
||||
})
|
||||
|
||||
it('should merge user and project settings', async () => {
|
||||
const projectRoot = path.join(tmpDir, 'myproject')
|
||||
await fs.mkdir(path.join(projectRoot, '.claude'), { recursive: true })
|
||||
|
||||
const svc = new SettingsService(projectRoot)
|
||||
await svc.updateUserSettings({ theme: 'dark', model: 'claude-opus-4-6-20250610' })
|
||||
await svc.updateProjectSettings({ theme: 'light' })
|
||||
|
||||
const merged = await svc.getSettings()
|
||||
// project overrides user
|
||||
expect(merged.theme).toBe('light')
|
||||
// user value preserved when not overridden
|
||||
expect(merged.model).toBe('claude-opus-4-6-20250610')
|
||||
})
|
||||
|
||||
it('should get default permission mode', async () => {
|
||||
const svc = new SettingsService()
|
||||
const mode = await svc.getPermissionMode()
|
||||
expect(mode).toBe('default')
|
||||
})
|
||||
|
||||
it('should set and get permission mode', async () => {
|
||||
const svc = new SettingsService()
|
||||
await svc.setPermissionMode('plan')
|
||||
const mode = await svc.getPermissionMode()
|
||||
expect(mode).toBe('plan')
|
||||
})
|
||||
|
||||
it('should reject invalid permission mode', async () => {
|
||||
const svc = new SettingsService()
|
||||
await expect(svc.setPermissionMode('invalid')).rejects.toThrow('Invalid permission mode')
|
||||
})
|
||||
|
||||
it('should preserve other settings when updating permission mode', async () => {
|
||||
const svc = new SettingsService()
|
||||
await svc.updateUserSettings({ theme: 'dark' })
|
||||
await svc.setPermissionMode('acceptEdits')
|
||||
|
||||
const settings = await svc.getUserSettings()
|
||||
expect(settings.theme).toBe('dark')
|
||||
expect(settings.defaultMode).toBe('acceptEdits')
|
||||
})
|
||||
})
|
||||
|
||||
// =============================================================================
|
||||
// Settings API
|
||||
// =============================================================================
|
||||
|
||||
describe('Settings API', () => {
|
||||
beforeEach(setup)
|
||||
afterEach(teardown)
|
||||
|
||||
it('GET /api/settings should return merged settings', async () => {
|
||||
// Seed some user settings
|
||||
const settingsPath = path.join(tmpDir, 'settings.json')
|
||||
await fs.writeFile(settingsPath, JSON.stringify({ theme: 'dark' }))
|
||||
|
||||
const { req, url, segments } = makeRequest('GET', '/api/settings')
|
||||
const res = await handleSettingsApi(req, url, segments)
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
const body = await res.json()
|
||||
expect(body.theme).toBe('dark')
|
||||
})
|
||||
|
||||
it('GET /api/settings/user should return user settings', async () => {
|
||||
const { req, url, segments } = makeRequest('GET', '/api/settings/user')
|
||||
const res = await handleSettingsApi(req, url, segments)
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
const body = await res.json()
|
||||
expect(body).toEqual({})
|
||||
})
|
||||
|
||||
it('PUT /api/settings/user should update user settings', async () => {
|
||||
const { req, url, segments } = makeRequest('PUT', '/api/settings/user', {
|
||||
model: 'claude-opus-4-6-20250610',
|
||||
})
|
||||
const res = await handleSettingsApi(req, url, segments)
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
const body = await res.json()
|
||||
expect(body.ok).toBe(true)
|
||||
|
||||
// Verify persisted
|
||||
const { req: r2, url: u2, segments: s2 } = makeRequest('GET', '/api/settings/user')
|
||||
const res2 = await handleSettingsApi(r2, u2, s2)
|
||||
const body2 = await res2.json()
|
||||
expect(body2.model).toBe('claude-opus-4-6-20250610')
|
||||
})
|
||||
|
||||
it('GET /api/permissions/mode should return default mode', async () => {
|
||||
const { req, url, segments } = makeRequest('GET', '/api/permissions/mode')
|
||||
const res = await handleSettingsApi(req, url, segments)
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
const body = await res.json()
|
||||
expect(body.mode).toBe('default')
|
||||
})
|
||||
|
||||
it('PUT /api/permissions/mode should set mode', async () => {
|
||||
const { req, url, segments } = makeRequest('PUT', '/api/permissions/mode', {
|
||||
mode: 'bypassPermissions',
|
||||
})
|
||||
const res = await handleSettingsApi(req, url, segments)
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
const body = await res.json()
|
||||
expect(body.ok).toBe(true)
|
||||
expect(body.mode).toBe('bypassPermissions')
|
||||
})
|
||||
|
||||
it('PUT /api/permissions/mode should reject invalid mode', async () => {
|
||||
const { req, url, segments } = makeRequest('PUT', '/api/permissions/mode', {
|
||||
mode: 'yolo',
|
||||
})
|
||||
const res = await handleSettingsApi(req, url, segments)
|
||||
|
||||
expect(res.status).toBe(400)
|
||||
})
|
||||
|
||||
it('should return 404 for unknown settings endpoint', async () => {
|
||||
const { req, url, segments } = makeRequest('GET', '/api/settings/unknown')
|
||||
const res = await handleSettingsApi(req, url, segments)
|
||||
expect(res.status).toBe(404)
|
||||
})
|
||||
})
|
||||
|
||||
// =============================================================================
|
||||
// Models API
|
||||
// =============================================================================
|
||||
|
||||
describe('Models API', () => {
|
||||
beforeEach(setup)
|
||||
afterEach(teardown)
|
||||
|
||||
it('GET /api/models should return available models', async () => {
|
||||
const { req, url, segments } = makeRequest('GET', '/api/models')
|
||||
const res = await handleModelsApi(req, url, segments)
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
const body = await res.json()
|
||||
expect(body.models).toBeArray()
|
||||
expect(body.models.length).toBe(4)
|
||||
expect(body.models[0].id).toContain('claude')
|
||||
})
|
||||
|
||||
it('GET /api/models/current should return default model when not set', async () => {
|
||||
const { req, url, segments } = makeRequest('GET', '/api/models/current')
|
||||
const res = await handleModelsApi(req, url, segments)
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
const body = await res.json()
|
||||
expect(body.model.id).toBe('claude-sonnet-4-6-20250514')
|
||||
})
|
||||
|
||||
it('PUT /api/models/current should switch model', async () => {
|
||||
const { req, url, segments } = makeRequest('PUT', '/api/models/current', {
|
||||
modelId: 'claude-opus-4-6-20250610',
|
||||
})
|
||||
const res = await handleModelsApi(req, url, segments)
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
const body = await res.json()
|
||||
expect(body.ok).toBe(true)
|
||||
expect(body.model).toBe('claude-opus-4-6-20250610')
|
||||
|
||||
// Verify persisted
|
||||
const { req: r2, url: u2, segments: s2 } = makeRequest('GET', '/api/models/current')
|
||||
const res2 = await handleModelsApi(r2, u2, s2)
|
||||
const body2 = await res2.json()
|
||||
expect(body2.model.id).toBe('claude-opus-4-6-20250610')
|
||||
})
|
||||
|
||||
it('PUT /api/models/current should reject missing modelId', async () => {
|
||||
const { req, url, segments } = makeRequest('PUT', '/api/models/current', {})
|
||||
const res = await handleModelsApi(req, url, segments)
|
||||
expect(res.status).toBe(400)
|
||||
})
|
||||
|
||||
it('GET /api/effort should return default effort level', async () => {
|
||||
const { req, url, segments } = makeRequest('GET', '/api/effort')
|
||||
const res = await handleModelsApi(req, url, segments)
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
const body = await res.json()
|
||||
expect(body.level).toBe('medium')
|
||||
expect(body.available).toEqual(['low', 'medium', 'high', 'max'])
|
||||
})
|
||||
|
||||
it('PUT /api/effort should set effort level', async () => {
|
||||
const { req, url, segments } = makeRequest('PUT', '/api/effort', { level: 'high' })
|
||||
const res = await handleModelsApi(req, url, segments)
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
const body = await res.json()
|
||||
expect(body.ok).toBe(true)
|
||||
expect(body.level).toBe('high')
|
||||
})
|
||||
|
||||
it('PUT /api/effort should reject invalid level', async () => {
|
||||
const { req, url, segments } = makeRequest('PUT', '/api/effort', { level: 'turbo' })
|
||||
const res = await handleModelsApi(req, url, segments)
|
||||
expect(res.status).toBe(400)
|
||||
})
|
||||
|
||||
it('should return 404 for unknown models endpoint', async () => {
|
||||
const { req, url, segments } = makeRequest('GET', '/api/models/unknown')
|
||||
const res = await handleModelsApi(req, url, segments)
|
||||
expect(res.status).toBe(404)
|
||||
})
|
||||
})
|
||||
|
||||
// =============================================================================
|
||||
// Status API
|
||||
// =============================================================================
|
||||
|
||||
describe('Status API', () => {
|
||||
beforeEach(async () => {
|
||||
await setup()
|
||||
resetUsage()
|
||||
})
|
||||
afterEach(teardown)
|
||||
|
||||
it('GET /api/status should return health check', async () => {
|
||||
const { req, url, segments } = makeRequest('GET', '/api/status')
|
||||
const res = await handleStatusApi(req, url, segments)
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
const body = await res.json()
|
||||
expect(body.status).toBe('ok')
|
||||
expect(body.version).toBeDefined()
|
||||
expect(body.uptime).toBeGreaterThanOrEqual(0)
|
||||
})
|
||||
|
||||
it('GET /api/status/diagnostics should return system info', async () => {
|
||||
const { req, url, segments } = makeRequest('GET', '/api/status/diagnostics')
|
||||
const res = await handleStatusApi(req, url, segments)
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
const body = await res.json()
|
||||
expect(body.platform).toBeDefined()
|
||||
expect(body.arch).toBeDefined()
|
||||
expect(body.configDir).toBeDefined()
|
||||
})
|
||||
|
||||
it('GET /api/status/usage should return token usage', async () => {
|
||||
addUsage(100, 50, 0.005)
|
||||
addUsage(200, 100, 0.01)
|
||||
|
||||
const { req, url, segments } = makeRequest('GET', '/api/status/usage')
|
||||
const res = await handleStatusApi(req, url, segments)
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
const body = await res.json()
|
||||
expect(body.totalInputTokens).toBe(300)
|
||||
expect(body.totalOutputTokens).toBe(150)
|
||||
expect(body.totalCost).toBeCloseTo(0.015)
|
||||
})
|
||||
|
||||
it('GET /api/status/user should return user info', async () => {
|
||||
const { req, url, segments } = makeRequest('GET', '/api/status/user')
|
||||
const res = await handleStatusApi(req, url, segments)
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
const body = await res.json()
|
||||
expect(body.configDir).toBe(tmpDir)
|
||||
expect(body.projects).toBeArray()
|
||||
})
|
||||
|
||||
it('should reject non-GET methods', async () => {
|
||||
const { req, url, segments } = makeRequest('POST', '/api/status')
|
||||
const res = await handleStatusApi(req, url, segments)
|
||||
expect(res.status).toBe(405)
|
||||
})
|
||||
|
||||
it('should return 404 for unknown status endpoint', async () => {
|
||||
const { req, url, segments } = makeRequest('GET', '/api/status/nonexistent')
|
||||
const res = await handleStatusApi(req, url, segments)
|
||||
expect(res.status).toBe(404)
|
||||
})
|
||||
})
|
||||
139
src/server/api/agents.ts
Normal file
139
src/server/api/agents.ts
Normal file
@ -0,0 +1,139 @@
|
||||
/**
|
||||
* Agents REST API
|
||||
*
|
||||
* GET /api/agents — 获取 Agent 列表
|
||||
* GET /api/agents/:name — 获取 Agent 详情
|
||||
* POST /api/agents — 创建 Agent
|
||||
* PUT /api/agents/:name — 更新 Agent
|
||||
* DELETE /api/agents/:name — 删除 Agent
|
||||
*
|
||||
* GET /api/tasks — 获取后台任务列表 (placeholder)
|
||||
* GET /api/tasks/:id — 获取任务详情 (placeholder)
|
||||
*/
|
||||
|
||||
import { AgentService } from '../services/agentService.js'
|
||||
import { ApiError, errorResponse } from '../middleware/errorHandler.js'
|
||||
|
||||
const agentService = new AgentService()
|
||||
|
||||
export async function handleAgentsApi(
|
||||
req: Request,
|
||||
_url: URL,
|
||||
segments: string[],
|
||||
): Promise<Response> {
|
||||
try {
|
||||
const resource = segments[1] // 'agents' | 'tasks'
|
||||
|
||||
if (resource === 'tasks') {
|
||||
return await handleTasksPlaceholder(req, segments)
|
||||
}
|
||||
|
||||
return await handleAgents(req, segments)
|
||||
} catch (error) {
|
||||
return errorResponse(error)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Agent CRUD ─────────────────────────────────────────────────────────────
|
||||
|
||||
async function handleAgents(
|
||||
req: Request,
|
||||
segments: string[],
|
||||
): Promise<Response> {
|
||||
const method = req.method
|
||||
const agentName = segments[2] ? decodeURIComponent(segments[2]) : undefined
|
||||
|
||||
// ── GET /api/agents ──────────────────────────────────────────────────
|
||||
if (method === 'GET' && !agentName) {
|
||||
const agents = await agentService.listAgents()
|
||||
return Response.json({ agents })
|
||||
}
|
||||
|
||||
// ── GET /api/agents/:name ────────────────────────────────────────────
|
||||
if (method === 'GET' && agentName) {
|
||||
const agent = await agentService.getAgent(agentName)
|
||||
if (!agent) {
|
||||
throw ApiError.notFound(`Agent not found: ${agentName}`)
|
||||
}
|
||||
return Response.json({ agent })
|
||||
}
|
||||
|
||||
// ── POST /api/agents ─────────────────────────────────────────────────
|
||||
if (method === 'POST' && !agentName) {
|
||||
const body = await parseJsonBody(req)
|
||||
if (!body.name || typeof body.name !== 'string') {
|
||||
throw ApiError.badRequest('Missing or invalid "name" in request body')
|
||||
}
|
||||
await agentService.createAgent({
|
||||
name: body.name as string,
|
||||
description: body.description as string | undefined,
|
||||
model: body.model as string | undefined,
|
||||
tools: body.tools as string[] | undefined,
|
||||
systemPrompt: body.systemPrompt as string | undefined,
|
||||
color: body.color as string | undefined,
|
||||
})
|
||||
return Response.json({ ok: true }, { status: 201 })
|
||||
}
|
||||
|
||||
// ── PUT /api/agents/:name ────────────────────────────────────────────
|
||||
if (method === 'PUT' && agentName) {
|
||||
const body = await parseJsonBody(req)
|
||||
await agentService.updateAgent(agentName, body as Record<string, unknown>)
|
||||
const updated = await agentService.getAgent(agentName)
|
||||
return Response.json({ agent: updated })
|
||||
}
|
||||
|
||||
// ── DELETE /api/agents/:name ─────────────────────────────────────────
|
||||
if (method === 'DELETE' && agentName) {
|
||||
await agentService.deleteAgent(agentName)
|
||||
return Response.json({ ok: true })
|
||||
}
|
||||
|
||||
throw new ApiError(
|
||||
405,
|
||||
`Method ${method} not allowed on /api/agents${agentName ? `/${agentName}` : ''}`,
|
||||
'METHOD_NOT_ALLOWED',
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Tasks placeholder ──────────────────────────────────────────────────────
|
||||
|
||||
async function handleTasksPlaceholder(
|
||||
req: Request,
|
||||
segments: string[],
|
||||
): Promise<Response> {
|
||||
const method = req.method
|
||||
const taskId = segments[2]
|
||||
|
||||
if (method !== 'GET') {
|
||||
throw new ApiError(
|
||||
405,
|
||||
`Method ${method} not allowed on /api/tasks`,
|
||||
'METHOD_NOT_ALLOWED',
|
||||
)
|
||||
}
|
||||
|
||||
// GET /api/tasks/:id
|
||||
if (taskId) {
|
||||
return Response.json({
|
||||
task: {
|
||||
id: taskId,
|
||||
status: 'pending',
|
||||
message: 'Background tasks are not yet implemented',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// GET /api/tasks
|
||||
return Response.json({ tasks: [] })
|
||||
}
|
||||
|
||||
// ─── Helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
async function parseJsonBody(req: Request): Promise<Record<string, unknown>> {
|
||||
try {
|
||||
return (await req.json()) as Record<string, unknown>
|
||||
} catch {
|
||||
throw ApiError.badRequest('Invalid JSON body')
|
||||
}
|
||||
}
|
||||
147
src/server/api/conversations.ts
Normal file
147
src/server/api/conversations.ts
Normal file
@ -0,0 +1,147 @@
|
||||
/**
|
||||
* Conversation API Routes
|
||||
*
|
||||
* 提供对话交互的 REST 端点。实际的流式对话通过 WebSocket 处理,
|
||||
* 此处的 REST API 用于非流式操作与状态查询。
|
||||
*
|
||||
* Routes:
|
||||
* POST /api/sessions/:id/chat — 发送消息(入队)
|
||||
* GET /api/sessions/:id/chat/status — 查询对话状态
|
||||
* POST /api/sessions/:id/chat/stop — 停止生成
|
||||
*/
|
||||
|
||||
import { ApiError, errorResponse } from '../middleware/errorHandler.js'
|
||||
import { sessionService } from '../services/sessionService.js'
|
||||
|
||||
// In-memory conversation state per session
|
||||
const sessionStates = new Map<string, 'idle' | 'thinking' | 'tool_executing'>()
|
||||
|
||||
export async function handleConversationsApi(
|
||||
req: Request,
|
||||
url: URL,
|
||||
segments: string[]
|
||||
): Promise<Response> {
|
||||
try {
|
||||
// segments: ['api', 'sessions', ':id', 'chat', ...rest]
|
||||
// or: ['api', 'conversations', ...]
|
||||
//
|
||||
// When routed through the sessions handler:
|
||||
// segments = ['api', 'sessions', sessionId, 'chat', subAction?]
|
||||
// When routed directly via /api/conversations:
|
||||
// segments = ['api', 'conversations', sessionId, subAction?]
|
||||
|
||||
let sessionId: string | undefined
|
||||
let subAction: string | undefined
|
||||
|
||||
if (segments[1] === 'sessions') {
|
||||
// /api/sessions/:id/chat[/status|/stop]
|
||||
sessionId = segments[2]
|
||||
// segments[3] === 'chat'
|
||||
subAction = segments[4]
|
||||
} else {
|
||||
// /api/conversations/:id[/status|/stop]
|
||||
sessionId = segments[2]
|
||||
subAction = segments[3]
|
||||
}
|
||||
|
||||
if (!sessionId) {
|
||||
throw ApiError.badRequest('Session ID is required')
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// GET /chat/status
|
||||
// -----------------------------------------------------------------------
|
||||
if (subAction === 'status' && req.method === 'GET') {
|
||||
return getChatStatus(sessionId)
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// POST /chat/stop
|
||||
// -----------------------------------------------------------------------
|
||||
if (subAction === 'stop' && req.method === 'POST') {
|
||||
return stopChat(sessionId)
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// POST /chat (send message)
|
||||
// -----------------------------------------------------------------------
|
||||
if (!subAction) {
|
||||
if (req.method !== 'POST') {
|
||||
return Response.json(
|
||||
{ error: 'METHOD_NOT_ALLOWED', message: `Method ${req.method} not allowed` },
|
||||
{ status: 405 }
|
||||
)
|
||||
}
|
||||
return await sendMessage(req, sessionId)
|
||||
}
|
||||
|
||||
return Response.json(
|
||||
{ error: 'NOT_FOUND', message: `Unknown chat sub-resource: ${subAction}` },
|
||||
{ status: 404 }
|
||||
)
|
||||
} catch (error) {
|
||||
return errorResponse(error)
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Handler implementations
|
||||
// ============================================================================
|
||||
|
||||
async function sendMessage(req: Request, sessionId: string): Promise<Response> {
|
||||
// Validate session exists
|
||||
const session = await sessionService.getSession(sessionId)
|
||||
if (!session) {
|
||||
throw ApiError.notFound(`Session not found: ${sessionId}`)
|
||||
}
|
||||
|
||||
let body: { content?: string }
|
||||
try {
|
||||
body = (await req.json()) as { content?: string }
|
||||
} catch {
|
||||
throw ApiError.badRequest('Invalid JSON body')
|
||||
}
|
||||
|
||||
if (!body.content || typeof body.content !== 'string') {
|
||||
throw ApiError.badRequest('content (string) is required in request body')
|
||||
}
|
||||
|
||||
const messageId = crypto.randomUUID()
|
||||
|
||||
// Mark session as thinking — actual processing happens through WebSocket
|
||||
sessionStates.set(sessionId, 'thinking')
|
||||
|
||||
return Response.json(
|
||||
{ messageId, status: 'queued' as const },
|
||||
{ status: 202 }
|
||||
)
|
||||
}
|
||||
|
||||
function getChatStatus(sessionId: string): Response {
|
||||
const state = sessionStates.get(sessionId) || 'idle'
|
||||
return Response.json({ state })
|
||||
}
|
||||
|
||||
function stopChat(sessionId: string): Response {
|
||||
// Reset to idle — in a full implementation this would signal the
|
||||
// WebSocket handler / subprocess to abort the current generation.
|
||||
sessionStates.set(sessionId, 'idle')
|
||||
return Response.json({ ok: true })
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Helpers for WebSocket integration (exported for use by ws/handler)
|
||||
// ============================================================================
|
||||
|
||||
export function setSessionChatState(
|
||||
sessionId: string,
|
||||
state: 'idle' | 'thinking' | 'tool_executing'
|
||||
): void {
|
||||
sessionStates.set(sessionId, state)
|
||||
}
|
||||
|
||||
export function getSessionChatState(
|
||||
sessionId: string
|
||||
): 'idle' | 'thinking' | 'tool_executing' {
|
||||
return sessionStates.get(sessionId) || 'idle'
|
||||
}
|
||||
149
src/server/api/models.ts
Normal file
149
src/server/api/models.ts
Normal file
@ -0,0 +1,149 @@
|
||||
/**
|
||||
* Models REST API
|
||||
*
|
||||
* GET /api/models — 获取可用模型列表
|
||||
* GET /api/models/current — 获取当前选中的模型
|
||||
* PUT /api/models/current — 切换模型
|
||||
* GET /api/effort — 获取 Effort 等级
|
||||
* PUT /api/effort — 设置 Effort 等级
|
||||
*/
|
||||
|
||||
import { SettingsService } from '../services/settingsService.js'
|
||||
import { ApiError, errorResponse } from '../middleware/errorHandler.js'
|
||||
|
||||
// ─── Static data ──────────────────────────────────────────────────────────────
|
||||
|
||||
const AVAILABLE_MODELS = [
|
||||
{
|
||||
id: 'claude-opus-4-6-20250610',
|
||||
name: 'Opus 4.6',
|
||||
description: 'Most capable for ambitious work',
|
||||
context: '200k',
|
||||
},
|
||||
{
|
||||
id: 'claude-opus-4-6-20250610',
|
||||
name: 'Opus 4.6 1M',
|
||||
description: 'Most capable for ambitious work',
|
||||
context: '1m',
|
||||
},
|
||||
{
|
||||
id: 'claude-sonnet-4-6-20250514',
|
||||
name: 'Sonnet 4.6',
|
||||
description: 'Most efficient for everyday tasks',
|
||||
context: '200k',
|
||||
},
|
||||
{
|
||||
id: 'claude-haiku-4-5-20251001',
|
||||
name: 'Haiku 4.5',
|
||||
description: 'Fastest for quick answers',
|
||||
context: '200k',
|
||||
},
|
||||
] as const
|
||||
|
||||
const EFFORT_LEVELS = ['low', 'medium', 'high', 'max'] as const
|
||||
|
||||
const DEFAULT_MODEL = 'claude-sonnet-4-6-20250514'
|
||||
const DEFAULT_EFFORT = 'medium'
|
||||
|
||||
const settingsService = new SettingsService()
|
||||
|
||||
// ─── Router ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function handleModelsApi(
|
||||
req: Request,
|
||||
url: URL,
|
||||
segments: string[],
|
||||
): Promise<Response> {
|
||||
try {
|
||||
const resource = segments[1] // 'models' | 'effort'
|
||||
const sub = segments[2] // 'current' | undefined
|
||||
|
||||
// ── /api/effort ───────────────────────────────────────────────────
|
||||
if (resource === 'effort') {
|
||||
return await handleEffort(req)
|
||||
}
|
||||
|
||||
// ── /api/models/* ─────────────────────────────────────────────────
|
||||
switch (sub) {
|
||||
case undefined:
|
||||
// GET /api/models
|
||||
if (req.method !== 'GET') throw methodNotAllowed(req.method)
|
||||
return Response.json({ models: AVAILABLE_MODELS })
|
||||
|
||||
case 'current':
|
||||
return await handleCurrentModel(req)
|
||||
|
||||
default:
|
||||
throw ApiError.notFound(`Unknown models endpoint: ${sub}`)
|
||||
}
|
||||
} catch (error) {
|
||||
return errorResponse(error)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Handlers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
async function handleCurrentModel(req: Request): Promise<Response> {
|
||||
if (req.method === 'GET') {
|
||||
const settings = await settingsService.getUserSettings()
|
||||
const modelId = (settings.model as string) || DEFAULT_MODEL
|
||||
const model = AVAILABLE_MODELS.find((m) => m.id === modelId) || {
|
||||
id: modelId,
|
||||
name: modelId,
|
||||
description: 'Custom model',
|
||||
context: 'unknown',
|
||||
}
|
||||
return Response.json({ model })
|
||||
}
|
||||
|
||||
if (req.method === 'PUT') {
|
||||
const body = await parseJsonBody(req)
|
||||
const modelId = body.modelId
|
||||
if (typeof modelId !== 'string' || !modelId) {
|
||||
throw ApiError.badRequest('Missing or invalid "modelId" in request body')
|
||||
}
|
||||
await settingsService.updateUserSettings({ model: modelId })
|
||||
return Response.json({ ok: true, model: modelId })
|
||||
}
|
||||
|
||||
throw methodNotAllowed(req.method)
|
||||
}
|
||||
|
||||
async function handleEffort(req: Request): Promise<Response> {
|
||||
if (req.method === 'GET') {
|
||||
const settings = await settingsService.getUserSettings()
|
||||
const level = (settings.effort as string) || DEFAULT_EFFORT
|
||||
return Response.json({ level, available: EFFORT_LEVELS })
|
||||
}
|
||||
|
||||
if (req.method === 'PUT') {
|
||||
const body = await parseJsonBody(req)
|
||||
const level = body.level
|
||||
if (typeof level !== 'string') {
|
||||
throw ApiError.badRequest('Missing or invalid "level" in request body')
|
||||
}
|
||||
if (!EFFORT_LEVELS.includes(level as (typeof EFFORT_LEVELS)[number])) {
|
||||
throw ApiError.badRequest(
|
||||
`Invalid effort level: "${level}". Valid levels: ${EFFORT_LEVELS.join(', ')}`,
|
||||
)
|
||||
}
|
||||
await settingsService.updateUserSettings({ effort: level })
|
||||
return Response.json({ ok: true, level })
|
||||
}
|
||||
|
||||
throw methodNotAllowed(req.method)
|
||||
}
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
async function parseJsonBody(req: Request): Promise<Record<string, unknown>> {
|
||||
try {
|
||||
return (await req.json()) as Record<string, unknown>
|
||||
} catch {
|
||||
throw ApiError.badRequest('Invalid JSON body')
|
||||
}
|
||||
}
|
||||
|
||||
function methodNotAllowed(method: string): ApiError {
|
||||
return new ApiError(405, `Method ${method} not allowed`, 'METHOD_NOT_ALLOWED')
|
||||
}
|
||||
79
src/server/api/scheduled-tasks.ts
Normal file
79
src/server/api/scheduled-tasks.ts
Normal file
@ -0,0 +1,79 @@
|
||||
/**
|
||||
* Scheduled Tasks REST API
|
||||
*
|
||||
* GET /api/scheduled-tasks — 获取任务列表
|
||||
* POST /api/scheduled-tasks — 创建任务
|
||||
* PUT /api/scheduled-tasks/:id — 更新任务
|
||||
* DELETE /api/scheduled-tasks/:id — 删除任务
|
||||
*/
|
||||
|
||||
import { CronService } from '../services/cronService.js'
|
||||
import { ApiError, errorResponse } from '../middleware/errorHandler.js'
|
||||
|
||||
const cronService = new CronService()
|
||||
|
||||
export async function handleScheduledTasksApi(
|
||||
req: Request,
|
||||
_url: URL,
|
||||
segments: string[],
|
||||
): Promise<Response> {
|
||||
try {
|
||||
const method = req.method
|
||||
const taskId = segments[2] // /api/scheduled-tasks/:id
|
||||
|
||||
// ── GET /api/scheduled-tasks ──────────────────────────────────────────
|
||||
if (method === 'GET' && !taskId) {
|
||||
const tasks = await cronService.listTasks()
|
||||
return Response.json({ tasks })
|
||||
}
|
||||
|
||||
// ── POST /api/scheduled-tasks ─────────────────────────────────────────
|
||||
if (method === 'POST' && !taskId) {
|
||||
const body = await parseJsonBody(req)
|
||||
const task = await cronService.createTask({
|
||||
name: body.name as string | undefined,
|
||||
description: body.description as string | undefined,
|
||||
cron: body.cron as string,
|
||||
prompt: body.prompt as string,
|
||||
recurring: body.recurring as boolean | undefined,
|
||||
permanent: body.permanent as boolean | undefined,
|
||||
permissionMode: body.permissionMode as string | undefined,
|
||||
model: body.model as string | undefined,
|
||||
folderPath: body.folderPath as string | undefined,
|
||||
useWorktree: body.useWorktree as boolean | undefined,
|
||||
})
|
||||
return Response.json({ task }, { status: 201 })
|
||||
}
|
||||
|
||||
// ── PUT /api/scheduled-tasks/:id ──────────────────────────────────────
|
||||
if (method === 'PUT' && taskId) {
|
||||
const body = await parseJsonBody(req)
|
||||
const task = await cronService.updateTask(taskId, body)
|
||||
return Response.json({ task })
|
||||
}
|
||||
|
||||
// ── DELETE /api/scheduled-tasks/:id ───────────────────────────────────
|
||||
if (method === 'DELETE' && taskId) {
|
||||
await cronService.deleteTask(taskId)
|
||||
return Response.json({ ok: true })
|
||||
}
|
||||
|
||||
throw new ApiError(
|
||||
405,
|
||||
`Method ${method} not allowed on /api/scheduled-tasks${taskId ? `/${taskId}` : ''}`,
|
||||
'METHOD_NOT_ALLOWED',
|
||||
)
|
||||
} catch (error) {
|
||||
return errorResponse(error)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
async function parseJsonBody(req: Request): Promise<Record<string, unknown>> {
|
||||
try {
|
||||
return (await req.json()) as Record<string, unknown>
|
||||
} catch {
|
||||
throw ApiError.badRequest('Invalid JSON body')
|
||||
}
|
||||
}
|
||||
67
src/server/api/search.ts
Normal file
67
src/server/api/search.ts
Normal file
@ -0,0 +1,67 @@
|
||||
/**
|
||||
* Search REST API
|
||||
*
|
||||
* POST /api/search — 全局工作区搜索
|
||||
* POST /api/search/sessions — 搜索会话历史
|
||||
*/
|
||||
|
||||
import { SearchService } from '../services/searchService.js'
|
||||
import { ApiError, errorResponse } from '../middleware/errorHandler.js'
|
||||
|
||||
const searchService = new SearchService()
|
||||
|
||||
export async function handleSearchApi(
|
||||
req: Request,
|
||||
_url: URL,
|
||||
segments: string[],
|
||||
): Promise<Response> {
|
||||
try {
|
||||
const method = req.method
|
||||
const sub = segments[2] // 'sessions' | undefined
|
||||
|
||||
if (method !== 'POST') {
|
||||
throw new ApiError(
|
||||
405,
|
||||
`Method ${method} not allowed. Use POST.`,
|
||||
'METHOD_NOT_ALLOWED',
|
||||
)
|
||||
}
|
||||
|
||||
const body = await parseJsonBody(req)
|
||||
const query = body.query as string
|
||||
if (!query || typeof query !== 'string') {
|
||||
throw ApiError.badRequest('Missing or invalid "query" in request body')
|
||||
}
|
||||
|
||||
// ── POST /api/search/sessions ──────────────────────────────────────────
|
||||
if (sub === 'sessions') {
|
||||
const results = await searchService.searchSessions(query)
|
||||
return Response.json({ results })
|
||||
}
|
||||
|
||||
// ── POST /api/search ───────────────────────────────────────────────────
|
||||
if (!sub) {
|
||||
const results = await searchService.searchWorkspace(query, {
|
||||
cwd: body.cwd as string | undefined,
|
||||
maxResults: body.maxResults as number | undefined,
|
||||
glob: body.glob as string | undefined,
|
||||
caseSensitive: body.caseSensitive as boolean | undefined,
|
||||
})
|
||||
return Response.json({ results, total: results.length })
|
||||
}
|
||||
|
||||
throw ApiError.notFound(`Unknown search endpoint: ${sub}`)
|
||||
} catch (error) {
|
||||
return errorResponse(error)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
async function parseJsonBody(req: Request): Promise<Record<string, unknown>> {
|
||||
try {
|
||||
return (await req.json()) as Record<string, unknown>
|
||||
} catch {
|
||||
throw ApiError.badRequest('Invalid JSON body')
|
||||
}
|
||||
}
|
||||
158
src/server/api/sessions.ts
Normal file
158
src/server/api/sessions.ts
Normal file
@ -0,0 +1,158 @@
|
||||
/**
|
||||
* Session REST API Routes
|
||||
*
|
||||
* 提供会话的 CRUD 操作接口,数据来自 CLI 共享的 JSONL 文件。
|
||||
*
|
||||
* Routes:
|
||||
* GET /api/sessions — 列出会话
|
||||
* GET /api/sessions/:id — 获取会话详情
|
||||
* GET /api/sessions/:id/messages — 获取会话消息
|
||||
* POST /api/sessions — 创建新会话
|
||||
* DELETE /api/sessions/:id — 删除会话
|
||||
* PATCH /api/sessions/:id — 重命名会话
|
||||
*/
|
||||
|
||||
import { sessionService } from '../services/sessionService.js'
|
||||
import { ApiError, errorResponse } from '../middleware/errorHandler.js'
|
||||
|
||||
export async function handleSessionsApi(
|
||||
req: Request,
|
||||
url: URL,
|
||||
segments: string[]
|
||||
): Promise<Response> {
|
||||
try {
|
||||
// segments: ['api', 'sessions', ...rest]
|
||||
const sessionId = segments[2] // may be undefined
|
||||
const subResource = segments[3] // e.g. 'messages'
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Collection routes: /api/sessions
|
||||
// -----------------------------------------------------------------------
|
||||
if (!sessionId) {
|
||||
switch (req.method) {
|
||||
case 'GET':
|
||||
return await listSessions(url)
|
||||
case 'POST':
|
||||
return await createSession(req)
|
||||
default:
|
||||
return Response.json(
|
||||
{ error: 'METHOD_NOT_ALLOWED', message: `Method ${req.method} not allowed` },
|
||||
{ status: 405 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Sub-resource routes: /api/sessions/:id/messages
|
||||
// -----------------------------------------------------------------------
|
||||
if (subResource === 'messages') {
|
||||
if (req.method !== 'GET') {
|
||||
return Response.json(
|
||||
{ error: 'METHOD_NOT_ALLOWED', message: `Method ${req.method} not allowed` },
|
||||
{ status: 405 }
|
||||
)
|
||||
}
|
||||
return await getSessionMessages(sessionId)
|
||||
}
|
||||
|
||||
// Route to conversations handler if sub-resource is 'chat'
|
||||
if (subResource === 'chat') {
|
||||
// This is handled by the conversations API, but in case the router
|
||||
// forwards it here, we delegate to the conversations module.
|
||||
// Normally the router should route /api/sessions/:id/chat/* to conversations.
|
||||
return Response.json(
|
||||
{ error: 'NOT_FOUND', message: 'Use /api/sessions/:id/chat via conversations API' },
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Item routes: /api/sessions/:id
|
||||
// -----------------------------------------------------------------------
|
||||
switch (req.method) {
|
||||
case 'GET':
|
||||
return await getSession(sessionId)
|
||||
case 'DELETE':
|
||||
return await deleteSession(sessionId)
|
||||
case 'PATCH':
|
||||
return await patchSession(req, sessionId)
|
||||
default:
|
||||
return Response.json(
|
||||
{ error: 'METHOD_NOT_ALLOWED', message: `Method ${req.method} not allowed` },
|
||||
{ status: 405 }
|
||||
)
|
||||
}
|
||||
} catch (error) {
|
||||
return errorResponse(error)
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Handler implementations
|
||||
// ============================================================================
|
||||
|
||||
async function listSessions(url: URL): Promise<Response> {
|
||||
const project = url.searchParams.get('project') || undefined
|
||||
const limit = parseInt(url.searchParams.get('limit') || '20', 10)
|
||||
const offset = parseInt(url.searchParams.get('offset') || '0', 10)
|
||||
|
||||
if (isNaN(limit) || limit < 0) {
|
||||
throw ApiError.badRequest('Invalid limit parameter')
|
||||
}
|
||||
if (isNaN(offset) || offset < 0) {
|
||||
throw ApiError.badRequest('Invalid offset parameter')
|
||||
}
|
||||
|
||||
const result = await sessionService.listSessions({ project, limit, offset })
|
||||
return Response.json(result)
|
||||
}
|
||||
|
||||
async function getSession(sessionId: string): Promise<Response> {
|
||||
const detail = await sessionService.getSession(sessionId)
|
||||
if (!detail) {
|
||||
throw ApiError.notFound(`Session not found: ${sessionId}`)
|
||||
}
|
||||
return Response.json(detail)
|
||||
}
|
||||
|
||||
async function getSessionMessages(sessionId: string): Promise<Response> {
|
||||
const messages = await sessionService.getSessionMessages(sessionId)
|
||||
return Response.json({ messages })
|
||||
}
|
||||
|
||||
async function createSession(req: Request): Promise<Response> {
|
||||
let body: { workDir?: string }
|
||||
try {
|
||||
body = (await req.json()) as { workDir?: string }
|
||||
} catch {
|
||||
throw ApiError.badRequest('Invalid JSON body')
|
||||
}
|
||||
|
||||
if (!body.workDir || typeof body.workDir !== 'string') {
|
||||
throw ApiError.badRequest('workDir (string) is required in request body')
|
||||
}
|
||||
|
||||
const result = await sessionService.createSession(body.workDir)
|
||||
return Response.json(result, { status: 201 })
|
||||
}
|
||||
|
||||
async function deleteSession(sessionId: string): Promise<Response> {
|
||||
await sessionService.deleteSession(sessionId)
|
||||
return Response.json({ ok: true })
|
||||
}
|
||||
|
||||
async function patchSession(req: Request, sessionId: string): Promise<Response> {
|
||||
let body: { title?: string }
|
||||
try {
|
||||
body = (await req.json()) as { title?: string }
|
||||
} catch {
|
||||
throw ApiError.badRequest('Invalid JSON body')
|
||||
}
|
||||
|
||||
if (!body.title || typeof body.title !== 'string') {
|
||||
throw ApiError.badRequest('title (string) is required in request body')
|
||||
}
|
||||
|
||||
await sessionService.renameSession(sessionId, body.title)
|
||||
return Response.json({ ok: true })
|
||||
}
|
||||
121
src/server/api/settings.ts
Normal file
121
src/server/api/settings.ts
Normal file
@ -0,0 +1,121 @@
|
||||
/**
|
||||
* Settings REST API
|
||||
*
|
||||
* GET /api/settings — 获取合并后的设置
|
||||
* GET /api/settings/user — 获取用户设置
|
||||
* GET /api/settings/project — 获取项目设置
|
||||
* PUT /api/settings/user — 更新用户设置
|
||||
* PUT /api/settings/project — 更新项目设置
|
||||
* GET /api/permissions/mode — 获取权限模式
|
||||
* PUT /api/permissions/mode — 设置权限模式
|
||||
*/
|
||||
|
||||
import { SettingsService } from '../services/settingsService.js'
|
||||
import { ApiError, errorResponse } from '../middleware/errorHandler.js'
|
||||
|
||||
const settingsService = new SettingsService()
|
||||
|
||||
export async function handleSettingsApi(
|
||||
req: Request,
|
||||
url: URL,
|
||||
segments: string[],
|
||||
): Promise<Response> {
|
||||
try {
|
||||
const resource = segments[1] // 'settings' | 'permissions'
|
||||
const sub = segments[2] // 'user' | 'project' | 'mode' | undefined
|
||||
|
||||
// ── /api/permissions/* ──────────────────────────────────────────────
|
||||
if (resource === 'permissions') {
|
||||
if (sub === 'mode') {
|
||||
return await handlePermissionMode(req)
|
||||
}
|
||||
throw ApiError.notFound(`Unknown permissions endpoint: ${sub}`)
|
||||
}
|
||||
|
||||
// ── /api/settings/* ─────────────────────────────────────────────────
|
||||
const method = req.method
|
||||
|
||||
switch (sub) {
|
||||
case undefined:
|
||||
// GET /api/settings
|
||||
if (method !== 'GET') throw methodNotAllowed(method)
|
||||
return Response.json(await settingsService.getSettings())
|
||||
|
||||
case 'user':
|
||||
return await handleUserSettings(req)
|
||||
|
||||
case 'project':
|
||||
return await handleProjectSettings(req, url)
|
||||
|
||||
default:
|
||||
throw ApiError.notFound(`Unknown settings endpoint: ${sub}`)
|
||||
}
|
||||
} catch (error) {
|
||||
return errorResponse(error)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Handlers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
async function handleUserSettings(req: Request): Promise<Response> {
|
||||
if (req.method === 'GET') {
|
||||
return Response.json(await settingsService.getUserSettings())
|
||||
}
|
||||
|
||||
if (req.method === 'PUT') {
|
||||
const body = await parseJsonBody(req)
|
||||
await settingsService.updateUserSettings(body)
|
||||
return Response.json({ ok: true })
|
||||
}
|
||||
|
||||
throw methodNotAllowed(req.method)
|
||||
}
|
||||
|
||||
async function handleProjectSettings(req: Request, url: URL): Promise<Response> {
|
||||
const projectRoot = url.searchParams.get('projectRoot') || undefined
|
||||
|
||||
if (req.method === 'GET') {
|
||||
return Response.json(await settingsService.getProjectSettings(projectRoot))
|
||||
}
|
||||
|
||||
if (req.method === 'PUT') {
|
||||
const body = await parseJsonBody(req)
|
||||
await settingsService.updateProjectSettings(body, projectRoot)
|
||||
return Response.json({ ok: true })
|
||||
}
|
||||
|
||||
throw methodNotAllowed(req.method)
|
||||
}
|
||||
|
||||
async function handlePermissionMode(req: Request): Promise<Response> {
|
||||
if (req.method === 'GET') {
|
||||
const mode = await settingsService.getPermissionMode()
|
||||
return Response.json({ mode })
|
||||
}
|
||||
|
||||
if (req.method === 'PUT') {
|
||||
const body = await parseJsonBody(req)
|
||||
const mode = body.mode
|
||||
if (typeof mode !== 'string') {
|
||||
throw ApiError.badRequest('Missing or invalid "mode" in request body')
|
||||
}
|
||||
await settingsService.setPermissionMode(mode)
|
||||
return Response.json({ ok: true, mode })
|
||||
}
|
||||
|
||||
throw methodNotAllowed(req.method)
|
||||
}
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
async function parseJsonBody(req: Request): Promise<Record<string, unknown>> {
|
||||
try {
|
||||
return (await req.json()) as Record<string, unknown>
|
||||
} catch {
|
||||
throw ApiError.badRequest('Invalid JSON body')
|
||||
}
|
||||
}
|
||||
|
||||
function methodNotAllowed(method: string): ApiError {
|
||||
return new ApiError(405, `Method ${method} not allowed`, 'METHOD_NOT_ALLOWED')
|
||||
}
|
||||
140
src/server/api/status.ts
Normal file
140
src/server/api/status.ts
Normal file
@ -0,0 +1,140 @@
|
||||
/**
|
||||
* Status REST API
|
||||
*
|
||||
* GET /api/status — 健康检查
|
||||
* GET /api/status/diagnostics — 系统诊断信息
|
||||
* GET /api/status/usage — Token 用量(当前会话累计)
|
||||
* GET /api/status/user — 用户信息
|
||||
*/
|
||||
|
||||
import * as os from 'os'
|
||||
import * as path from 'path'
|
||||
import * as fs from 'fs/promises'
|
||||
import { ApiError, errorResponse } from '../middleware/errorHandler.js'
|
||||
|
||||
// 服务器启动时间(用于计算 uptime)
|
||||
const startedAt = Date.now()
|
||||
|
||||
// 会话级别的 token 用量累计(进程生命周期内)
|
||||
const usage = {
|
||||
totalInputTokens: 0,
|
||||
totalOutputTokens: 0,
|
||||
totalCost: 0,
|
||||
}
|
||||
|
||||
/** 供外部累加 token 用量 */
|
||||
export function addUsage(input: number, output: number, cost: number) {
|
||||
usage.totalInputTokens += input
|
||||
usage.totalOutputTokens += output
|
||||
usage.totalCost += cost
|
||||
}
|
||||
|
||||
/** 重置用量(测试用) */
|
||||
export function resetUsage() {
|
||||
usage.totalInputTokens = 0
|
||||
usage.totalOutputTokens = 0
|
||||
usage.totalCost = 0
|
||||
}
|
||||
|
||||
// ─── Router ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function handleStatusApi(
|
||||
req: Request,
|
||||
_url: URL,
|
||||
segments: string[],
|
||||
): Promise<Response> {
|
||||
try {
|
||||
if (req.method !== 'GET') {
|
||||
throw new ApiError(405, `Method ${req.method} not allowed`, 'METHOD_NOT_ALLOWED')
|
||||
}
|
||||
|
||||
const sub = segments[2] // 'diagnostics' | 'usage' | 'user' | undefined
|
||||
|
||||
switch (sub) {
|
||||
case undefined:
|
||||
return handleHealthCheck()
|
||||
|
||||
case 'diagnostics':
|
||||
return handleDiagnostics()
|
||||
|
||||
case 'usage':
|
||||
return handleUsage()
|
||||
|
||||
case 'user':
|
||||
return await handleUser()
|
||||
|
||||
default:
|
||||
throw ApiError.notFound(`Unknown status endpoint: ${sub}`)
|
||||
}
|
||||
} catch (error) {
|
||||
return errorResponse(error)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Handlers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function handleHealthCheck(): Response {
|
||||
return Response.json({
|
||||
status: 'ok',
|
||||
version: getVersion(),
|
||||
uptime: Date.now() - startedAt,
|
||||
})
|
||||
}
|
||||
|
||||
function handleDiagnostics(): Response {
|
||||
return Response.json({
|
||||
nodeVersion: process.version,
|
||||
bunVersion: typeof Bun !== 'undefined' ? Bun.version : 'N/A',
|
||||
platform: process.platform,
|
||||
arch: process.arch,
|
||||
configDir: getConfigDir(),
|
||||
memory: {
|
||||
rss: process.memoryUsage.rss(),
|
||||
heapUsed: process.memoryUsage().heapUsed,
|
||||
heapTotal: process.memoryUsage().heapTotal,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function handleUsage(): Response {
|
||||
return Response.json({
|
||||
totalInputTokens: usage.totalInputTokens,
|
||||
totalOutputTokens: usage.totalOutputTokens,
|
||||
totalCost: usage.totalCost,
|
||||
})
|
||||
}
|
||||
|
||||
async function handleUser(): Promise<Response> {
|
||||
const configDir = getConfigDir()
|
||||
const projects = await discoverProjects(configDir)
|
||||
|
||||
return Response.json({
|
||||
configDir,
|
||||
projects,
|
||||
})
|
||||
}
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function getConfigDir(): string {
|
||||
return process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude')
|
||||
}
|
||||
|
||||
function getVersion(): string {
|
||||
// 从 package.json 的 version 字段读取;回退到环境变量或 unknown
|
||||
return process.env.APP_VERSION || '999.0.0-local'
|
||||
}
|
||||
|
||||
/**
|
||||
* 扫描 configDir 下的 projects 目录,返回已知的项目路径列表。
|
||||
* 如果目录不存在,返回空数组。
|
||||
*/
|
||||
async function discoverProjects(configDir: string): Promise<string[]> {
|
||||
const projectsDir = path.join(configDir, 'projects')
|
||||
try {
|
||||
const entries = await fs.readdir(projectsDir)
|
||||
return entries.filter((e) => !e.startsWith('.'))
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
88
src/server/index.ts
Normal file
88
src/server/index.ts
Normal file
@ -0,0 +1,88 @@
|
||||
/**
|
||||
* Claude Code Desktop App — HTTP + WebSocket Server
|
||||
*
|
||||
* 为桌面端 UI 提供 REST API 和 WebSocket 实时通信。
|
||||
* 读写与 CLI 完全相同的文件系统,确保 CLI/UI 数据互通。
|
||||
*/
|
||||
|
||||
import { handleApiRequest } from './router.js'
|
||||
import { handleWebSocket, type WebSocketData } from './ws/handler.js'
|
||||
import { corsHeaders } from './middleware/cors.js'
|
||||
import { requireAuth } from './middleware/auth.js'
|
||||
|
||||
const PORT = parseInt(process.env.SERVER_PORT || '3456', 10)
|
||||
const HOST = process.env.SERVER_HOST || '127.0.0.1'
|
||||
|
||||
export function startServer(port = PORT, host = HOST) {
|
||||
const server = Bun.serve<WebSocketData>({
|
||||
port,
|
||||
hostname: host,
|
||||
|
||||
async fetch(req, server) {
|
||||
const url = new URL(req.url)
|
||||
|
||||
const origin = req.headers.get('Origin')
|
||||
|
||||
// Handle CORS preflight
|
||||
if (req.method === 'OPTIONS') {
|
||||
return new Response(null, { status: 204, headers: corsHeaders(origin) })
|
||||
}
|
||||
|
||||
// WebSocket upgrade
|
||||
if (url.pathname.startsWith('/ws/')) {
|
||||
// Validate session ID format
|
||||
const sessionId = url.pathname.split('/').pop() || ''
|
||||
if (!sessionId || !/^[0-9a-zA-Z_-]{1,64}$/.test(sessionId)) {
|
||||
return new Response('Invalid session ID', { status: 400 })
|
||||
}
|
||||
const upgraded = server.upgrade(req, {
|
||||
data: {
|
||||
sessionId,
|
||||
connectedAt: Date.now(),
|
||||
},
|
||||
})
|
||||
if (upgraded) return undefined
|
||||
return new Response('WebSocket upgrade failed', { status: 400 })
|
||||
}
|
||||
|
||||
// REST API
|
||||
if (url.pathname.startsWith('/api/')) {
|
||||
try {
|
||||
const response = await handleApiRequest(req, url)
|
||||
// Add CORS headers to all responses
|
||||
const headers = new Headers(response.headers)
|
||||
for (const [key, value] of Object.entries(corsHeaders(origin))) {
|
||||
headers.set(key, value)
|
||||
}
|
||||
return new Response(response.body, {
|
||||
status: response.status,
|
||||
headers,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('[Server] API error:', error)
|
||||
return Response.json(
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500, headers: corsHeaders() }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Health check
|
||||
if (url.pathname === '/health') {
|
||||
return Response.json({ status: 'ok', timestamp: new Date().toISOString() })
|
||||
}
|
||||
|
||||
return new Response('Not Found', { status: 404 })
|
||||
},
|
||||
|
||||
websocket: handleWebSocket,
|
||||
})
|
||||
|
||||
console.log(`[Server] Claude Code API server running at http://${host}:${port}`)
|
||||
return server
|
||||
}
|
||||
|
||||
// Direct execution
|
||||
if (import.meta.main) {
|
||||
startServer()
|
||||
}
|
||||
42
src/server/middleware/auth.ts
Normal file
42
src/server/middleware/auth.ts
Normal file
@ -0,0 +1,42 @@
|
||||
/**
|
||||
* Authentication middleware
|
||||
*
|
||||
* 本地桌面应用场景下,使用 Anthropic API Key 做简单鉴权。
|
||||
* 验证请求头中的 Authorization: Bearer <key> 与 .env 中的 ANTHROPIC_API_KEY 是否匹配。
|
||||
*/
|
||||
|
||||
export function validateAuth(req: Request): { valid: boolean; error?: string } {
|
||||
const authHeader = req.headers.get('Authorization')
|
||||
|
||||
if (!authHeader) {
|
||||
return { valid: false, error: 'Missing Authorization header' }
|
||||
}
|
||||
|
||||
const [scheme, token] = authHeader.split(' ')
|
||||
|
||||
if (scheme !== 'Bearer' || !token) {
|
||||
return { valid: false, error: 'Invalid Authorization format. Use: Bearer <token>' }
|
||||
}
|
||||
|
||||
const apiKey = process.env.ANTHROPIC_API_KEY
|
||||
if (!apiKey) {
|
||||
return { valid: false, error: 'Server ANTHROPIC_API_KEY not configured' }
|
||||
}
|
||||
|
||||
if (token !== apiKey) {
|
||||
return { valid: false, error: 'Invalid API key' }
|
||||
}
|
||||
|
||||
return { valid: true }
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to check auth and return 401 if invalid
|
||||
*/
|
||||
export function requireAuth(req: Request): Response | null {
|
||||
const { valid, error } = validateAuth(req)
|
||||
if (!valid) {
|
||||
return Response.json({ error: 'Unauthorized', message: error }, { status: 401 })
|
||||
}
|
||||
return null
|
||||
}
|
||||
17
src/server/middleware/cors.ts
Normal file
17
src/server/middleware/cors.ts
Normal file
@ -0,0 +1,17 @@
|
||||
/**
|
||||
* CORS middleware for local desktop app communication
|
||||
*/
|
||||
|
||||
export function corsHeaders(origin?: string | null): Record<string, string> {
|
||||
// Only allow localhost origins for security
|
||||
const allowedOrigin =
|
||||
origin && /^https?:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/.test(origin)
|
||||
? origin
|
||||
: 'http://localhost:3000'
|
||||
return {
|
||||
'Access-Control-Allow-Origin': allowedOrigin,
|
||||
'Access-Control-Allow-Methods': 'GET, POST, PUT, PATCH, DELETE, OPTIONS',
|
||||
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
|
||||
'Access-Control-Max-Age': '86400',
|
||||
}
|
||||
}
|
||||
45
src/server/middleware/errorHandler.ts
Normal file
45
src/server/middleware/errorHandler.ts
Normal file
@ -0,0 +1,45 @@
|
||||
/**
|
||||
* Unified error handling utilities
|
||||
*/
|
||||
|
||||
export class ApiError extends Error {
|
||||
constructor(
|
||||
public statusCode: number,
|
||||
message: string,
|
||||
public code?: string
|
||||
) {
|
||||
super(message)
|
||||
this.name = 'ApiError'
|
||||
}
|
||||
|
||||
static badRequest(message: string) {
|
||||
return new ApiError(400, message, 'BAD_REQUEST')
|
||||
}
|
||||
|
||||
static notFound(message: string) {
|
||||
return new ApiError(404, message, 'NOT_FOUND')
|
||||
}
|
||||
|
||||
static conflict(message: string) {
|
||||
return new ApiError(409, message, 'CONFLICT')
|
||||
}
|
||||
|
||||
static internal(message: string) {
|
||||
return new ApiError(500, message, 'INTERNAL_ERROR')
|
||||
}
|
||||
}
|
||||
|
||||
export function errorResponse(error: unknown): Response {
|
||||
if (error instanceof ApiError) {
|
||||
return Response.json(
|
||||
{ error: error.code || 'ERROR', message: error.message },
|
||||
{ status: error.statusCode }
|
||||
)
|
||||
}
|
||||
|
||||
console.error('[Server] Unexpected error:', error)
|
||||
return Response.json(
|
||||
{ error: 'INTERNAL_ERROR', message: 'An unexpected error occurred' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
63
src/server/router.ts
Normal file
63
src/server/router.ts
Normal file
@ -0,0 +1,63 @@
|
||||
/**
|
||||
* API Router — 将请求路由到对应的 API handler
|
||||
*/
|
||||
|
||||
import { handleSessionsApi } from './api/sessions.js'
|
||||
import { handleSettingsApi } from './api/settings.js'
|
||||
import { handleModelsApi } from './api/models.js'
|
||||
import { handleScheduledTasksApi } from './api/scheduled-tasks.js'
|
||||
import { handleSearchApi } from './api/search.js'
|
||||
import { handleAgentsApi } from './api/agents.js'
|
||||
import { handleStatusApi } from './api/status.js'
|
||||
import { handleConversationsApi } from './api/conversations.js'
|
||||
|
||||
export async function handleApiRequest(req: Request, url: URL): Promise<Response> {
|
||||
const path = url.pathname
|
||||
const segments = path.split('/').filter(Boolean) // ['api', 'sessions', ...]
|
||||
|
||||
// Route to appropriate handler based on the second segment
|
||||
const resource = segments[1]
|
||||
|
||||
switch (resource) {
|
||||
case 'sessions': {
|
||||
// Route /api/sessions/:id/chat/* to conversations handler
|
||||
const subResource = segments[3]
|
||||
if (subResource === 'chat') {
|
||||
return handleConversationsApi(req, url, segments)
|
||||
}
|
||||
return handleSessionsApi(req, url, segments)
|
||||
}
|
||||
|
||||
case 'conversations':
|
||||
return handleConversationsApi(req, url, segments)
|
||||
|
||||
case 'settings':
|
||||
return handleSettingsApi(req, url, segments)
|
||||
|
||||
case 'models':
|
||||
case 'effort':
|
||||
return handleModelsApi(req, url, segments)
|
||||
|
||||
case 'permissions':
|
||||
return handleSettingsApi(req, url, segments) // permissions under settings
|
||||
|
||||
case 'scheduled-tasks':
|
||||
return handleScheduledTasksApi(req, url, segments)
|
||||
|
||||
case 'search':
|
||||
return handleSearchApi(req, url, segments)
|
||||
|
||||
case 'agents':
|
||||
case 'tasks':
|
||||
return handleAgentsApi(req, url, segments)
|
||||
|
||||
case 'status':
|
||||
return handleStatusApi(req, url, segments)
|
||||
|
||||
default:
|
||||
return Response.json(
|
||||
{ error: 'Not Found', message: `Unknown API resource: ${resource}` },
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
}
|
||||
240
src/server/services/agentService.ts
Normal file
240
src/server/services/agentService.ts
Normal file
@ -0,0 +1,240 @@
|
||||
/**
|
||||
* AgentService — Agent 定义的增删改查
|
||||
*
|
||||
* Agent 定义存储在 ~/.claude/agents/ 目录下,每个 Agent 一个 YAML 文件。
|
||||
* 也支持 .md 文件(YAML frontmatter 格式)。
|
||||
*/
|
||||
|
||||
import * as fs from 'fs/promises'
|
||||
import * as path from 'path'
|
||||
import * as os from 'os'
|
||||
import YAML from 'yaml'
|
||||
import { ApiError } from '../middleware/errorHandler.js'
|
||||
|
||||
export type AgentDefinition = {
|
||||
name: string
|
||||
description?: string
|
||||
model?: string
|
||||
tools?: string[]
|
||||
systemPrompt?: string
|
||||
color?: string
|
||||
}
|
||||
|
||||
export class AgentService {
|
||||
/** Agent 定义目录 */
|
||||
private getAgentsDir(): string {
|
||||
const configDir =
|
||||
process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude')
|
||||
return path.join(configDir, 'agents')
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 公开方法
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** 列出所有 Agent 定义 */
|
||||
async listAgents(): Promise<AgentDefinition[]> {
|
||||
const dir = this.getAgentsDir()
|
||||
|
||||
try {
|
||||
await fs.access(dir)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
|
||||
const entries = await fs.readdir(dir, { withFileTypes: true })
|
||||
const agents: AgentDefinition[] = []
|
||||
|
||||
for (const entry of entries) {
|
||||
if (!entry.isFile()) continue
|
||||
const ext = path.extname(entry.name)
|
||||
if (ext !== '.yaml' && ext !== '.yml' && ext !== '.md') continue
|
||||
|
||||
try {
|
||||
const agent = await this.loadAgentFile(path.join(dir, entry.name))
|
||||
if (agent) agents.push(agent)
|
||||
} catch {
|
||||
// 跳过无法解析的文件
|
||||
}
|
||||
}
|
||||
|
||||
return agents
|
||||
}
|
||||
|
||||
/** 获取单个 Agent */
|
||||
async getAgent(name: string): Promise<AgentDefinition | null> {
|
||||
const filePath = await this.findAgentFile(name)
|
||||
if (!filePath) return null
|
||||
return this.loadAgentFile(filePath)
|
||||
}
|
||||
|
||||
/** 创建 Agent */
|
||||
async createAgent(agent: AgentDefinition): Promise<void> {
|
||||
if (!agent.name) {
|
||||
throw ApiError.badRequest('Agent name is required')
|
||||
}
|
||||
|
||||
const existing = await this.findAgentFile(agent.name)
|
||||
if (existing) {
|
||||
throw ApiError.conflict(`Agent already exists: ${agent.name}`)
|
||||
}
|
||||
|
||||
const dir = this.getAgentsDir()
|
||||
await fs.mkdir(dir, { recursive: true })
|
||||
|
||||
const filePath = path.join(dir, `${this.sanitizeName(agent.name)}.yaml`)
|
||||
await this.writeAgentFile(filePath, agent)
|
||||
}
|
||||
|
||||
/** 更新 Agent */
|
||||
async updateAgent(
|
||||
name: string,
|
||||
updates: Partial<AgentDefinition>,
|
||||
): Promise<void> {
|
||||
const filePath = await this.findAgentFile(name)
|
||||
if (!filePath) {
|
||||
throw ApiError.notFound(`Agent not found: ${name}`)
|
||||
}
|
||||
|
||||
const current = await this.loadAgentFile(filePath)
|
||||
if (!current) {
|
||||
throw ApiError.notFound(`Agent not found: ${name}`)
|
||||
}
|
||||
|
||||
const merged: AgentDefinition = { ...current, ...updates, name: current.name }
|
||||
await this.writeAgentFile(filePath, merged)
|
||||
}
|
||||
|
||||
/** 删除 Agent */
|
||||
async deleteAgent(name: string): Promise<void> {
|
||||
const filePath = await this.findAgentFile(name)
|
||||
if (!filePath) {
|
||||
throw ApiError.notFound(`Agent not found: ${name}`)
|
||||
}
|
||||
await fs.unlink(filePath)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 内部方法
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** 查找 Agent 文件(支持 .yaml / .yml / .md) */
|
||||
private async findAgentFile(name: string): Promise<string | null> {
|
||||
const dir = this.getAgentsDir()
|
||||
const safeName = this.sanitizeName(name)
|
||||
const candidates = [
|
||||
path.join(dir, `${safeName}.yaml`),
|
||||
path.join(dir, `${safeName}.yml`),
|
||||
path.join(dir, `${safeName}.md`),
|
||||
]
|
||||
|
||||
for (const candidate of candidates) {
|
||||
try {
|
||||
await fs.access(candidate)
|
||||
return candidate
|
||||
} catch {
|
||||
// 继续尝试下一个
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/** 从文件加载 Agent 定义 */
|
||||
private async loadAgentFile(
|
||||
filePath: string,
|
||||
): Promise<AgentDefinition | null> {
|
||||
const raw = await fs.readFile(filePath, 'utf-8')
|
||||
const ext = path.extname(filePath)
|
||||
|
||||
if (ext === '.md') {
|
||||
return this.parseMarkdownFrontmatter(raw, filePath)
|
||||
}
|
||||
|
||||
// YAML 文件
|
||||
const data = YAML.parse(raw) as Record<string, unknown>
|
||||
if (!data || typeof data !== 'object') return null
|
||||
|
||||
return this.toAgentDefinition(data, filePath)
|
||||
}
|
||||
|
||||
/** 解析 Markdown frontmatter */
|
||||
private parseMarkdownFrontmatter(
|
||||
content: string,
|
||||
filePath: string,
|
||||
): AgentDefinition | null {
|
||||
const fmMatch = content.match(/^---\n([\s\S]*?)\n---/)
|
||||
if (!fmMatch) return null
|
||||
|
||||
const data = YAML.parse(fmMatch[1]) as Record<string, unknown>
|
||||
if (!data || typeof data !== 'object') return null
|
||||
|
||||
// systemPrompt 可以来自 frontmatter 之后的 body
|
||||
const body = content.slice(fmMatch[0].length).trim()
|
||||
if (body && !data.systemPrompt) {
|
||||
data.systemPrompt = body
|
||||
}
|
||||
|
||||
return this.toAgentDefinition(data, filePath)
|
||||
}
|
||||
|
||||
/** 将 Record 转为 AgentDefinition */
|
||||
private toAgentDefinition(
|
||||
data: Record<string, unknown>,
|
||||
filePath: string,
|
||||
): AgentDefinition {
|
||||
const baseName = path.basename(filePath).replace(/\.(yaml|yml|md)$/, '')
|
||||
return {
|
||||
name: typeof data.name === 'string' ? data.name : baseName,
|
||||
description:
|
||||
typeof data.description === 'string' ? data.description : undefined,
|
||||
model: typeof data.model === 'string' ? data.model : undefined,
|
||||
tools: Array.isArray(data.tools)
|
||||
? (data.tools as string[])
|
||||
: undefined,
|
||||
systemPrompt:
|
||||
typeof data.systemPrompt === 'string' ? data.systemPrompt : undefined,
|
||||
color: typeof data.color === 'string' ? data.color : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
/** 将 Agent 定义写入文件(根据扩展名选择格式) */
|
||||
private async writeAgentFile(
|
||||
filePath: string,
|
||||
agent: AgentDefinition,
|
||||
): Promise<void> {
|
||||
const ext = path.extname(filePath)
|
||||
|
||||
// 构建 frontmatter/YAML 数据(不含 systemPrompt,它在 .md 中放 body)
|
||||
const data: Record<string, unknown> = { name: agent.name }
|
||||
if (agent.description !== undefined) data.description = agent.description
|
||||
if (agent.model !== undefined) data.model = agent.model
|
||||
if (agent.tools !== undefined) data.tools = agent.tools
|
||||
if (agent.color !== undefined) data.color = agent.color
|
||||
|
||||
if (ext === '.md') {
|
||||
// Markdown: frontmatter + body (systemPrompt)
|
||||
const yamlStr = YAML.stringify(data)
|
||||
let content = `---\n${yamlStr}---\n`
|
||||
if (agent.systemPrompt) {
|
||||
content += `\n${agent.systemPrompt}\n`
|
||||
}
|
||||
await fs.writeFile(filePath, content, 'utf-8')
|
||||
return
|
||||
}
|
||||
|
||||
// YAML: all fields including systemPrompt
|
||||
if (agent.systemPrompt !== undefined) data.systemPrompt = agent.systemPrompt
|
||||
const yamlStr = YAML.stringify(data)
|
||||
await fs.writeFile(filePath, yamlStr, 'utf-8')
|
||||
}
|
||||
|
||||
/** 安全化文件名 */
|
||||
private sanitizeName(name: string): string {
|
||||
return name
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9_-]/g, '-')
|
||||
.replace(/-+/g, '-')
|
||||
.replace(/^-|-$/g, '')
|
||||
}
|
||||
}
|
||||
142
src/server/services/cronService.ts
Normal file
142
src/server/services/cronService.ts
Normal file
@ -0,0 +1,142 @@
|
||||
/**
|
||||
* CronService — 管理定时任务的增删改查
|
||||
*
|
||||
* 任务持久化到 ~/.claude/scheduled_tasks.json(JSON 文件)。
|
||||
* 文件格式: { "tasks": [ CronTask, ... ] }
|
||||
*/
|
||||
|
||||
import * as fs from 'fs/promises'
|
||||
import * as path from 'path'
|
||||
import * as os from 'os'
|
||||
import * as crypto from 'crypto'
|
||||
import { ApiError } from '../middleware/errorHandler.js'
|
||||
|
||||
export type CronTask = {
|
||||
id: string
|
||||
name?: string
|
||||
description?: string
|
||||
cron: string // 5-field cron expression
|
||||
prompt: string
|
||||
createdAt: number // epoch ms
|
||||
lastFiredAt?: number
|
||||
recurring?: boolean
|
||||
permanent?: boolean
|
||||
permissionMode?: string
|
||||
model?: string
|
||||
folderPath?: string
|
||||
useWorktree?: boolean
|
||||
}
|
||||
|
||||
type TasksFile = {
|
||||
tasks: CronTask[]
|
||||
}
|
||||
|
||||
export class CronService {
|
||||
/** 任务文件路径 */
|
||||
private getTasksFilePath(): string {
|
||||
const configDir =
|
||||
process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude')
|
||||
return path.join(configDir, 'scheduled_tasks.json')
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 公开方法
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** 获取所有任务 */
|
||||
async listTasks(): Promise<CronTask[]> {
|
||||
const data = await this.readTasksFile()
|
||||
return data.tasks
|
||||
}
|
||||
|
||||
/** 创建新任务 */
|
||||
async createTask(
|
||||
task: Omit<CronTask, 'id' | 'createdAt'>,
|
||||
): Promise<CronTask> {
|
||||
if (!task.cron || !task.prompt) {
|
||||
throw ApiError.badRequest('Fields "cron" and "prompt" are required')
|
||||
}
|
||||
|
||||
const data = await this.readTasksFile()
|
||||
const newTask: CronTask = {
|
||||
...task,
|
||||
id: crypto.randomBytes(4).toString('hex'),
|
||||
createdAt: Date.now(),
|
||||
}
|
||||
data.tasks.push(newTask)
|
||||
await this.writeTasksFile(data)
|
||||
return newTask
|
||||
}
|
||||
|
||||
/** 更新已有任务 */
|
||||
async updateTask(id: string, updates: Partial<CronTask>): Promise<CronTask> {
|
||||
const data = await this.readTasksFile()
|
||||
const index = data.tasks.findIndex((t) => t.id === id)
|
||||
if (index === -1) {
|
||||
throw ApiError.notFound(`Task not found: ${id}`)
|
||||
}
|
||||
|
||||
// 不允许修改 id 和 createdAt
|
||||
const { id: _id, createdAt: _ca, ...safeUpdates } = updates
|
||||
data.tasks[index] = { ...data.tasks[index], ...safeUpdates }
|
||||
await this.writeTasksFile(data)
|
||||
return data.tasks[index]
|
||||
}
|
||||
|
||||
/** 删除任务 */
|
||||
async deleteTask(id: string): Promise<void> {
|
||||
const data = await this.readTasksFile()
|
||||
const index = data.tasks.findIndex((t) => t.id === id)
|
||||
if (index === -1) {
|
||||
throw ApiError.notFound(`Task not found: ${id}`)
|
||||
}
|
||||
data.tasks.splice(index, 1)
|
||||
await this.writeTasksFile(data)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 内部: 文件读写
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** 读取任务 JSON 文件。文件不存在时返回空列表。 */
|
||||
private async readTasksFile(): Promise<TasksFile> {
|
||||
try {
|
||||
const raw = await fs.readFile(this.getTasksFilePath(), 'utf-8')
|
||||
const parsed = JSON.parse(raw) as TasksFile
|
||||
// 兼容异常格式
|
||||
if (!Array.isArray(parsed.tasks)) {
|
||||
return { tasks: [] }
|
||||
}
|
||||
return parsed
|
||||
} catch (err: unknown) {
|
||||
if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
return { tasks: [] }
|
||||
}
|
||||
throw ApiError.internal(
|
||||
`Failed to read scheduled tasks: ${(err as Error).message}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** 原子写入任务 JSON 文件 */
|
||||
private async writeTasksFile(data: TasksFile): Promise<void> {
|
||||
const filePath = this.getTasksFilePath()
|
||||
const dir = path.dirname(filePath)
|
||||
await fs.mkdir(dir, { recursive: true })
|
||||
|
||||
const tmpFile = `${filePath}.tmp.${Date.now()}`
|
||||
try {
|
||||
await fs.writeFile(
|
||||
tmpFile,
|
||||
JSON.stringify(data, null, 2) + '\n',
|
||||
'utf-8',
|
||||
)
|
||||
await fs.rename(tmpFile, filePath)
|
||||
} catch (err) {
|
||||
await fs.unlink(tmpFile).catch(() => {})
|
||||
throw ApiError.internal(
|
||||
`Failed to write scheduled tasks: ${(err as Error).message}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
348
src/server/services/searchService.ts
Normal file
348
src/server/services/searchService.ts
Normal file
@ -0,0 +1,348 @@
|
||||
/**
|
||||
* SearchService — 工作区文件搜索 & 会话历史搜索
|
||||
*
|
||||
* 优先使用 ripgrep (rg),不可用时降级到 grep。
|
||||
*/
|
||||
|
||||
import { spawn } from 'child_process'
|
||||
import * as fs from 'fs/promises'
|
||||
import * as path from 'path'
|
||||
import * as os from 'os'
|
||||
import { ApiError } from '../middleware/errorHandler.js'
|
||||
|
||||
export type SearchResult = {
|
||||
file: string
|
||||
line: number
|
||||
text: string
|
||||
context?: string[]
|
||||
}
|
||||
|
||||
export type SessionSearchResult = {
|
||||
sessionId: string
|
||||
title: string
|
||||
matchCount: number
|
||||
matches: Array<{ line: number; text: string }>
|
||||
}
|
||||
|
||||
export class SearchService {
|
||||
// ---------------------------------------------------------------------------
|
||||
// 工作区搜索
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** 使用 ripgrep 搜索工作目录 */
|
||||
async searchWorkspace(
|
||||
query: string,
|
||||
options?: {
|
||||
cwd?: string
|
||||
maxResults?: number
|
||||
glob?: string
|
||||
caseSensitive?: boolean
|
||||
},
|
||||
): Promise<SearchResult[]> {
|
||||
if (!query) {
|
||||
throw ApiError.badRequest('Search query is required')
|
||||
}
|
||||
|
||||
const cwd = options?.cwd || process.cwd()
|
||||
const maxResults = options?.maxResults || 200
|
||||
|
||||
// 尝试 rg,降级到 grep
|
||||
const hasRg = await this.commandExists('rg')
|
||||
if (hasRg) {
|
||||
try {
|
||||
return await this.searchWithRipgrep(query, cwd, maxResults, options)
|
||||
} catch {
|
||||
// rg 执行失败,降级到 grep
|
||||
}
|
||||
}
|
||||
|
||||
return this.searchWithGrep(query, cwd, maxResults, options)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 会话历史搜索
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** 搜索会话历史文件 */
|
||||
async searchSessions(query: string): Promise<SessionSearchResult[]> {
|
||||
if (!query) {
|
||||
throw ApiError.badRequest('Search query is required')
|
||||
}
|
||||
|
||||
const configDir =
|
||||
process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude')
|
||||
const projectsDir = path.join(configDir, 'projects')
|
||||
|
||||
const results: SessionSearchResult[] = []
|
||||
|
||||
try {
|
||||
await fs.access(projectsDir)
|
||||
} catch {
|
||||
// 目录不存在,返回空
|
||||
return results
|
||||
}
|
||||
|
||||
// 遍历 projects/ 下的 JSONL 会话文件
|
||||
const entries = await this.walkJsonlFiles(projectsDir)
|
||||
const lowerQuery = query.toLowerCase()
|
||||
|
||||
for (const filePath of entries) {
|
||||
try {
|
||||
const raw = await fs.readFile(filePath, 'utf-8')
|
||||
const lines = raw.split('\n').filter(Boolean)
|
||||
|
||||
const matches: Array<{ line: number; text: string }> = []
|
||||
let title = path.basename(filePath, '.jsonl')
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i]
|
||||
if (line.toLowerCase().includes(lowerQuery)) {
|
||||
// 尝试提取可读文本
|
||||
try {
|
||||
const obj = JSON.parse(line) as Record<string, unknown>
|
||||
const text =
|
||||
typeof obj.message === 'string'
|
||||
? obj.message
|
||||
: typeof obj.content === 'string'
|
||||
? obj.content
|
||||
: line.slice(0, 200)
|
||||
|
||||
// 提取 title
|
||||
if (i === 0 && typeof obj.title === 'string') {
|
||||
title = obj.title
|
||||
}
|
||||
|
||||
matches.push({ line: i + 1, text: text.slice(0, 300) })
|
||||
} catch {
|
||||
matches.push({ line: i + 1, text: line.slice(0, 200) })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (matches.length > 0) {
|
||||
const sessionId = path.basename(filePath, '.jsonl')
|
||||
results.push({
|
||||
sessionId,
|
||||
title,
|
||||
matchCount: matches.length,
|
||||
matches: matches.slice(0, 20), // 每个会话最多 20 条匹配
|
||||
})
|
||||
}
|
||||
} catch {
|
||||
// 跳过无法读取的文件
|
||||
}
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ripgrep 搜索
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
private async searchWithRipgrep(
|
||||
query: string,
|
||||
cwd: string,
|
||||
maxResults: number,
|
||||
options?: {
|
||||
glob?: string
|
||||
caseSensitive?: boolean
|
||||
},
|
||||
): Promise<SearchResult[]> {
|
||||
const args = ['--json', '--max-count', String(maxResults)]
|
||||
|
||||
if (options?.caseSensitive === false) {
|
||||
args.push('--ignore-case')
|
||||
}
|
||||
|
||||
// 添加上下文行
|
||||
args.push('-C', '4')
|
||||
|
||||
if (options?.glob) {
|
||||
args.push('--glob', options.glob)
|
||||
}
|
||||
|
||||
args.push('--', query, cwd)
|
||||
|
||||
const output = await this.runCommand('rg', args)
|
||||
return this.parseRipgrepJson(output, maxResults)
|
||||
}
|
||||
|
||||
/** 解析 ripgrep JSON 输出 */
|
||||
private parseRipgrepJson(
|
||||
output: string,
|
||||
maxResults: number,
|
||||
): SearchResult[] {
|
||||
const results: SearchResult[] = []
|
||||
const lines = output.split('\n').filter(Boolean)
|
||||
|
||||
// 收集上下文:key = `${file}:${matchLine}`
|
||||
const contextMap = new Map<
|
||||
string,
|
||||
{ file: string; line: number; text: string; context: string[] }
|
||||
>()
|
||||
|
||||
for (const line of lines) {
|
||||
try {
|
||||
const obj = JSON.parse(line) as Record<string, unknown>
|
||||
if (obj.type === 'match') {
|
||||
const data = obj.data as {
|
||||
path?: { text?: string }
|
||||
line_number?: number
|
||||
lines?: { text?: string }
|
||||
submatches?: unknown[]
|
||||
}
|
||||
|
||||
const file = data.path?.text || ''
|
||||
const lineNum = data.line_number || 0
|
||||
const text = (data.lines?.text || '').replace(/\n$/, '')
|
||||
const key = `${file}:${lineNum}`
|
||||
|
||||
contextMap.set(key, { file, line: lineNum, text, context: [] })
|
||||
} else if (obj.type === 'context') {
|
||||
// 上下文行归属到最近的 match
|
||||
const data = obj.data as {
|
||||
path?: { text?: string }
|
||||
line_number?: number
|
||||
lines?: { text?: string }
|
||||
}
|
||||
const text = (data.lines?.text || '').replace(/\n$/, '')
|
||||
|
||||
// 附加到最后一个相同文件的 match
|
||||
const file = data.path?.text || ''
|
||||
for (const [key, entry] of contextMap) {
|
||||
if (key.startsWith(file + ':')) {
|
||||
entry.context.push(text)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// 跳过无法解析的行
|
||||
}
|
||||
}
|
||||
|
||||
for (const entry of contextMap.values()) {
|
||||
if (results.length >= maxResults) break
|
||||
results.push({
|
||||
file: entry.file,
|
||||
line: entry.line,
|
||||
text: entry.text,
|
||||
context: entry.context.length > 0 ? entry.context : undefined,
|
||||
})
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// grep 降级
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
private async searchWithGrep(
|
||||
query: string,
|
||||
cwd: string,
|
||||
maxResults: number,
|
||||
options?: {
|
||||
glob?: string
|
||||
caseSensitive?: boolean
|
||||
},
|
||||
): Promise<SearchResult[]> {
|
||||
const args = ['-rn', '--max-count', String(maxResults)]
|
||||
|
||||
if (options?.caseSensitive === false) {
|
||||
args.push('-i')
|
||||
}
|
||||
|
||||
if (options?.glob) {
|
||||
args.push('--include', options.glob)
|
||||
}
|
||||
|
||||
args.push('--', query, cwd)
|
||||
|
||||
const output = await this.runCommand('grep', args)
|
||||
return this.parseGrepOutput(output, maxResults)
|
||||
}
|
||||
|
||||
/** 解析 grep 输出 (file:line:text) */
|
||||
private parseGrepOutput(output: string, maxResults: number): SearchResult[] {
|
||||
const results: SearchResult[] = []
|
||||
const lines = output.split('\n').filter(Boolean)
|
||||
|
||||
for (const line of lines) {
|
||||
if (results.length >= maxResults) break
|
||||
|
||||
// grep -n 输出格式: file:line:text
|
||||
const match = line.match(/^(.+?):(\d+):(.*)$/)
|
||||
if (match) {
|
||||
results.push({
|
||||
file: match[1],
|
||||
line: parseInt(match[2], 10),
|
||||
text: match[3],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 工具方法
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** 运行外部命令,返回 stdout */
|
||||
private runCommand(cmd: string, args: string[]): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const proc = spawn(cmd, args, { stdio: ['ignore', 'pipe', 'pipe'] })
|
||||
const chunks: Buffer[] = []
|
||||
|
||||
proc.stdout.on('data', (chunk: Buffer) => chunks.push(chunk))
|
||||
|
||||
proc.on('close', (code) => {
|
||||
const output = Buffer.concat(chunks).toString('utf-8')
|
||||
// rg/grep 返回 1 表示无匹配,不视为错误
|
||||
if (code === 0 || code === 1) {
|
||||
resolve(output)
|
||||
} else {
|
||||
reject(
|
||||
new Error(`Command "${cmd}" exited with code ${code}: ${output}`),
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
proc.on('error', (err) => {
|
||||
reject(err)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/** 检测命令是否存在 */
|
||||
private commandExists(cmd: string): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
const proc = spawn('which', [cmd], { stdio: 'ignore' })
|
||||
proc.on('close', (code) => resolve(code === 0))
|
||||
proc.on('error', () => resolve(false))
|
||||
})
|
||||
}
|
||||
|
||||
/** 递归查找 .jsonl 文件 */
|
||||
private async walkJsonlFiles(dir: string): Promise<string[]> {
|
||||
const results: string[] = []
|
||||
|
||||
try {
|
||||
const entries = await fs.readdir(dir, { withFileTypes: true })
|
||||
for (const entry of entries) {
|
||||
const fullPath = path.join(dir, entry.name)
|
||||
if (entry.isDirectory()) {
|
||||
const sub = await this.walkJsonlFiles(fullPath)
|
||||
results.push(...sub)
|
||||
} else if (entry.name.endsWith('.jsonl')) {
|
||||
results.push(fullPath)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// 跳过不可访问的目录
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
}
|
||||
514
src/server/services/sessionService.ts
Normal file
514
src/server/services/sessionService.ts
Normal file
@ -0,0 +1,514 @@
|
||||
/**
|
||||
* Session Service — 会话文件的读写操作封装
|
||||
*
|
||||
* 读写 CLI 持久化在 ~/.claude/projects/{sanitized_path}/{sessionId}.jsonl 的会话数据,
|
||||
* 确保 Desktop App 与 CLI 的数据完全互通。
|
||||
*/
|
||||
|
||||
import * as fs from 'node:fs/promises'
|
||||
import * as path from 'node:path'
|
||||
import * as os from 'node:os'
|
||||
import { ApiError } from '../middleware/errorHandler.js'
|
||||
|
||||
// ============================================================================
|
||||
// Types
|
||||
// ============================================================================
|
||||
|
||||
export type SessionListItem = {
|
||||
id: string
|
||||
title: string
|
||||
createdAt: string
|
||||
modifiedAt: string
|
||||
messageCount: number
|
||||
projectPath: string
|
||||
}
|
||||
|
||||
export type SessionDetail = SessionListItem & {
|
||||
messages: MessageEntry[]
|
||||
}
|
||||
|
||||
export type MessageEntry = {
|
||||
id: string
|
||||
type: 'user' | 'assistant' | 'system' | 'tool_use' | 'tool_result'
|
||||
content: unknown
|
||||
timestamp: string
|
||||
model?: string
|
||||
parentUuid?: string
|
||||
isSidechain?: boolean
|
||||
}
|
||||
|
||||
/** Raw entry parsed from a single JSONL line */
|
||||
type RawEntry = {
|
||||
type?: string
|
||||
uuid?: string
|
||||
parentUuid?: string | null
|
||||
isSidechain?: boolean
|
||||
isMeta?: boolean
|
||||
message?: {
|
||||
role?: string
|
||||
content?: unknown
|
||||
model?: string
|
||||
id?: string
|
||||
type?: string
|
||||
}
|
||||
timestamp?: string
|
||||
customTitle?: string
|
||||
title?: string
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Service
|
||||
// ============================================================================
|
||||
|
||||
export class SessionService {
|
||||
// --------------------------------------------------------------------------
|
||||
// Config helpers
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
private getConfigDir(): string {
|
||||
return process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude')
|
||||
}
|
||||
|
||||
private getProjectsDir(): string {
|
||||
return path.join(this.getConfigDir(), 'projects')
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize a path the same way the CLI does: replace path separators with '-'.
|
||||
* For example, `/Users/nanmi/workspace` becomes `-Users-nanmi-workspace`.
|
||||
*/
|
||||
private sanitizePath(dirPath: string): string {
|
||||
return dirPath.replace(/\//g, '-').replace(/\\/g, '-')
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// JSONL parsing
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
private async readJsonlFile(filePath: string): Promise<RawEntry[]> {
|
||||
let content: string
|
||||
try {
|
||||
content = await fs.readFile(filePath, 'utf-8')
|
||||
} catch (err: unknown) {
|
||||
if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
return []
|
||||
}
|
||||
throw err
|
||||
}
|
||||
|
||||
const entries: RawEntry[] = []
|
||||
for (const line of content.split('\n')) {
|
||||
const trimmed = line.trim()
|
||||
if (!trimmed) continue
|
||||
try {
|
||||
entries.push(JSON.parse(trimmed) as RawEntry)
|
||||
} catch {
|
||||
// skip malformed lines
|
||||
}
|
||||
}
|
||||
return entries
|
||||
}
|
||||
|
||||
private async appendJsonlEntry(filePath: string, entry: Record<string, unknown>): Promise<void> {
|
||||
const line = JSON.stringify(entry) + '\n'
|
||||
await fs.appendFile(filePath, line, 'utf-8')
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Entry → MessageEntry conversion
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
private entryToMessage(entry: RawEntry): MessageEntry | null {
|
||||
const msg = entry.message
|
||||
if (!msg || !msg.role) return null
|
||||
|
||||
// Determine our normalized type
|
||||
let type: MessageEntry['type']
|
||||
const role = msg.role
|
||||
|
||||
if (role === 'user') {
|
||||
// Check if the content is a tool_result array
|
||||
if (Array.isArray(msg.content)) {
|
||||
const hasToolResult = msg.content.some(
|
||||
(block: Record<string, unknown>) => block.type === 'tool_result'
|
||||
)
|
||||
if (hasToolResult) {
|
||||
type = 'tool_result'
|
||||
} else {
|
||||
type = 'user'
|
||||
}
|
||||
} else {
|
||||
type = 'user'
|
||||
}
|
||||
} else if (role === 'assistant') {
|
||||
// Check if the content contains tool_use blocks
|
||||
if (Array.isArray(msg.content)) {
|
||||
const hasToolUse = msg.content.some(
|
||||
(block: Record<string, unknown>) => block.type === 'tool_use'
|
||||
)
|
||||
type = hasToolUse ? 'tool_use' : 'assistant'
|
||||
} else {
|
||||
type = 'assistant'
|
||||
}
|
||||
} else {
|
||||
type = 'system'
|
||||
}
|
||||
|
||||
return {
|
||||
id: entry.uuid || crypto.randomUUID(),
|
||||
type,
|
||||
content: msg.content,
|
||||
timestamp: entry.timestamp || new Date().toISOString(),
|
||||
model: msg.model,
|
||||
parentUuid: entry.parentUuid ?? undefined,
|
||||
isSidechain: entry.isSidechain,
|
||||
}
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Title extraction
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
private extractTitle(entries: RawEntry[]): string {
|
||||
// 1. Look for custom title entry (appended by renameSession)
|
||||
for (let i = entries.length - 1; i >= 0; i--) {
|
||||
const e = entries[i]!
|
||||
if (e.type === 'custom-title' && e.customTitle) {
|
||||
return e.customTitle
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Look for first non-meta user message as title
|
||||
for (const e of entries) {
|
||||
if (e.type === 'user' && !e.isMeta && e.message?.role === 'user') {
|
||||
const content = e.message.content
|
||||
let text: string | undefined
|
||||
if (typeof content === 'string') {
|
||||
text = content
|
||||
} else if (Array.isArray(content)) {
|
||||
// Extract text from content blocks: [{ type: 'text', text: '...' }]
|
||||
const textBlock = content.find(
|
||||
(block: Record<string, unknown>) => block.type === 'text' && typeof block.text === 'string'
|
||||
)
|
||||
if (textBlock) text = textBlock.text as string
|
||||
}
|
||||
if (text) {
|
||||
return text.length > 80 ? text.slice(0, 80) + '...' : text
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return 'Untitled Session'
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Session file discovery
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Find all .jsonl session files across all project directories.
|
||||
* Returns an array of { filePath, projectDir, sessionId }.
|
||||
*/
|
||||
private async discoverSessionFiles(projectFilter?: string): Promise<
|
||||
Array<{ filePath: string; projectDir: string; sessionId: string }>
|
||||
> {
|
||||
const projectsDir = this.getProjectsDir()
|
||||
let projectDirs: string[]
|
||||
|
||||
try {
|
||||
projectDirs = await fs.readdir(projectsDir)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
|
||||
// Optionally filter to a specific project
|
||||
if (projectFilter) {
|
||||
const sanitized = this.sanitizePath(projectFilter)
|
||||
projectDirs = projectDirs.filter((d) => d === sanitized)
|
||||
}
|
||||
|
||||
const results: Array<{ filePath: string; projectDir: string; sessionId: string }> = []
|
||||
|
||||
for (const dir of projectDirs) {
|
||||
const dirPath = path.join(projectsDir, dir)
|
||||
|
||||
// Ensure it's a directory
|
||||
try {
|
||||
const stat = await fs.stat(dirPath)
|
||||
if (!stat.isDirectory()) continue
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
|
||||
let files: string[]
|
||||
try {
|
||||
files = await fs.readdir(dirPath)
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
|
||||
for (const file of files) {
|
||||
if (!file.endsWith('.jsonl')) continue
|
||||
const sessionId = file.replace('.jsonl', '')
|
||||
results.push({
|
||||
filePath: path.join(dirPath, file),
|
||||
projectDir: dir,
|
||||
sessionId,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the .jsonl file for a given session ID.
|
||||
* Searches across all project directories since sessions may belong to any project.
|
||||
*/
|
||||
private async findSessionFile(
|
||||
sessionId: string
|
||||
): Promise<{ filePath: string; projectDir: string } | null> {
|
||||
// Validate sessionId format to prevent path traversal
|
||||
if (!this.isValidSessionId(sessionId)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const projectsDir = this.getProjectsDir()
|
||||
let projectDirs: string[]
|
||||
|
||||
try {
|
||||
projectDirs = await fs.readdir(projectsDir)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
|
||||
for (const dir of projectDirs) {
|
||||
const filePath = path.join(projectsDir, dir, `${sessionId}.jsonl`)
|
||||
try {
|
||||
await fs.access(filePath)
|
||||
return { filePath, projectDir: dir }
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
private isValidSessionId(id: string): boolean {
|
||||
// UUID v4 format
|
||||
return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(id)
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Public API
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* List all sessions, optionally filtered by project path.
|
||||
*/
|
||||
async listSessions(options?: {
|
||||
project?: string
|
||||
limit?: number
|
||||
offset?: number
|
||||
}): Promise<{ sessions: SessionListItem[]; total: number }> {
|
||||
const sessionFiles = await this.discoverSessionFiles(options?.project)
|
||||
|
||||
// Build session list items with metadata from file stats & first entries
|
||||
const items: SessionListItem[] = []
|
||||
|
||||
for (const { filePath, projectDir, sessionId } of sessionFiles) {
|
||||
try {
|
||||
const stat = await fs.stat(filePath)
|
||||
const entries = await this.readJsonlFile(filePath)
|
||||
|
||||
// Count transcript messages only (user + assistant)
|
||||
const messageCount = entries.filter(
|
||||
(e) => (e.type === 'user' || e.type === 'assistant') && e.message?.role
|
||||
).length
|
||||
|
||||
const title = this.extractTitle(entries)
|
||||
|
||||
// Find the earliest timestamp from entries, fallback to file birthtime
|
||||
let createdAt = stat.birthtime.toISOString()
|
||||
for (const e of entries) {
|
||||
if (e.timestamp) {
|
||||
createdAt = e.timestamp
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
items.push({
|
||||
id: sessionId,
|
||||
title,
|
||||
createdAt,
|
||||
modifiedAt: stat.mtime.toISOString(),
|
||||
messageCount,
|
||||
projectPath: projectDir,
|
||||
})
|
||||
} catch {
|
||||
// Skip unreadable files
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by modifiedAt descending (most recent first)
|
||||
items.sort((a, b) => new Date(b.modifiedAt).getTime() - new Date(a.modifiedAt).getTime())
|
||||
|
||||
const total = items.length
|
||||
const offset = options?.offset ?? 0
|
||||
const limit = options?.limit ?? 50
|
||||
const paginated = items.slice(offset, offset + limit)
|
||||
|
||||
return { sessions: paginated, total }
|
||||
}
|
||||
|
||||
/**
|
||||
* Get full session detail including all messages.
|
||||
*/
|
||||
async getSession(sessionId: string): Promise<SessionDetail | null> {
|
||||
const found = await this.findSessionFile(sessionId)
|
||||
if (!found) return null
|
||||
|
||||
const { filePath, projectDir } = found
|
||||
const stat = await fs.stat(filePath)
|
||||
const entries = await this.readJsonlFile(filePath)
|
||||
|
||||
const messages = this.entriesToMessages(entries)
|
||||
const title = this.extractTitle(entries)
|
||||
|
||||
let createdAt = stat.birthtime.toISOString()
|
||||
for (const e of entries) {
|
||||
if (e.timestamp) {
|
||||
createdAt = e.timestamp
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
id: sessionId,
|
||||
title,
|
||||
createdAt,
|
||||
modifiedAt: stat.mtime.toISOString(),
|
||||
messageCount: messages.length,
|
||||
projectPath: projectDir,
|
||||
messages,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get only the messages for a session (lighter than full detail).
|
||||
*/
|
||||
async getSessionMessages(sessionId: string): Promise<MessageEntry[]> {
|
||||
const found = await this.findSessionFile(sessionId)
|
||||
if (!found) {
|
||||
throw ApiError.notFound(`Session not found: ${sessionId}`)
|
||||
}
|
||||
|
||||
const entries = await this.readJsonlFile(found.filePath)
|
||||
return this.entriesToMessages(entries)
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new session file for the given working directory.
|
||||
*/
|
||||
async createSession(workDir: string): Promise<{ sessionId: string }> {
|
||||
if (!workDir) {
|
||||
throw ApiError.badRequest('workDir is required')
|
||||
}
|
||||
|
||||
const sessionId = crypto.randomUUID()
|
||||
const sanitized = this.sanitizePath(workDir)
|
||||
const dirPath = path.join(this.getProjectsDir(), sanitized)
|
||||
|
||||
// Ensure the project directory exists
|
||||
await fs.mkdir(dirPath, { recursive: true })
|
||||
|
||||
const filePath = path.join(dirPath, `${sessionId}.jsonl`)
|
||||
const now = new Date().toISOString()
|
||||
|
||||
// Write an initial file-history-snapshot entry (matches CLI behavior)
|
||||
const initialEntry = {
|
||||
type: 'file-history-snapshot',
|
||||
messageId: crypto.randomUUID(),
|
||||
snapshot: {
|
||||
messageId: crypto.randomUUID(),
|
||||
trackedFileBackups: {},
|
||||
timestamp: now,
|
||||
},
|
||||
isSnapshotUpdate: false,
|
||||
}
|
||||
|
||||
await fs.writeFile(filePath, JSON.stringify(initialEntry) + '\n', 'utf-8')
|
||||
|
||||
return { sessionId }
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a session's JSONL file.
|
||||
*/
|
||||
async deleteSession(sessionId: string): Promise<void> {
|
||||
const found = await this.findSessionFile(sessionId)
|
||||
if (!found) {
|
||||
throw ApiError.notFound(`Session not found: ${sessionId}`)
|
||||
}
|
||||
|
||||
await fs.unlink(found.filePath)
|
||||
}
|
||||
|
||||
/**
|
||||
* Rename a session by appending a custom-title entry to its JSONL file.
|
||||
*/
|
||||
async renameSession(sessionId: string, title: string): Promise<void> {
|
||||
if (!title || typeof title !== 'string') {
|
||||
throw ApiError.badRequest('title is required')
|
||||
}
|
||||
|
||||
const found = await this.findSessionFile(sessionId)
|
||||
if (!found) {
|
||||
throw ApiError.notFound(`Session not found: ${sessionId}`)
|
||||
}
|
||||
|
||||
const entry = {
|
||||
type: 'custom-title',
|
||||
customTitle: title,
|
||||
timestamp: new Date().toISOString(),
|
||||
}
|
||||
|
||||
await this.appendJsonlEntry(found.filePath, entry)
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Private helpers
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
private entriesToMessages(entries: RawEntry[]): MessageEntry[] {
|
||||
const messages: MessageEntry[] = []
|
||||
for (const entry of entries) {
|
||||
// Only process transcript entries (user / assistant / system with messages)
|
||||
if (!entry.message?.role) continue
|
||||
|
||||
// Skip meta entries (CLI internal bookkeeping)
|
||||
if (entry.isMeta) continue
|
||||
|
||||
// Skip non-transcript entry types
|
||||
const entryType = entry.type
|
||||
if (
|
||||
entryType !== 'user' &&
|
||||
entryType !== 'assistant' &&
|
||||
entryType !== 'system'
|
||||
) {
|
||||
continue
|
||||
}
|
||||
|
||||
const msg = this.entryToMessage(entry)
|
||||
if (msg) {
|
||||
messages.push(msg)
|
||||
}
|
||||
}
|
||||
return messages
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance for shared use across API handlers
|
||||
export const sessionService = new SessionService()
|
||||
151
src/server/services/settingsService.ts
Normal file
151
src/server/services/settingsService.ts
Normal file
@ -0,0 +1,151 @@
|
||||
/**
|
||||
* Settings Service — 读写用户级和项目级设置文件
|
||||
*
|
||||
* 设置文件为 JSON 格式:
|
||||
* - 用户级: ~/.claude/settings.json
|
||||
* - 项目级: {projectRoot}/.claude/settings.json
|
||||
*
|
||||
* 合并策略:Object.assign({}, userSettings, projectSettings)
|
||||
*/
|
||||
|
||||
import * as fs from 'fs/promises'
|
||||
import * as path from 'path'
|
||||
import * as os from 'os'
|
||||
import { ApiError } from '../middleware/errorHandler.js'
|
||||
|
||||
const VALID_PERMISSION_MODES = [
|
||||
'default',
|
||||
'acceptEdits',
|
||||
'plan',
|
||||
'bypassPermissions',
|
||||
'dontAsk',
|
||||
] as const
|
||||
|
||||
export type PermissionMode = (typeof VALID_PERMISSION_MODES)[number]
|
||||
|
||||
export class SettingsService {
|
||||
private projectRoot?: string
|
||||
|
||||
constructor(projectRoot?: string) {
|
||||
this.projectRoot = projectRoot
|
||||
}
|
||||
|
||||
/** 配置目录,支持通过环境变量覆盖(便于测试) */
|
||||
private getConfigDir(): string {
|
||||
return process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude')
|
||||
}
|
||||
|
||||
/** 用户级设置文件路径 */
|
||||
private getUserSettingsPath(): string {
|
||||
return path.join(this.getConfigDir(), 'settings.json')
|
||||
}
|
||||
|
||||
/** 项目级设置文件路径 */
|
||||
private getProjectSettingsPath(projectRoot?: string): string {
|
||||
const root = projectRoot || this.projectRoot
|
||||
if (!root) {
|
||||
throw ApiError.badRequest('Project root is required for project settings')
|
||||
}
|
||||
return path.join(root, '.claude', 'settings.json')
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 读取
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** 安全读取 JSON 文件,文件不存在时返回空对象 */
|
||||
private async readJsonFile(filePath: string): Promise<Record<string, unknown>> {
|
||||
try {
|
||||
const raw = await fs.readFile(filePath, 'utf-8')
|
||||
return JSON.parse(raw) as Record<string, unknown>
|
||||
} catch (err: unknown) {
|
||||
if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
return {}
|
||||
}
|
||||
throw ApiError.internal(`Failed to read settings from ${filePath}: ${err}`)
|
||||
}
|
||||
}
|
||||
|
||||
/** 获取合并后的设置(user + project) */
|
||||
async getSettings(projectRoot?: string): Promise<Record<string, unknown>> {
|
||||
const user = await this.getUserSettings()
|
||||
try {
|
||||
const project = await this.getProjectSettings(projectRoot)
|
||||
return Object.assign({}, user, project)
|
||||
} catch {
|
||||
// project root 未指定时,仅返回 user settings
|
||||
return user
|
||||
}
|
||||
}
|
||||
|
||||
/** 获取用户级设置 */
|
||||
async getUserSettings(): Promise<Record<string, unknown>> {
|
||||
return this.readJsonFile(this.getUserSettingsPath())
|
||||
}
|
||||
|
||||
/** 获取项目级设置 */
|
||||
async getProjectSettings(projectRoot?: string): Promise<Record<string, unknown>> {
|
||||
return this.readJsonFile(this.getProjectSettingsPath(projectRoot))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 写入(原子写入:先写临时文件,再 rename)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** 原子写入 JSON 文件 */
|
||||
private async writeJsonFile(
|
||||
filePath: string,
|
||||
data: Record<string, unknown>,
|
||||
): Promise<void> {
|
||||
const dir = path.dirname(filePath)
|
||||
await fs.mkdir(dir, { recursive: true })
|
||||
|
||||
const tmpFile = `${filePath}.tmp.${Date.now()}`
|
||||
try {
|
||||
await fs.writeFile(tmpFile, JSON.stringify(data, null, 2) + '\n', 'utf-8')
|
||||
await fs.rename(tmpFile, filePath)
|
||||
} catch (err) {
|
||||
// 清理临时文件(best-effort)
|
||||
await fs.unlink(tmpFile).catch(() => {})
|
||||
throw ApiError.internal(`Failed to write settings to ${filePath}: ${err}`)
|
||||
}
|
||||
}
|
||||
|
||||
/** 更新用户级设置(浅合并) */
|
||||
async updateUserSettings(settings: Record<string, unknown>): Promise<void> {
|
||||
const current = await this.getUserSettings()
|
||||
const merged = Object.assign({}, current, settings)
|
||||
await this.writeJsonFile(this.getUserSettingsPath(), merged)
|
||||
}
|
||||
|
||||
/** 更新项目级设置(浅合并) */
|
||||
async updateProjectSettings(
|
||||
settings: Record<string, unknown>,
|
||||
projectRoot?: string,
|
||||
): Promise<void> {
|
||||
const filePath = this.getProjectSettingsPath(projectRoot)
|
||||
const current = await this.readJsonFile(filePath)
|
||||
const merged = Object.assign({}, current, settings)
|
||||
await this.writeJsonFile(filePath, merged)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 权限模式
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** 获取当前权限模式 */
|
||||
async getPermissionMode(): Promise<string> {
|
||||
const settings = await this.getUserSettings()
|
||||
return (settings.defaultMode as string) || 'default'
|
||||
}
|
||||
|
||||
/** 设置权限模式 */
|
||||
async setPermissionMode(mode: string): Promise<void> {
|
||||
if (!VALID_PERMISSION_MODES.includes(mode as PermissionMode)) {
|
||||
throw ApiError.badRequest(
|
||||
`Invalid permission mode: "${mode}". Valid modes: ${VALID_PERMISSION_MODES.join(', ')}`,
|
||||
)
|
||||
}
|
||||
await this.updateUserSettings({ defaultMode: mode })
|
||||
}
|
||||
}
|
||||
59
src/server/ws/events.ts
Normal file
59
src/server/ws/events.ts
Normal file
@ -0,0 +1,59 @@
|
||||
/**
|
||||
* WebSocket event type definitions
|
||||
*
|
||||
* 定义客户端与服务器之间 WebSocket 通信的消息类型。
|
||||
*/
|
||||
|
||||
// ============================================================================
|
||||
// Client → Server
|
||||
// ============================================================================
|
||||
|
||||
export type ClientMessage =
|
||||
| { type: 'user_message'; content: string; attachments?: AttachmentRef[] }
|
||||
| { type: 'permission_response'; requestId: string; allowed: boolean; rule?: string }
|
||||
| { type: 'stop_generation' }
|
||||
| { type: 'ping' }
|
||||
|
||||
export type AttachmentRef = {
|
||||
type: 'file' | 'image'
|
||||
path?: string
|
||||
data?: string // base64 for images
|
||||
mimeType?: string
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Server → Client
|
||||
// ============================================================================
|
||||
|
||||
export type ServerMessage =
|
||||
| { type: 'connected'; sessionId: string }
|
||||
| { type: 'content_start'; blockType: 'text' | 'tool_use'; toolName?: string; toolUseId?: string }
|
||||
| { type: 'content_delta'; text?: string; toolInput?: string }
|
||||
| { type: 'tool_use_complete'; toolName: string; toolUseId: string; input: unknown }
|
||||
| { type: 'tool_result'; toolUseId: string; content: unknown; isError: boolean }
|
||||
| { type: 'permission_request'; requestId: string; toolName: string; input: unknown; description?: string }
|
||||
| { type: 'message_complete'; usage: TokenUsage }
|
||||
| { type: 'thinking'; text: string }
|
||||
| { type: 'status'; state: ChatState; verb?: string; elapsed?: number; tokens?: number }
|
||||
| { type: 'error'; message: string; code: string; retryable?: boolean }
|
||||
| { type: 'pong' }
|
||||
|
||||
export type TokenUsage = {
|
||||
input_tokens: number
|
||||
output_tokens: number
|
||||
cache_read_tokens?: number
|
||||
cache_creation_tokens?: number
|
||||
}
|
||||
|
||||
export type ChatState = 'idle' | 'thinking' | 'tool_executing' | 'streaming' | 'permission_pending'
|
||||
|
||||
// ============================================================================
|
||||
// Internal types
|
||||
// ============================================================================
|
||||
|
||||
export type WebSocketSession = {
|
||||
sessionId: string
|
||||
connectedAt: number
|
||||
abortController?: AbortController
|
||||
isGenerating: boolean
|
||||
}
|
||||
136
src/server/ws/handler.ts
Normal file
136
src/server/ws/handler.ts
Normal file
@ -0,0 +1,136 @@
|
||||
/**
|
||||
* WebSocket connection handler
|
||||
*
|
||||
* 管理 WebSocket 连接生命周期,处理消息路由。
|
||||
*/
|
||||
|
||||
import type { ServerWebSocket } from 'bun'
|
||||
import type { ClientMessage, ServerMessage, WebSocketSession } from './events.js'
|
||||
|
||||
export type WebSocketData = {
|
||||
sessionId: string
|
||||
connectedAt: number
|
||||
}
|
||||
|
||||
// Active WebSocket sessions
|
||||
const activeSessions = new Map<string, ServerWebSocket<WebSocketData>>()
|
||||
|
||||
export const handleWebSocket = {
|
||||
open(ws: ServerWebSocket<WebSocketData>) {
|
||||
const { sessionId } = ws.data
|
||||
console.log(`[WS] Client connected for session: ${sessionId}`)
|
||||
|
||||
activeSessions.set(sessionId, ws)
|
||||
|
||||
const msg: ServerMessage = { type: 'connected', sessionId }
|
||||
ws.send(JSON.stringify(msg))
|
||||
},
|
||||
|
||||
message(ws: ServerWebSocket<WebSocketData>, rawMessage: string | Buffer) {
|
||||
try {
|
||||
const message = JSON.parse(
|
||||
typeof rawMessage === 'string' ? rawMessage : rawMessage.toString()
|
||||
) as ClientMessage
|
||||
|
||||
switch (message.type) {
|
||||
case 'user_message':
|
||||
handleUserMessage(ws, message)
|
||||
break
|
||||
|
||||
case 'permission_response':
|
||||
handlePermissionResponse(ws, message)
|
||||
break
|
||||
|
||||
case 'stop_generation':
|
||||
handleStopGeneration(ws)
|
||||
break
|
||||
|
||||
case 'ping':
|
||||
ws.send(JSON.stringify({ type: 'pong' } satisfies ServerMessage))
|
||||
break
|
||||
|
||||
default:
|
||||
sendError(ws, `Unknown message type: ${(message as any).type}`, 'UNKNOWN_TYPE')
|
||||
}
|
||||
} catch (error) {
|
||||
sendError(ws, `Invalid message format: ${error}`, 'PARSE_ERROR')
|
||||
}
|
||||
},
|
||||
|
||||
close(ws: ServerWebSocket<WebSocketData>, code: number, reason: string) {
|
||||
const { sessionId } = ws.data
|
||||
console.log(`[WS] Client disconnected from session: ${sessionId} (${code}: ${reason})`)
|
||||
activeSessions.delete(sessionId)
|
||||
},
|
||||
|
||||
drain(ws: ServerWebSocket<WebSocketData>) {
|
||||
// Backpressure handling - called when the socket is ready to receive more data
|
||||
},
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Message handlers (to be implemented by conversationService)
|
||||
// ============================================================================
|
||||
|
||||
async function handleUserMessage(
|
||||
ws: ServerWebSocket<WebSocketData>,
|
||||
message: Extract<ClientMessage, { type: 'user_message' }>
|
||||
) {
|
||||
const { sessionId } = ws.data
|
||||
|
||||
// Send thinking status
|
||||
sendMessage(ws, { type: 'status', state: 'thinking', verb: 'Thinking' })
|
||||
|
||||
// TODO: Integrate with ConversationService to process the message
|
||||
// For now, echo back a placeholder response
|
||||
sendMessage(ws, { type: 'content_start', blockType: 'text' })
|
||||
sendMessage(ws, {
|
||||
type: 'content_delta',
|
||||
text: `[Server] Received message for session ${sessionId}: "${message.content}". Chat integration pending.`,
|
||||
})
|
||||
sendMessage(ws, {
|
||||
type: 'message_complete',
|
||||
usage: { input_tokens: 0, output_tokens: 0 },
|
||||
})
|
||||
sendMessage(ws, { type: 'status', state: 'idle' })
|
||||
}
|
||||
|
||||
function handlePermissionResponse(
|
||||
ws: ServerWebSocket<WebSocketData>,
|
||||
message: Extract<ClientMessage, { type: 'permission_response' }>
|
||||
) {
|
||||
// TODO: Forward permission response to the active tool execution
|
||||
console.log(`[WS] Permission response for ${message.requestId}: ${message.allowed}`)
|
||||
}
|
||||
|
||||
function handleStopGeneration(ws: ServerWebSocket<WebSocketData>) {
|
||||
// TODO: Abort the active generation for this session
|
||||
console.log(`[WS] Stop generation requested for session: ${ws.data.sessionId}`)
|
||||
sendMessage(ws, { type: 'status', state: 'idle' })
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Helpers
|
||||
// ============================================================================
|
||||
|
||||
function sendMessage(ws: ServerWebSocket<WebSocketData>, message: ServerMessage) {
|
||||
ws.send(JSON.stringify(message))
|
||||
}
|
||||
|
||||
function sendError(ws: ServerWebSocket<WebSocketData>, message: string, code: string) {
|
||||
sendMessage(ws, { type: 'error', message, code })
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a message to a specific session's WebSocket (for use by services)
|
||||
*/
|
||||
export function sendToSession(sessionId: string, message: ServerMessage): boolean {
|
||||
const ws = activeSessions.get(sessionId)
|
||||
if (!ws) return false
|
||||
ws.send(JSON.stringify(message))
|
||||
return true
|
||||
}
|
||||
|
||||
export function getActiveSessionIds(): string[] {
|
||||
return Array.from(activeSessions.keys())
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user