From 0c6d2754b4d53cc58fa2507c79d90728eaf20c69 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Sun, 5 Apr 2026 13:23:44 +0800 Subject: [PATCH] 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) --- .env.example | 53 +- docs/ui-clone/01-requirements.md | 568 +++++++++++ docs/ui-clone/02-ui-design-spec.md | 897 ++++++++++++++++++ docs/ui-clone/03-server-architecture.md | 272 ++++++ .../__tests__/e2e/business-flow.test.ts | 829 ++++++++++++++++ src/server/__tests__/e2e/full-flow.test.ts | 351 +++++++ src/server/__tests__/scheduled-tasks.test.ts | 336 +++++++ src/server/__tests__/sessions.test.ts | 548 +++++++++++ src/server/__tests__/settings.test.ts | 376 ++++++++ src/server/api/agents.ts | 139 +++ src/server/api/conversations.ts | 147 +++ src/server/api/models.ts | 149 +++ src/server/api/scheduled-tasks.ts | 79 ++ src/server/api/search.ts | 67 ++ src/server/api/sessions.ts | 158 +++ src/server/api/settings.ts | 121 +++ src/server/api/status.ts | 140 +++ src/server/index.ts | 88 ++ src/server/middleware/auth.ts | 42 + src/server/middleware/cors.ts | 17 + src/server/middleware/errorHandler.ts | 45 + src/server/router.ts | 63 ++ src/server/services/agentService.ts | 240 +++++ src/server/services/cronService.ts | 142 +++ src/server/services/searchService.ts | 348 +++++++ src/server/services/sessionService.ts | 514 ++++++++++ src/server/services/settingsService.ts | 151 +++ src/server/ws/events.ts | 59 ++ src/server/ws/handler.ts | 136 +++ 29 files changed, 7025 insertions(+), 50 deletions(-) create mode 100644 docs/ui-clone/01-requirements.md create mode 100644 docs/ui-clone/02-ui-design-spec.md create mode 100644 docs/ui-clone/03-server-architecture.md create mode 100644 src/server/__tests__/e2e/business-flow.test.ts create mode 100644 src/server/__tests__/e2e/full-flow.test.ts create mode 100644 src/server/__tests__/scheduled-tasks.test.ts create mode 100644 src/server/__tests__/sessions.test.ts create mode 100644 src/server/__tests__/settings.test.ts create mode 100644 src/server/api/agents.ts create mode 100644 src/server/api/conversations.ts create mode 100644 src/server/api/models.ts create mode 100644 src/server/api/scheduled-tasks.ts create mode 100644 src/server/api/search.ts create mode 100644 src/server/api/sessions.ts create mode 100644 src/server/api/settings.ts create mode 100644 src/server/api/status.ts create mode 100644 src/server/index.ts create mode 100644 src/server/middleware/auth.ts create mode 100644 src/server/middleware/cors.ts create mode 100644 src/server/middleware/errorHandler.ts create mode 100644 src/server/router.ts create mode 100644 src/server/services/agentService.ts create mode 100644 src/server/services/cronService.ts create mode 100644 src/server/services/searchService.ts create mode 100644 src/server/services/sessionService.ts create mode 100644 src/server/services/settingsService.ts create mode 100644 src/server/ws/events.ts create mode 100644 src/server/ws/handler.ts diff --git a/.env.example b/.env.example index 99f367cc..a28d1a43 100644 --- a/.env.example +++ b/.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 diff --git a/docs/ui-clone/01-requirements.md b/docs/ui-clone/01-requirements.md new file mode 100644 index 00000000..d2a2744d --- /dev/null +++ b/docs/ui-clone/01-requirements.md @@ -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 + foregroundedTaskId?: string + viewingAgentTaskId?: string + + // MCP 服务器 + mcp: { + clients: MCPConnection[] + tools: Tool[] + commands: Command[] + } + + // 团队协作 + teamContext?: { + teamName: string + teammates: Record + 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` | diff --git a/docs/ui-clone/02-ui-design-spec.md b/docs/ui-clone/02-ui-design-spec.md new file mode 100644 index 00000000..98554e6a --- /dev/null +++ b/docs/ui-clone/02-ui-design-spec.md @@ -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 | diff --git a/docs/ui-clone/03-server-architecture.md b/docs/ui-clone/03-server-architecture.md new file mode 100644 index 00000000..7ec3a49c --- /dev/null +++ b/docs/ui-clone/03-server-architecture.md @@ -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 + + // 获取会话消息 + async getSessionMessages(sessionId: string): Promise + + // 创建新会话 + async createSession(workDir: string): Promise + + // 删除会话 + async deleteSession(sessionId: string): Promise + + // 重命名会话 + async renameSession(sessionId: string, title: string): Promise +} +``` + +**关键实现**: +- `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 +- 设置读写 +- 搜索功能 diff --git a/src/server/__tests__/e2e/business-flow.test.ts b/src/server/__tests__/e2e/business-flow.test.ts new file mode 100644 index 00000000..7192c9cf --- /dev/null +++ b/src/server/__tests__/e2e/business-flow.test.ts @@ -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 +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((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((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((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((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((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') + }) +}) diff --git a/src/server/__tests__/e2e/full-flow.test.ts b/src/server/__tests__/e2e/full-flow.test.ts new file mode 100644 index 00000000..8335c842 --- /dev/null +++ b/src/server/__tests__/e2e/full-flow.test.ts @@ -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 +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((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) + }) +}) diff --git a/src/server/__tests__/scheduled-tasks.test.ts b/src/server/__tests__/scheduled-tasks.test.ts new file mode 100644 index 00000000..ac6daab5 --- /dev/null +++ b/src/server/__tests__/scheduled-tasks.test.ts @@ -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 { + 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 { + 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() + 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 + + 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) + }) +}) diff --git a/src/server/__tests__/sessions.test.ts b/src/server/__tests__/sessions.test.ts new file mode 100644 index 00000000..74074c10 --- /dev/null +++ b/src/server/__tests__/sessions.test.ts @@ -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 { + 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 { + 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[] +): Promise { + 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 { + 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 { + 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 { + 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 { + return { + parentUuid: null, + isSidechain: false, + type: 'user', + message: { role: 'user', content: 'internal' }, + 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 | 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') + }) +}) diff --git a/src/server/__tests__/settings.test.ts b/src/server/__tests__/settings.test.ts new file mode 100644 index 00000000..0517af6b --- /dev/null +++ b/src/server/__tests__/settings.test.ts @@ -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, +): { 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) + }) +}) diff --git a/src/server/api/agents.ts b/src/server/api/agents.ts new file mode 100644 index 00000000..5e9f514a --- /dev/null +++ b/src/server/api/agents.ts @@ -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 { + 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 { + 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) + 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 { + 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> { + try { + return (await req.json()) as Record + } catch { + throw ApiError.badRequest('Invalid JSON body') + } +} diff --git a/src/server/api/conversations.ts b/src/server/api/conversations.ts new file mode 100644 index 00000000..87bc1baf --- /dev/null +++ b/src/server/api/conversations.ts @@ -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() + +export async function handleConversationsApi( + req: Request, + url: URL, + segments: string[] +): Promise { + 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 { + // 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' +} diff --git a/src/server/api/models.ts b/src/server/api/models.ts new file mode 100644 index 00000000..668df954 --- /dev/null +++ b/src/server/api/models.ts @@ -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 { + 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 { + 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 { + 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> { + try { + return (await req.json()) as Record + } catch { + throw ApiError.badRequest('Invalid JSON body') + } +} + +function methodNotAllowed(method: string): ApiError { + return new ApiError(405, `Method ${method} not allowed`, 'METHOD_NOT_ALLOWED') +} diff --git a/src/server/api/scheduled-tasks.ts b/src/server/api/scheduled-tasks.ts new file mode 100644 index 00000000..13de0f2f --- /dev/null +++ b/src/server/api/scheduled-tasks.ts @@ -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 { + 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> { + try { + return (await req.json()) as Record + } catch { + throw ApiError.badRequest('Invalid JSON body') + } +} diff --git a/src/server/api/search.ts b/src/server/api/search.ts new file mode 100644 index 00000000..1c889bc0 --- /dev/null +++ b/src/server/api/search.ts @@ -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 { + 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> { + try { + return (await req.json()) as Record + } catch { + throw ApiError.badRequest('Invalid JSON body') + } +} diff --git a/src/server/api/sessions.ts b/src/server/api/sessions.ts new file mode 100644 index 00000000..b284a249 --- /dev/null +++ b/src/server/api/sessions.ts @@ -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 { + 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 { + 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 { + 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 { + const messages = await sessionService.getSessionMessages(sessionId) + return Response.json({ messages }) +} + +async function createSession(req: Request): Promise { + 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 { + await sessionService.deleteSession(sessionId) + return Response.json({ ok: true }) +} + +async function patchSession(req: Request, sessionId: string): Promise { + 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 }) +} diff --git a/src/server/api/settings.ts b/src/server/api/settings.ts new file mode 100644 index 00000000..0463185d --- /dev/null +++ b/src/server/api/settings.ts @@ -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 { + 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 { + 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 { + 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 { + 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> { + try { + return (await req.json()) as Record + } catch { + throw ApiError.badRequest('Invalid JSON body') + } +} + +function methodNotAllowed(method: string): ApiError { + return new ApiError(405, `Method ${method} not allowed`, 'METHOD_NOT_ALLOWED') +} diff --git a/src/server/api/status.ts b/src/server/api/status.ts new file mode 100644 index 00000000..c91b753c --- /dev/null +++ b/src/server/api/status.ts @@ -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 { + 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 { + 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 { + const projectsDir = path.join(configDir, 'projects') + try { + const entries = await fs.readdir(projectsDir) + return entries.filter((e) => !e.startsWith('.')) + } catch { + return [] + } +} diff --git a/src/server/index.ts b/src/server/index.ts new file mode 100644 index 00000000..34b1e439 --- /dev/null +++ b/src/server/index.ts @@ -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({ + 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() +} diff --git a/src/server/middleware/auth.ts b/src/server/middleware/auth.ts new file mode 100644 index 00000000..970dd116 --- /dev/null +++ b/src/server/middleware/auth.ts @@ -0,0 +1,42 @@ +/** + * Authentication middleware + * + * 本地桌面应用场景下,使用 Anthropic API Key 做简单鉴权。 + * 验证请求头中的 Authorization: Bearer 与 .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 ' } + } + + 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 +} diff --git a/src/server/middleware/cors.ts b/src/server/middleware/cors.ts new file mode 100644 index 00000000..7bdf1c42 --- /dev/null +++ b/src/server/middleware/cors.ts @@ -0,0 +1,17 @@ +/** + * CORS middleware for local desktop app communication + */ + +export function corsHeaders(origin?: string | null): Record { + // 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', + } +} diff --git a/src/server/middleware/errorHandler.ts b/src/server/middleware/errorHandler.ts new file mode 100644 index 00000000..485ce66e --- /dev/null +++ b/src/server/middleware/errorHandler.ts @@ -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 } + ) +} diff --git a/src/server/router.ts b/src/server/router.ts new file mode 100644 index 00000000..9a5601c1 --- /dev/null +++ b/src/server/router.ts @@ -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 { + 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 } + ) + } +} diff --git a/src/server/services/agentService.ts b/src/server/services/agentService.ts new file mode 100644 index 00000000..fd19da4c --- /dev/null +++ b/src/server/services/agentService.ts @@ -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 { + 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 { + const filePath = await this.findAgentFile(name) + if (!filePath) return null + return this.loadAgentFile(filePath) + } + + /** 创建 Agent */ + async createAgent(agent: AgentDefinition): Promise { + 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, + ): Promise { + 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 { + 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 { + 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 { + 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 + 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 + 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, + 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 { + const ext = path.extname(filePath) + + // 构建 frontmatter/YAML 数据(不含 systemPrompt,它在 .md 中放 body) + const data: Record = { 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, '') + } +} diff --git a/src/server/services/cronService.ts b/src/server/services/cronService.ts new file mode 100644 index 00000000..d6c51508 --- /dev/null +++ b/src/server/services/cronService.ts @@ -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 { + const data = await this.readTasksFile() + return data.tasks + } + + /** 创建新任务 */ + async createTask( + task: Omit, + ): Promise { + 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): Promise { + 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 { + 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 { + 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 { + 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}`, + ) + } + } +} diff --git a/src/server/services/searchService.ts b/src/server/services/searchService.ts new file mode 100644 index 00000000..a3edad2d --- /dev/null +++ b/src/server/services/searchService.ts @@ -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 { + 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 { + 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 + 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 { + 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 + 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 { + 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 { + 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 { + 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 { + 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 + } +} diff --git a/src/server/services/sessionService.ts b/src/server/services/sessionService.ts new file mode 100644 index 00000000..8253edc1 --- /dev/null +++ b/src/server/services/sessionService.ts @@ -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 { + 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): Promise { + 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) => 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) => 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) => 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 { + 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 { + 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 { + 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 { + 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() diff --git a/src/server/services/settingsService.ts b/src/server/services/settingsService.ts new file mode 100644 index 00000000..19dc6482 --- /dev/null +++ b/src/server/services/settingsService.ts @@ -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> { + try { + const raw = await fs.readFile(filePath, 'utf-8') + return JSON.parse(raw) as Record + } 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> { + 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> { + return this.readJsonFile(this.getUserSettingsPath()) + } + + /** 获取项目级设置 */ + async getProjectSettings(projectRoot?: string): Promise> { + return this.readJsonFile(this.getProjectSettingsPath(projectRoot)) + } + + // --------------------------------------------------------------------------- + // 写入(原子写入:先写临时文件,再 rename) + // --------------------------------------------------------------------------- + + /** 原子写入 JSON 文件 */ + private async writeJsonFile( + filePath: string, + data: Record, + ): Promise { + 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): Promise { + const current = await this.getUserSettings() + const merged = Object.assign({}, current, settings) + await this.writeJsonFile(this.getUserSettingsPath(), merged) + } + + /** 更新项目级设置(浅合并) */ + async updateProjectSettings( + settings: Record, + projectRoot?: string, + ): Promise { + 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 { + const settings = await this.getUserSettings() + return (settings.defaultMode as string) || 'default' + } + + /** 设置权限模式 */ + async setPermissionMode(mode: string): Promise { + 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 }) + } +} diff --git a/src/server/ws/events.ts b/src/server/ws/events.ts new file mode 100644 index 00000000..b85c5f12 --- /dev/null +++ b/src/server/ws/events.ts @@ -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 +} diff --git a/src/server/ws/handler.ts b/src/server/ws/handler.ts new file mode 100644 index 00000000..cbc5136f --- /dev/null +++ b/src/server/ws/handler.ts @@ -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>() + +export const handleWebSocket = { + open(ws: ServerWebSocket) { + 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, 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, code: number, reason: string) { + const { sessionId } = ws.data + console.log(`[WS] Client disconnected from session: ${sessionId} (${code}: ${reason})`) + activeSessions.delete(sessionId) + }, + + drain(ws: ServerWebSocket) { + // Backpressure handling - called when the socket is ready to receive more data + }, +} + +// ============================================================================ +// Message handlers (to be implemented by conversationService) +// ============================================================================ + +async function handleUserMessage( + ws: ServerWebSocket, + message: Extract +) { + 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, + message: Extract +) { + // TODO: Forward permission response to the active tool execution + console.log(`[WS] Permission response for ${message.requestId}: ${message.allowed}`) +} + +function handleStopGeneration(ws: ServerWebSocket) { + // 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, message: ServerMessage) { + ws.send(JSON.stringify(message)) +} + +function sendError(ws: ServerWebSocket, 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()) +}