diff --git a/apps/site/content/docs/en/cli-tools.mdx b/apps/site/content/docs/en/cli-tools.mdx new file mode 100644 index 0000000..57dd475 --- /dev/null +++ b/apps/site/content/docs/en/cli-tools.mdx @@ -0,0 +1,109 @@ +--- +title: CLI Tools +description: Manage system CLI tools so Claude can automatically detect and use command-line capabilities on your machine. +--- + +# CLI Tools + +Many AI workflows require command-line tools — FFmpeg for video processing, jq for JSON parsing, ripgrep for code search. CodePilot's CLI Tools feature helps you manage these tools and lets Claude automatically know what's available on your machine. + +## Why This Matters + +When you tell Claude "convert this video to MP4," it needs to know whether FFmpeg is installed on your system. Without that knowledge, it can only offer generic advice. With it, Claude can give you a ready-to-run command. + +The CLI Tools feature does three things: + +1. **Detect** — Automatically scans your system for installed command-line tools +2. **Recommend** — Offers a curated list of useful tools with one-click installation +3. **Awareness** — Automatically tells Claude what tools you have during conversations, so it gives more precise answers + +## Opening the Tool Manager + +Click the **CLI Tools** icon (terminal icon) in the left navigation rail. + +The page has two sections: + +- **Installed** — Tools detected on your system, showing version and status +- **Recommended** — Curated tools commonly used in AI workflows, ready to install + +## Installing Tools + +Find the tool you need in the Recommended section and click **Install**. + +If a tool supports multiple installation methods (e.g., Homebrew, npm), you'll be asked to choose. The installation progress shows real-time terminal output so you can see exactly what's happening. + +Currently recommended tools: + +| Tool | Purpose | Install via | +|------|---------|------------| +| FFmpeg | Audio/video transcoding, trimming, merging | Homebrew | +| jq | JSON data parsing and transformation | Homebrew | +| ripgrep | Ultra-fast text search (much faster than grep) | Homebrew | +| yt-dlp | Video downloading | Homebrew / pipx | +| pandoc | Document format conversion (Markdown, Word, PDF) | Homebrew | + +## Viewing Tool Details + +Click the **Details** button on a tool card to see: + +- **Introduction** — What the tool does +- **Use Cases** — Most common scenarios +- **Setup Guide** — Steps from installation to first use +- **Example Prompts** — Ready-to-use prompts you can copy into a conversation + +Example prompts are the most practical part. For FFmpeg, you might see: + +> "Convert input.mov to MP4 format, keeping original quality" + +Click the copy button or "Send to Chat" to start a conversation with that prompt immediately. + +## AI-Enhanced Descriptions + +Installed tools support AI-generated detailed descriptions. Click the **Auto Describe** button on a tool card, and Claude will generate a bilingual description based on the tool's name and capabilities. + +The description is saved locally and will persist across sessions. + +## Using Tools in Conversations + +### Automatic Awareness (Recommended) + +After installing tools, you don't need to do anything extra. CodePilot automatically detects your installed tools before each conversation and includes them in the system prompt. + +This means when you say: + +> "Convert all .mov files in this directory to .mp4" + +Claude knows you have FFmpeg and gives you a ready-to-run command, instead of first asking "Do you have FFmpeg installed?" + +### Manual Tool Selection + +If you want to explicitly tell Claude to use a specific tool, click the **terminal icon** in the chat input toolbar to open the CLI tool picker. + +After selecting a tool: + +- If the input is empty, it auto-fills a guide phrase like "I want to use FFmpeg to: " — just add your specific request +- If there's already text in the input, a tool badge is attached to the message, and Claude will prioritize that tool in its response + +## Best Practices + +### Describe the Goal, Not the Command + +Less effective: +> "Run ffmpeg -i input.mov -c:v libx264 output.mp4" + +More effective: +> "Convert input.mov to MP4, keep the quality, minimize file size" + +Let Claude choose the optimal parameters. It knows FFmpeg's encoding options better than most people. + +### Combine Multiple Tools + +CLI tools work great together. For example: + +> "Download the video from this YouTube link, trim the first 30 seconds, and convert to GIF" + +If you have yt-dlp and FFmpeg installed, Claude will chain them together into a complete workflow. + +### Start with Example Prompts + +Not sure how to use a tool? Open its detail page and start with the example prompts. They cover the most common use cases and are the fastest way to get started. diff --git a/apps/site/content/docs/en/meta.json b/apps/site/content/docs/en/meta.json index 7b2468a..d4970ca 100644 --- a/apps/site/content/docs/en/meta.json +++ b/apps/site/content/docs/en/meta.json @@ -8,6 +8,7 @@ "providers", "mcp", "skills", + "cli-tools", "bridge", "assistant-workspace", "design-agent", diff --git a/apps/site/content/docs/zh/cli-tools.mdx b/apps/site/content/docs/zh/cli-tools.mdx new file mode 100644 index 0000000..ec31f50 --- /dev/null +++ b/apps/site/content/docs/zh/cli-tools.mdx @@ -0,0 +1,109 @@ +--- +title: CLI 工具 +description: 管理系统 CLI 工具,让 Claude 自动识别并使用你电脑上的命令行能力。 +--- + +# CLI 工具 + +很多 AI 工作流需要配合命令行工具——用 FFmpeg 处理视频、用 jq 解析 JSON、用 ripgrep 搜索代码。CodePilot 的 CLI 工具功能帮你管理这些工具,并让 Claude 自动知道你的电脑上有什么可用。 + +## 为什么需要这个功能? + +当你跟 Claude 说"帮我把这个视频转成 MP4",Claude 需要知道你的系统上是否安装了 FFmpeg。如果不知道,它只能给出通用建议;如果知道,它可以直接给你一条可以执行的命令。 + +CLI 工具功能做了三件事: + +1. **检测** — 自动扫描你系统上已安装的命令行工具 +2. **推荐** — 提供精选工具列表,一键安装 +3. **感知** — 在对话中自动告诉 Claude 你有哪些工具,让它给出更精准的回答 + +## 打开工具管理 + +点击左侧导航栏的 **CLI Tools** 图标(终端图标)进入管理页面。 + +页面分为两个区域: + +- **已安装** — 系统上检测到的工具,显示版本号和状态 +- **推荐** — 精选的常用 AI 工作流工具,可以直接安装 + +## 安装工具 + +在推荐区找到你需要的工具,点击 **安装** 按钮。 + +如果工具支持多种安装方式(如 Homebrew、npm),会先让你选择。安装过程中会实时显示终端日志,你可以看到完整的安装输出。 + +目前推荐的工具包括: + +| 工具 | 用途 | 安装方式 | +|------|------|---------| +| FFmpeg | 音视频转码、剪辑、合并 | Homebrew | +| jq | JSON 数据解析和转换 | Homebrew | +| ripgrep | 极速文本搜索(比 grep 快得多) | Homebrew | +| yt-dlp | 视频下载 | Homebrew / pipx | +| pandoc | 文档格式转换(Markdown、Word、PDF 互转) | Homebrew | + +## 查看工具详情 + +点击工具卡片上的 **详情** 按钮,可以看到: + +- **工具简介** — 这个工具能做什么 +- **适用场景** — 最常见的使用方式 +- **操作引导** — 从安装到上手的步骤 +- **示例提示词** — 可以直接复制到对话中使用的提示词 + +示例提示词是这个功能最实用的部分。比如 FFmpeg 的示例提示词: + +> "把 input.mov 转换成 MP4 格式,保持原始质量" + +点击旁边的复制按钮或"发送到聊天"按钮,就可以直接用这个提示词开始对话。 + +## AI 自动完善介绍 + +已安装的工具支持用 AI 生成更详细的介绍。点击工具卡片上的 **自动完善** 按钮,Claude 会根据工具的名称和用途生成一段中英双语的详细描述。 + +这个描述会保存在本地,下次打开时自动显示。 + +## 在对话中使用 + +### 自动感知(推荐) + +安装好工具后,不需要做任何额外操作。CodePilot 会在每次对话时自动检测你的已安装工具,并在 system prompt 中告诉 Claude。 + +这意味着当你说: + +> "帮我把这个目录下所有的 .mov 文件转成 .mp4" + +Claude 会知道你有 FFmpeg,直接给出可执行的命令,而不是先问你"是否安装了 FFmpeg"。 + +### 手动选择工具 + +如果你想明确告诉 Claude 使用某个特定工具,可以在聊天输入框的工具栏中点击 **终端图标**,打开 CLI 工具选择器。 + +选择一个工具后: + +- 如果输入框为空,会自动预填一段引导文字,比如"我想用 FFmpeg 工具完成:" — 你只需补充具体需求 +- 如果输入框已有内容,会在消息上附加一个工具标记,Claude 会优先使用这个工具来回答 + +## 最佳实践 + +### 描述需求而不是命令 + +不好的用法: +> "运行 ffmpeg -i input.mov -c:v libx264 output.mp4" + +好的用法: +> "把 input.mov 转成 MP4,画质保持不变,文件尽量小" + +让 Claude 来选择最优的参数组合。它比大多数人更了解 FFmpeg 的编码选项。 + +### 组合多个工具 + +CLI 工具之间可以组合使用。比如: + +> "从这个 YouTube 链接下载视频,然后裁剪前 30 秒,转成 GIF" + +如果你安装了 yt-dlp 和 FFmpeg,Claude 会把它们串联起来,给你一个完整的工作流。 + +### 用示例提示词起步 + +不确定怎么用一个工具?打开它的详情页,从示例提示词开始。这些提示词覆盖了最常见的使用场景,是快速上手的好方法。 diff --git a/apps/site/content/docs/zh/meta.json b/apps/site/content/docs/zh/meta.json index c0e40a4..ecdd442 100644 --- a/apps/site/content/docs/zh/meta.json +++ b/apps/site/content/docs/zh/meta.json @@ -8,6 +8,7 @@ "providers", "mcp", "skills", + "cli-tools", "bridge", "assistant-workspace", "design-agent", diff --git a/docs/handover/README.md b/docs/handover/README.md index 9cefb10..6193f2f 100644 --- a/docs/handover/README.md +++ b/docs/handover/README.md @@ -12,3 +12,4 @@ | bridge-system.md | 多 IM 远程桥接系统架构(目录结构、数据流、设计决策) | | assistant-workspace.md | 助理工作区:人格/记忆文件、对话式引导、自动触发、确定性落盘 | | theme-system.md | 主题家族系统:两层架构、JSON schema、代码高亮三条渲染链、12 个主题清单 | +| cli-tools.md | CLI 工具管理:静态 catalog、系统检测、一键安装、AI 描述、聊天上下文注入、输入框选择器 | diff --git a/docs/handover/cli-tools.md b/docs/handover/cli-tools.md new file mode 100644 index 0000000..175071c --- /dev/null +++ b/docs/handover/cli-tools.md @@ -0,0 +1,139 @@ +# CLI Tools — 系统 CLI 工具管理与聊天感知 + +## 核心思路 + +AI 工作流中 CLI 工具(ffmpeg、jq、ripgrep 等)是重要基础设施,但用户往往不知道装什么、怎么装。本功能在侧边栏新增 "CLI Tools" 页面,提供精选工具推荐、一键安装、AI 补全描述,并在聊天时自动将已安装工具注入 system prompt,让 Claude 知道用户系统上有哪些工具可用。 + +## 目录结构 + +``` +src/lib/ +├── cli-tools-catalog.ts # 静态精选 catalog(6 个核心 + EXTRA_WELL_KNOWN_BINS) +├── cli-tools-detect.ts # 系统检测逻辑(which/where + --version) +├── cli-tools-context.ts # 聊天上下文构建(system prompt 注入块) + +src/app/api/cli-tools/ +├── catalog/route.ts # GET — 返回完整 catalog 列表 +├── installed/route.ts # GET — 检测并返回已安装工具的 runtime info +└── [id]/ + ├── status/route.ts # GET — 单个工具状态 + 版本 + ├── install/route.ts # POST — SSE 流式安装日志 + ├── detail/route.ts # GET — 详情弹窗数据 + └── describe/route.ts # POST — AI 生成双语工具描述 + +src/components/cli-tools/ +├── CliToolsManager.tsx # 主管理容器(已安装区 + 推荐区) +├── CliToolCard.tsx # 工具卡片(installed / recommended 两种 variant) +├── CliToolDetailDialog.tsx # 详情弹窗(简介 / 场景 / 引导 / 示例提示词) +├── CliToolInstallDialog.tsx # SSE 安装进度弹窗 +├── CliToolBatchDescribeDialog.tsx # 批量 AI 描述生成 +└── CliToolExtraDetailDialog.tsx # 额外检测工具的详情弹窗 + +src/app/cli-tools/page.tsx # 页面入口 +``` + +## 数据流 + +### 工具检测 + +``` +页面加载 → CliToolsManager 并行请求: + GET /api/cli-tools/catalog → CLI_TOOLS_CATALOG(静态数据) + GET /api/cli-tools/installed → detectAllCliTools() + → 遍历 catalog binNames + EXTRA_WELL_KNOWN_BINS + → which/where 查找 → --version 提取版本 + → 模块级缓存(TTL 2 分钟) +→ 合并 catalog + runtime info → 分区渲染 +``` + +### 工具安装 + +``` +用户点击"安装" → 选择安装方式(brew/npm/pipx) + → POST /api/cli-tools/[id]/install { method } + → 服务端 spawn 子进程执行 catalog 中声明的 command + → SSE 流式返回 stdout/stderr + → CliToolInstallDialog 实时显示日志 + → 完成后重新检测工具状态 +``` + +安全约束:只执行 catalog 中声明的 command,不接受用户自定义命令。 + +### AI 描述生成 + +``` +用户点击"自动完善介绍" → POST /api/cli-tools/[id]/describe { providerId } + → 校验 tool.supportsAutoDescribe === true + → generateTextViaSdk() 生成中英双语介绍(60s 超时) + → 返回 { zh, en } 文本 + → 前端存入 localStorage(key: cli-tool-desc-{id}) + → 刷新后从 localStorage 恢复 +``` + +### 聊天上下文注入 + +``` +用户发送消息 → POST /api/chat + → buildCliToolsContext() + → detectAllCliTools()(命中缓存则直接返回) + → 格式化已安装工具为 XML 块 + → 追加到 system prompt 末尾 + → Claude 获知用户系统上可用的 CLI 工具 +``` + +### 聊天侧 CLI 选择器 + +``` +聊天输入框工具栏 → 点击 Terminal 图标 → popoverMode = 'cli' + → 异步 fetch /api/cli-tools/installed + /api/cli-tools/catalog + → 搜索框过滤 → 选择工具 + → 若输入框为空:预填 "我想用 {tool} 工具完成:" (zh) / "I want to use {tool} to: " (en) + → 若输入框有内容:附加 CliBadge { id, name } + → 发送时 CliBadge → systemPromptAppend 注入到 system prompt(不显示在对话中) +``` + +## 类型定义 + +关键类型在 `src/types/index.ts`: + +| 类型 | 用途 | +|------|------| +| `CliToolStatus` | `'not_installed' \| 'installed' \| 'needs_auth' \| 'ready'` | +| `CliToolCategory` | `'media' \| 'data' \| 'search' \| 'download' \| 'document' \| 'productivity'` | +| `InstallMethod` | `'brew' \| 'npm' \| 'pipx' \| 'cargo'` | +| `CliToolDefinition` | 完整的工具定义(名称、binNames、摘要、分类、安装方式、详情、示例提示词) | +| `CliToolRuntimeInfo` | 运行时检测结果(状态、版本、路径、AI 描述) | +| `CliToolInstallMethod` | 包含 `method`、`command`、`platforms` | + +## 设计决策 + +### 为什么用静态 catalog 而不是动态发现? + +安全性——只允许执行预审过的安装命令。可预测性——工具列表、描述、示例提示词都是策划过的内容,质量可控。 + +### 为什么检测用 which 而不是直接运行? + +Electron 桌面环境中 PATH 往往不完整。通过 `getExpandedPath()`(`src/lib/platform.ts`)展开 `/usr/local/bin`、`~/.cargo/bin` 等常见路径后传给 `which`,兼容桌面和终端两种启动方式。 + +### 为什么 AI 描述存 localStorage 而不是 DB? + +描述是锦上添花,不是核心数据。避免 schema 迁移负担,丢失可重新生成。 + +### 聊天侧 CLI 选择器的 popover 模式 + +采用 `PopoverMode = 'file' | 'skill' | 'cli' | null` 枚举,CLI 是 button-triggered(点击工具栏图标触发),与 skill 的 text-triggered(输入 `/` 触发)不同。button-triggered 弹窗的焦点策略是将焦点交给弹窗内的搜索框,而 text-triggered 弹窗焦点保留在 textarea。 + +### EXTRA_WELL_KNOWN_BINS + +除 catalog 中的 6 个精选工具外,`cli-tools-catalog.ts` 还导出 `EXTRA_WELL_KNOWN_BINS` 数组——常见但不需要详情页的工具(如 python、node、go、docker 等),用于聊天上下文注入,让 Claude 知道系统上还有哪些工具。 + +## 修改文件清单 + +| 文件 | 改动 | +|------|------| +| `src/types/index.ts` | 新增 CLI tool 相关类型定义 | +| `src/components/layout/NavRail.tsx` | navItems 增加 CLI Tools 导航项 | +| `src/i18n/en.ts` | 新增 `nav.cliTools` + `cli_tools.*` 英文 key | +| `src/i18n/zh.ts` | 新增 `nav.cliTools` + `cli_tools.*` 中文 key | +| `src/app/api/chat/route.ts` | system prompt 末尾追加 CLI tools context | +| `src/components/chat/MessageInput.tsx` | CLI 选择器弹窗 + CliBadge + systemPromptAppend | diff --git a/src/__tests__/unit/provider-resolver.test.ts b/src/__tests__/unit/provider-resolver.test.ts index a55354e..63e8343 100644 --- a/src/__tests__/unit/provider-resolver.test.ts +++ b/src/__tests__/unit/provider-resolver.test.ts @@ -479,7 +479,7 @@ describe('Provider Resolver', () => { const config = toAiSdkConfig(resolved); assert.equal(config.sdkType, 'anthropic'); assert.equal(config.apiKey, 'key'); - assert.equal(config.baseUrl, 'https://api.anthropic.com'); + assert.equal(config.baseUrl, 'https://api.anthropic.com/v1'); assert.equal(config.modelId, 'sonnet'); assert.deepEqual(config.processEnvInjections, {}); }); diff --git a/src/app/api/chat/route.ts b/src/app/api/chat/route.ts index 9f15dbc..0776417 100644 --- a/src/app/api/chat/route.ts +++ b/src/app/api/chat/route.ts @@ -304,6 +304,17 @@ Start by greeting the user and asking the first question. finalSystemPrompt = (finalSystemPrompt || '') + '\n\n' + assistantProjectInstructions; } + // Inject available CLI tools context (best-effort, non-blocking) + try { + const { buildCliToolsContext } = await import('@/lib/cli-tools-context'); + const cliToolsCtx = await buildCliToolsContext(); + if (cliToolsCtx) { + finalSystemPrompt = (finalSystemPrompt || '') + '\n\n' + cliToolsCtx; + } + } catch { + // CLI tools context injection failed — don't block chat + } + // Load recent conversation history from DB as fallback context. // This is used when SDK session resume is unavailable or fails, // so the model still has conversation context. diff --git a/src/app/api/cli-tools/[id]/describe/route.ts b/src/app/api/cli-tools/[id]/describe/route.ts new file mode 100644 index 0000000..8db20b9 --- /dev/null +++ b/src/app/api/cli-tools/[id]/describe/route.ts @@ -0,0 +1,105 @@ +import { NextResponse } from 'next/server'; +import { CLI_TOOLS_CATALOG, EXTRA_WELL_KNOWN_BINS } from '@/lib/cli-tools-catalog'; +import { generateTextViaSdk } from '@/lib/claude-client'; + +/** + * Try to extract a JSON object from text that may be wrapped in markdown code blocks + * or contain leading/trailing non-JSON content. + */ +function extractJson(raw: string): Record { + // Try direct parse first + const trimmed = raw.trim(); + try { return JSON.parse(trimmed); } catch { /* continue */ } + + // Strip markdown code fences + const fenceMatch = trimmed.match(/```(?:json)?\s*\n?([\s\S]*?)\n?```/); + if (fenceMatch) { + try { return JSON.parse(fenceMatch[1].trim()); } catch { /* continue */ } + } + + // Find first { ... last } + const start = trimmed.indexOf('{'); + const end = trimmed.lastIndexOf('}'); + if (start !== -1 && end > start) { + try { return JSON.parse(trimmed.slice(start, end + 1)); } catch { /* continue */ } + } + + throw new Error('AI response was not valid JSON'); +} + +export async function POST( + request: Request, + { params }: { params: Promise<{ id: string }> } +) { + const { id } = await params; + + // Look up in catalog first, then in extra well-known list + const catalogTool = CLI_TOOLS_CATALOG.find(t => t.id === id); + const extraEntry = !catalogTool ? EXTRA_WELL_KNOWN_BINS.find(([eid]) => eid === id) : null; + + if (!catalogTool && !extraEntry) { + return NextResponse.json({ error: 'Tool not found' }, { status: 404 }); + } + + if (catalogTool && !catalogTool.supportsAutoDescribe) { + return NextResponse.json( + { error: 'Auto-describe not supported for this tool' }, + { status: 400 } + ); + } + + try { + const body = await request.json(); + const { providerId, model: requestModel } = body as { providerId?: string; model?: string }; + + const toolName = catalogTool?.name ?? extraEntry?.[1] ?? id; + const binNames = catalogTool?.binNames.join(', ') ?? extraEntry?.[2] ?? id; + const categories = catalogTool?.categories.join(', ') ?? 'general'; + const homepage = catalogTool?.homepage ?? 'N/A'; + + const prompt = `You are a technical writer. Write a brief, practical description of the CLI tool "${toolName}" (binary: ${binNames}). +Categories: ${categories} +Homepage: ${homepage} + +Provide the description in both Chinese and English. Focus on practical use cases in AI-assisted development workflows. + +Respond in this exact JSON format (no markdown, no code fences, just raw JSON): +{"zh": "中文描述(2-3句话)", "en": "English description (2-3 sentences)"}`; + + let result: string; + try { + result = await generateTextViaSdk({ + providerId: providerId || undefined, + model: requestModel || undefined, + system: 'You are a technical documentation writer. Respond with raw JSON only, no markdown formatting.', + prompt, + }); + } catch (genError) { + console.error(`[cli-tools/${id}/describe] generateTextViaSdk threw:`, genError); + const msg = genError instanceof Error ? genError.message : 'Text generation failed'; + return NextResponse.json({ error: msg }, { status: 502 }); + } + + if (!result || !result.trim()) { + return NextResponse.json( + { error: 'AI returned an empty response. Please check your provider configuration.' }, + { status: 502 } + ); + } + + const parsed = extractJson(result); + + if (!parsed.zh || !parsed.en) { + return NextResponse.json( + { error: 'AI response missing required zh/en fields' }, + { status: 502 } + ); + } + + return NextResponse.json({ description: parsed }); + } catch (error) { + console.error(`[cli-tools/${id}/describe] Error:`, error); + const message = error instanceof Error ? error.message : 'Description generation failed'; + return NextResponse.json({ error: message }, { status: 500 }); + } +} diff --git a/src/app/api/cli-tools/[id]/detail/route.ts b/src/app/api/cli-tools/[id]/detail/route.ts new file mode 100644 index 0000000..450b337 --- /dev/null +++ b/src/app/api/cli-tools/[id]/detail/route.ts @@ -0,0 +1,25 @@ +import { NextResponse } from 'next/server'; +import { CLI_TOOLS_CATALOG } from '@/lib/cli-tools-catalog'; + +export async function GET( + _request: Request, + { params }: { params: Promise<{ id: string }> } +) { + const { id } = await params; + const tool = CLI_TOOLS_CATALOG.find(t => t.id === id); + if (!tool) { + return NextResponse.json({ error: 'Tool not found' }, { status: 404 }); + } + + return NextResponse.json({ + id: tool.id, + name: tool.name, + detailIntro: tool.detailIntro, + useCases: tool.useCases, + guideSteps: tool.guideSteps, + examplePrompts: tool.examplePrompts, + homepage: tool.homepage, + repoUrl: tool.repoUrl, + officialDocsUrl: tool.officialDocsUrl, + }); +} diff --git a/src/app/api/cli-tools/[id]/install/route.ts b/src/app/api/cli-tools/[id]/install/route.ts new file mode 100644 index 0000000..3dae108 --- /dev/null +++ b/src/app/api/cli-tools/[id]/install/route.ts @@ -0,0 +1,100 @@ +import { NextResponse } from 'next/server'; +import { spawn } from 'child_process'; +import { CLI_TOOLS_CATALOG } from '@/lib/cli-tools-catalog'; +import { invalidateDetectCache } from '@/lib/cli-tools-detect'; +import { getExpandedPath } from '@/lib/platform'; + +export async function POST( + request: Request, + { params }: { params: Promise<{ id: string }> } +) { + const { id } = await params; + const tool = CLI_TOOLS_CATALOG.find(t => t.id === id); + if (!tool) { + return NextResponse.json({ error: 'Tool not found' }, { status: 404 }); + } + + try { + const body = await request.json(); + const { method } = body as { method: string }; + + // Security: only execute commands declared in the catalog + const installMethod = tool.installMethods.find(m => m.method === method); + if (!installMethod) { + return NextResponse.json( + { error: `Install method "${method}" not available for ${tool.name}` }, + { status: 400 } + ); + } + + // Platform check: reject if this method doesn't support the current platform + const currentPlatform = process.platform as 'darwin' | 'linux' | 'win32'; + if (!installMethod.platforms.includes(currentPlatform)) { + return NextResponse.json( + { error: `Install method "${method}" is not supported on ${currentPlatform}` }, + { status: 400 } + ); + } + + // Parse the command into executable and args + const parts = installMethod.command.split(/\s+/); + const cmd = parts[0]; + const args = parts.slice(1); + + const child = spawn(cmd, args, { + env: { ...process.env, PATH: getExpandedPath() }, + shell: true, + }); + + const encoder = new TextEncoder(); + const stream = new ReadableStream({ + start(controller) { + const send = (event: string, data: string) => { + controller.enqueue( + encoder.encode(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`) + ); + }; + + child.stdout?.on('data', (chunk: Buffer) => { + send('output', chunk.toString()); + }); + + child.stderr?.on('data', (chunk: Buffer) => { + send('output', chunk.toString()); + }); + + child.on('close', (code) => { + invalidateDetectCache(); + if (code === 0) { + send('done', 'Install completed successfully'); + } else { + send('error', `Process exited with code ${code}`); + } + controller.close(); + }); + + child.on('error', (err) => { + send('error', err.message); + controller.close(); + }); + }, + cancel() { + child.kill(); + }, + }); + + return new Response(stream, { + headers: { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + Connection: 'keep-alive', + }, + }); + } catch (error) { + console.error(`[cli-tools/${id}/install] Error:`, error); + return NextResponse.json( + { error: error instanceof Error ? error.message : 'Install failed' }, + { status: 500 } + ); + } +} diff --git a/src/app/api/cli-tools/[id]/status/route.ts b/src/app/api/cli-tools/[id]/status/route.ts new file mode 100644 index 0000000..cf6de8f --- /dev/null +++ b/src/app/api/cli-tools/[id]/status/route.ts @@ -0,0 +1,27 @@ +import { NextResponse } from 'next/server'; +import { CLI_TOOLS_CATALOG } from '@/lib/cli-tools-catalog'; +import { detectCliTool } from '@/lib/cli-tools-detect'; + +export const dynamic = 'force-dynamic'; + +export async function GET( + _request: Request, + { params }: { params: Promise<{ id: string }> } +) { + const { id } = await params; + const tool = CLI_TOOLS_CATALOG.find(t => t.id === id); + if (!tool) { + return NextResponse.json({ error: 'Tool not found' }, { status: 404 }); + } + + try { + const info = await detectCliTool(tool); + return NextResponse.json(info); + } catch (error) { + console.error(`[cli-tools/${id}/status] Error:`, error); + return NextResponse.json( + { error: error instanceof Error ? error.message : 'Detection failed' }, + { status: 500 } + ); + } +} diff --git a/src/app/api/cli-tools/catalog/route.ts b/src/app/api/cli-tools/catalog/route.ts new file mode 100644 index 0000000..79d4381 --- /dev/null +++ b/src/app/api/cli-tools/catalog/route.ts @@ -0,0 +1,6 @@ +import { NextResponse } from 'next/server'; +import { CLI_TOOLS_CATALOG } from '@/lib/cli-tools-catalog'; + +export async function GET() { + return NextResponse.json({ tools: CLI_TOOLS_CATALOG }); +} diff --git a/src/app/api/cli-tools/installed/route.ts b/src/app/api/cli-tools/installed/route.ts new file mode 100644 index 0000000..9b6c203 --- /dev/null +++ b/src/app/api/cli-tools/installed/route.ts @@ -0,0 +1,42 @@ +import { NextResponse } from 'next/server'; +import { execFile } from 'child_process'; +import { promisify } from 'util'; +import { detectAllCliTools } from '@/lib/cli-tools-detect'; +import { getExpandedPath } from '@/lib/platform'; + +const execFileAsync = promisify(execFile); + +export const dynamic = 'force-dynamic'; + +async function detectBrew(): Promise { + try { + await execFileAsync('/usr/bin/which', ['brew'], { + timeout: 3000, + env: { ...process.env, PATH: getExpandedPath() }, + }); + return true; + } catch { + return false; + } +} + +export async function GET() { + try { + const [{ catalog, extra }, hasBrew] = await Promise.all([ + detectAllCliTools(), + detectBrew(), + ]); + return NextResponse.json({ + tools: catalog, + extra, + platform: process.platform, + hasBrew, + }); + } catch (error) { + console.error('[cli-tools/installed] Error:', error); + return NextResponse.json( + { error: error instanceof Error ? error.message : 'Detection failed' }, + { status: 500 } + ); + } +} diff --git a/src/app/api/providers/models/route.ts b/src/app/api/providers/models/route.ts index acd096f..eb3eef8 100644 --- a/src/app/api/providers/models/route.ts +++ b/src/app/api/providers/models/route.ts @@ -1,7 +1,7 @@ import { NextResponse } from 'next/server'; -import { getAllProviders, getDefaultProviderId, getModelsForProvider } from '@/lib/db'; +import { getAllProviders, getDefaultProviderId, getModelsForProvider, getSetting } from '@/lib/db'; import { getContextWindow } from '@/lib/model-context'; -import { getDefaultModelsForProvider, inferProtocolFromLegacy } from '@/lib/provider-catalog'; +import { getDefaultModelsForProvider, inferProtocolFromLegacy, findPresetForLegacy } from '@/lib/provider-catalog'; import type { Protocol } from '@/lib/provider-catalog'; import type { ErrorResponse, ProviderModelGroup } from '@/types'; @@ -45,10 +45,19 @@ export async function GET() { const groups: ProviderModelGroup[] = []; // Always show the built-in Claude Code provider group. + // Mark it as sdkProxyOnly if no direct API credentials exist — in that case + // the env provider only works through the Claude Code SDK subprocess, not the + // Vercel AI SDK text generation path used by features like AI Describe. + const envHasDirectCredentials = !!( + process.env.ANTHROPIC_API_KEY || + process.env.ANTHROPIC_AUTH_TOKEN || + getSetting('anthropic_auth_token') + ); groups.push({ provider_id: 'env', provider_name: 'Claude Code', provider_type: 'anthropic', + ...(!envHasDirectCredentials ? { sdkProxyOnly: true } : {}), models: DEFAULT_MODELS.map(m => { const cw = getContextWindow(m.value); return cw != null ? { ...m, contextWindow: cw } : m; @@ -155,10 +164,15 @@ export async function GET() { }; }); + // Detect SDK-proxy-only providers via preset match + const preset = findPresetForLegacy(provider.base_url, provider.provider_type); + const sdkProxyOnly = preset?.sdkProxyOnly === true; + groups.push({ provider_id: provider.id, provider_name: provider.name, provider_type: provider.provider_type, + ...(sdkProxyOnly ? { sdkProxyOnly: true } : {}), models, }); } diff --git a/src/app/cli-tools/page.tsx b/src/app/cli-tools/page.tsx new file mode 100644 index 0000000..0206059 --- /dev/null +++ b/src/app/cli-tools/page.tsx @@ -0,0 +1,13 @@ +"use client"; + +import { CliToolsManager } from "@/components/cli-tools/CliToolsManager"; + +export default function CliToolsPage() { + return ( +
+
+ +
+
+ ); +} diff --git a/src/components/chat/MessageInput.tsx b/src/components/chat/MessageInput.tsx index f21f1ce..ae2c1e0 100644 --- a/src/components/chat/MessageInput.tsx +++ b/src/components/chat/MessageInput.tsx @@ -18,6 +18,8 @@ import { Brain, GlobeSimple, Stop, + Lightning, + Gear, } from "@phosphor-icons/react"; import type { Icon } from "@phosphor-icons/react"; import { cn } from '@/lib/utils'; @@ -36,6 +38,11 @@ import type { ChatStatus } from 'ai'; import type { FileAttachment, ProviderModelGroup, SkillKind } from '@/types'; import { nanoid } from 'nanoid'; import { SlashCommandButton } from './SlashCommandButton'; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from '@/components/ui/tooltip'; import { useImageGen } from '@/hooks/useImageGen'; import { PENDING_KEY, setRefImages, deleteRefImages } from '@/lib/image-ref-store'; @@ -126,7 +133,19 @@ interface CommandBadge { installedSource?: "agents" | "claude"; } -type PopoverMode = 'file' | 'skill' | null; +interface CliBadge { + id: string; + name: string; +} + +interface CliToolItem { + id: string; + name: string; + version: string | null; + summary: string; +} + +type PopoverMode = 'file' | 'skill' | 'cli' | null; // Expansion prompts for CLI-only commands (not natively supported by SDK). // SDK-native commands (/compact, /init, /review) are sent as-is — the SDK handles them directly. @@ -236,7 +255,7 @@ function AttachFileButton() { onClick={() => attachments.openFileDialog()} tooltip={t('messageInput.attachFiles')} > - + ); } @@ -327,7 +346,7 @@ export function MessageInput({ onEffortChange, sdkInitMeta, }: MessageInputProps) { - const { t } = useTranslation(); + const { t, locale } = useTranslation(); const imageGen = useImageGen(); const textareaRef = useRef(null); const popoverRef = useRef(null); @@ -340,8 +359,13 @@ export function MessageInput({ const [selectedIndex, setSelectedIndex] = useState(0); const [triggerPos, setTriggerPos] = useState(null); const [modelMenuOpen, setModelMenuOpen] = useState(false); + const [modelSearch, setModelSearch] = useState(''); const [inputValue, setInputValue] = useState(''); const [badge, setBadge] = useState(null); + const [cliBadge, setCliBadge] = useState(null); + const [cliTools, setCliTools] = useState([]); + const [cliFilter, setCliFilter] = useState(''); + const cliSearchRef = useRef(null); const [providerGroups, setProviderGroups] = useState([]); const [defaultProviderId, setDefaultProviderId] = useState(''); const [aiSuggestions, setAiSuggestions] = useState([]); @@ -525,6 +549,81 @@ export function MessageInput({ return [...BUILT_IN_COMMANDS, ...uniqueSkills]; }, [workingDirectory, sdkInitMeta]); + // Fetch CLI tools for CLI popover + const fetchCliTools = useCallback(async () => { + try { + const [installedRes, catalogRes] = await Promise.all([ + fetch('/api/cli-tools/installed'), + fetch('/api/cli-tools/catalog'), + ]); + const installedData = await installedRes.json(); + const catalogData = await catalogRes.json(); + + const catalogTools = catalogData.tools || []; + const runtimeInfos = installedData.tools || []; + const extraDetected = installedData.extra || []; + + // Build lookup for catalog summaries + const catalogMap = new Map(); + for (const ct of catalogTools) { + catalogMap.set(ct.id, { name: ct.name, summaryZh: ct.summaryZh, summaryEn: ct.summaryEn }); + } + + // Extra well-known names lookup + const extraNames: Record = {}; + try { + const { EXTRA_WELL_KNOWN_BINS } = await import('@/lib/cli-tools-catalog'); + for (const [id, name] of EXTRA_WELL_KNOWN_BINS) { + extraNames[id] = name; + } + } catch { /* ignore */ } + + // Load cached AI descriptions + let autoDesc: Record = {}; + try { + const cached = localStorage.getItem('cli-tools-auto-desc'); + if (cached) autoDesc = JSON.parse(cached); + } catch { /* ignore */ } + + const locale = document.documentElement.lang === 'zh' ? 'zh' : 'en'; + const items: CliToolItem[] = []; + + // Installed catalog tools + for (const ri of runtimeInfos) { + if (ri.status !== 'installed') continue; + const cat = catalogMap.get(ri.id); + const ad = autoDesc[ri.id]; + const summary = ad + ? (locale === 'zh' ? ad.zh : ad.en) + : cat + ? (locale === 'zh' ? cat.summaryZh : cat.summaryEn) + : ''; + items.push({ + id: ri.id, + name: cat?.name || ri.id, + version: ri.version, + summary, + }); + } + + // Extra detected tools + for (const ri of extraDetected) { + const ad = autoDesc[ri.id]; + const summary = ad ? (locale === 'zh' ? ad.zh : ad.en) : ''; + items.push({ + id: ri.id, + name: extraNames[ri.id] || ri.id, + version: ri.version, + summary, + }); + } + + setCliTools(items); + } catch { + setCliTools([]); + } + }, []); + // Close popover const closePopover = useCallback(() => { setPopoverMode(null); @@ -551,6 +650,52 @@ export function MessageInput({ setTimeout(() => textareaRef.current?.focus(), 0); }, []); + // Remove CLI badge + const removeCliBadge = useCallback(() => { + setCliBadge(null); + setTimeout(() => textareaRef.current?.focus(), 0); + }, []); + + // Handle CLI tool selection from popover + const handleCliSelect = useCallback((tool: CliToolItem) => { + closePopover(); + setCliFilter(''); + + if (!inputValue.trim()) { + // Empty input: prefill with prompt template + const prefix = locale === 'zh' + ? `我想用 ${tool.name} 工具完成:` + : `I want to use ${tool.name} to: `; + setInputValue(prefix); + setTimeout(() => { + const ta = textareaRef.current; + if (ta) { + ta.focus(); + ta.selectionStart = ta.selectionEnd = prefix.length; + } + }, 0); + } else { + // Non-empty input: set CLI badge + setCliBadge({ id: tool.id, name: tool.name }); + setTimeout(() => textareaRef.current?.focus(), 0); + } + }, [inputValue, locale, closePopover]); + + // Open CLI popover + const handleOpenCliPopover = useCallback(async () => { + if (popoverMode === 'cli') { + closePopover(); + return; + } + closePopover(); + setPopoverMode('cli'); + setCliFilter(''); + setSelectedIndex(0); + // Focus search input on next render (before fetch completes) + setTimeout(() => cliSearchRef.current?.focus(), 0); + fetchCliTools(); + }, [popoverMode, closePopover, fetchCliTools]); + // Insert selected item const insertItem = useCallback((item: PopoverItem) => { if (triggerPos === null) return; @@ -628,7 +773,8 @@ export function MessageInput({ return; } - if (popoverMode) { + // Only auto-close text-triggered popovers (file/skill); CLI is button-triggered + if (popoverMode && popoverMode !== 'cli') { closePopover(); } }, [fetchFiles, fetchSkills, popoverMode, closePopover]); @@ -782,9 +928,15 @@ export function MessageInput({ } } - onSend(content || 'Please review the attached file(s).', hasFiles ? files : undefined); + // If CLI badge is active, inject systemPromptAppend to guide model + const cliAppend = cliBadge + ? `The user wants to use the installed CLI tool "${cliBadge.name}" if appropriate for this task. Prefer using "${cliBadge.name}" when suitable.` + : undefined; + if (cliBadge) setCliBadge(null); + + onSend(content || 'Please review the attached file(s).', hasFiles ? files : undefined, cliAppend); setInputValue(''); - }, [inputValue, onSend, onImageGenerate, onCommand, disabled, isStreaming, closePopover, badge, imageGen]); + }, [inputValue, onSend, onImageGenerate, onCommand, disabled, isStreaming, closePopover, badge, cliBadge, imageGen]); const filteredItems = popoverItems.filter((item) => { const q = popoverFilter.toLowerCase(); @@ -891,8 +1043,8 @@ export function MessageInput({ const handleKeyDown = useCallback( (e: KeyboardEvent) => { - // Popover navigation - if (popoverMode && popoverItems.length > 0) { + // Popover navigation (skill/file mode) + if (popoverMode && popoverMode !== 'cli' && popoverItems.length > 0) { if (e.key === 'ArrowDown') { e.preventDefault(); setSelectedIndex((prev) => (prev + 1) % allDisplayedItems.length); @@ -917,21 +1069,51 @@ export function MessageInput({ } } + // CLI popover keyboard navigation + if (popoverMode === 'cli') { + if (e.key === 'Escape') { + e.preventDefault(); + closePopover(); + return; + } + if (cliTools.length > 0) { + const q = cliFilter.toLowerCase(); + const filtered = cliTools.filter(t => + t.name.toLowerCase().includes(q) || t.summary.toLowerCase().includes(q) + ); + if (filtered.length > 0) { + if (e.key === 'ArrowDown') { + e.preventDefault(); + setSelectedIndex((prev) => Math.min(prev + 1, filtered.length - 1)); + return; + } + if (e.key === 'ArrowUp') { + e.preventDefault(); + setSelectedIndex((prev) => Math.max(prev - 1, 0)); + return; + } + if (e.key === 'Enter') { + e.preventDefault(); + if (filtered[selectedIndex]) handleCliSelect(filtered[selectedIndex]); + return; + } + } + } + } + // Backspace removes badge when input is empty - if (e.key === 'Backspace' && badge && !inputValue) { - e.preventDefault(); - removeBadge(); - return; + if (e.key === 'Backspace' && !inputValue) { + if (badge) { e.preventDefault(); removeBadge(); return; } + if (cliBadge) { e.preventDefault(); removeCliBadge(); return; } } // Escape removes badge - if (e.key === 'Escape' && badge) { - e.preventDefault(); - removeBadge(); - return; + if (e.key === 'Escape') { + if (badge) { e.preventDefault(); removeBadge(); return; } + if (cliBadge) { e.preventDefault(); removeCliBadge(); return; } } }, - [popoverMode, popoverItems, popoverFilter, selectedIndex, insertItem, closePopover, badge, inputValue, removeBadge, allDisplayedItems] + [popoverMode, popoverItems, popoverFilter, selectedIndex, insertItem, closePopover, badge, cliBadge, inputValue, removeBadge, removeCliBadge, allDisplayedItems, cliTools, cliFilter, handleCliSelect] ); // Click outside to close popover @@ -952,6 +1134,7 @@ export function MessageInput({ const handler = (e: MouseEvent) => { if (modelMenuRef.current && !modelMenuRef.current.contains(e.target as Node)) { setModelMenuOpen(false); + setModelSearch(''); } }; document.addEventListener('mousedown', handler); @@ -1041,7 +1224,7 @@ export function MessageInput({ return (
{popoverMode === 'skill' ? (
@@ -1079,7 +1262,6 @@ export function MessageInput({ } }} className="w-full bg-transparent text-sm text-foreground placeholder:text-muted-foreground outline-none" - autoFocus />
) : ( @@ -1144,6 +1326,105 @@ export function MessageInput({ )}
+ {/* Footer: manage skills (skill mode only) */} + {popoverMode === 'skill' && ( +
+ +
+ )} + + ); + })()} + + {/* CLI Tools Popover */} + {popoverMode === 'cli' && (() => { + const q = cliFilter.toLowerCase(); + const filtered = cliTools.filter(t => + t.name.toLowerCase().includes(q) || t.summary.toLowerCase().includes(q) + ); + + return ( +
+
+ { setCliFilter(e.target.value); setSelectedIndex(0); }} + onKeyDown={(e) => { + if (e.key === 'ArrowDown') { + e.preventDefault(); + setSelectedIndex((prev) => Math.min(prev + 1, filtered.length - 1)); + } else if (e.key === 'ArrowUp') { + e.preventDefault(); + setSelectedIndex((prev) => Math.max(prev - 1, 0)); + } else if (e.key === 'Enter') { + e.preventDefault(); + if (filtered[selectedIndex]) handleCliSelect(filtered[selectedIndex]); + } else if (e.key === 'Escape') { + e.preventDefault(); + closePopover(); + textareaRef.current?.focus(); + } + }} + className="w-full bg-transparent text-sm text-foreground placeholder:text-muted-foreground outline-none" + /> +
+
+ {filtered.length > 0 ? ( + filtered.map((tool, idx) => ( + + )) + ) : ( +
+

{t('cliTools.noToolsDetected' as TranslationKey)}

+ +
+ )} +
+ {/* Footer: manage CLI tools */} +
+ +
); })()} @@ -1176,11 +1457,29 @@ export function MessageInput({ )} + {/* CLI badge */} + {cliBadge && ( +
+ + + CLI: {cliBadge.name} + + +
+ )} {/* File attachment capsules */} handleInputChange(e.currentTarget.value)} onKeyDown={handleKeyDown} @@ -1196,6 +1495,18 @@ export function MessageInput({ {/* Slash command button */} + {/* CLI tools button */} + + + + + + + + {t('cliTools.selectTool' as TranslationKey)} + + + {/* Model selector */}
- {modelMenuOpen && ( -
- {providerGroups.map((group, groupIdx) => ( -
- {/* Group header */} -
0 && "border-t" - )}> - {group.provider_name} -
- {/* Models in group */} -
- {group.models.map((opt) => { - const isActive = opt.value === currentModelValue && group.provider_id === currentProviderIdValue; - return ( - - ); - })} -
+ {modelMenuOpen && (() => { + const mq = modelSearch.toLowerCase(); + const filteredGroups = providerGroups.map(group => ({ + ...group, + models: group.models.filter(opt => + !mq || opt.label.toLowerCase().includes(mq) || group.provider_name.toLowerCase().includes(mq) + ), + })).filter(group => group.models.length > 0); + + return ( +
+ {/* Search */} +
+ setModelSearch(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Escape') { + e.preventDefault(); + setModelMenuOpen(false); + setModelSearch(''); + } + }} + className="w-full bg-transparent text-sm text-foreground placeholder:text-muted-foreground outline-none" + />
- ))} -
- )} + {/* Model list */} +
+ {filteredGroups.map((group, groupIdx) => ( +
+
0 && "border-t" + )}> + {group.provider_name} +
+
+ {group.models.map((opt) => { + const isActive = opt.value === currentModelValue && group.provider_id === currentProviderIdValue; + return ( + + ); + })} +
+
+ ))} + {filteredGroups.length === 0 && ( +
+ No models found +
+ )} +
+ {/* Footer: manage providers */} +
+ +
+
+ ); + })()}
{/* Effort selector — only visible when model supports effort */} @@ -1292,7 +1647,7 @@ export function MessageInput({ onStop={onStop} disabled={disabled} inputValue={inputValue} - hasBadge={!!badge} + hasBadge={!!badge || !!cliBadge} /> diff --git a/src/components/chat/SlashCommandButton.tsx b/src/components/chat/SlashCommandButton.tsx index a908564..73042ae 100644 --- a/src/components/chat/SlashCommandButton.tsx +++ b/src/components/chat/SlashCommandButton.tsx @@ -2,6 +2,7 @@ import { useTranslation } from '@/hooks/useTranslation'; import type { TranslationKey } from '@/i18n'; +import { Lightning } from '@phosphor-icons/react'; import { Tooltip, TooltipContent, @@ -20,7 +21,7 @@ export function SlashCommandButton({ onInsertSlash }: SlashCommandButtonProps) { - / + diff --git a/src/components/cli-tools/CliToolBatchDescribeDialog.tsx b/src/components/cli-tools/CliToolBatchDescribeDialog.tsx new file mode 100644 index 0000000..4ef5065 --- /dev/null +++ b/src/components/cli-tools/CliToolBatchDescribeDialog.tsx @@ -0,0 +1,351 @@ +"use client"; + +import { useState, useEffect, useRef, useCallback } from "react"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogFooter, + DialogDescription, +} from "@/components/ui/dialog"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Button } from "@/components/ui/button"; +import { SpinnerGap, CheckCircle, XCircle, Sparkle } from "@phosphor-icons/react"; +import { useTranslation } from "@/hooks/useTranslation"; +import type { TranslationKey } from "@/i18n"; + +interface ProviderModelGroup { + provider_id: string; + provider_name: string; + sdkProxyOnly?: boolean; + models: Array<{ value: string; label: string }>; +} + +type AutoDescCache = Record; + +interface CliToolBatchDescribeDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + toolIds: string[]; + existingDescriptions: AutoDescCache; + onComplete: (results: AutoDescCache) => void; +} + +type Phase = "select" | "running" | "done"; + +interface ToolResult { + id: string; + status: 'pending' | 'loading' | 'success' | 'error'; + error?: string; +} + +export function CliToolBatchDescribeDialog({ + open, + onOpenChange, + toolIds, + existingDescriptions, + onComplete, +}: CliToolBatchDescribeDialogProps) { + const { t } = useTranslation(); + const [phase, setPhase] = useState("select"); + const [providerGroups, setProviderGroups] = useState([]); + const [selectedProviderId, setSelectedProviderId] = useState(""); + const [selectedModel, setSelectedModel] = useState(""); + const [toolResults, setToolResults] = useState([]); + const [skipExisting, setSkipExisting] = useState(true); + const resultsRef = useRef({}); + const abortControllerRef = useRef(null); + + // Fetch available models when dialog opens + useEffect(() => { + if (!open) return; + setPhase("select"); + abortControllerRef.current = null; + resultsRef.current = {}; + setToolResults([]); + + fetch("/api/providers/models") + .then(r => r.json()) + .then(data => { + const allGroups: ProviderModelGroup[] = data.groups || []; + // All providers are supported — describe uses the same SDK path as chat + const groups = allGroups; + const defaultPid: string = data.default_provider_id || ''; + setProviderGroups(groups); + // Auto-select the user's default provider (if not filtered), or fall back to first group + const defaultGroup = groups.find(g => g.provider_id === defaultPid) || groups[0]; + if (defaultGroup) { + setSelectedProviderId(defaultGroup.provider_id); + if (defaultGroup.models.length > 0) { + setSelectedModel(defaultGroup.models[0].value); + } + } + }) + .catch(err => console.error("Failed to fetch models:", err)); + }, [open]); + + const selectedGroup = providerGroups.find(g => g.provider_id === selectedProviderId); + const models = selectedGroup?.models || []; + + const handleProviderChange = (pid: string) => { + setSelectedProviderId(pid); + const group = providerGroups.find(g => g.provider_id === pid); + if (group && group.models.length > 0) { + setSelectedModel(group.models[0].value); + } else { + setSelectedModel(""); + } + }; + + const handleStart = useCallback(async () => { + const idsToProcess = skipExisting + ? toolIds.filter(id => !existingDescriptions[id]) + : [...toolIds]; + + if (idsToProcess.length === 0) { + setPhase("done"); + return; + } + + const controller = new AbortController(); + abortControllerRef.current = controller; + + setPhase("running"); + setToolResults(idsToProcess.map(id => ({ id, status: 'pending' }))); + + for (let i = 0; i < idsToProcess.length; i++) { + if (controller.signal.aborted) break; + + const toolId = idsToProcess[i]; + setToolResults(prev => prev.map((r, idx) => + idx === i ? { ...r, status: 'loading' } : r + )); + + try { + // Always pass providerId so the server uses exactly what the user selected. + // 'env' is a valid providerId that resolveProvider understands. + const body: Record = { + providerId: selectedProviderId, + }; + if (selectedModel) { + body.model = selectedModel; + } + + const res = await fetch(`/api/cli-tools/${toolId}/describe`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + signal: controller.signal, + }); + + if (!res.ok) { + const errData = await res.json().catch(() => ({})); + throw new Error(errData.error || `HTTP ${res.status}`); + } + + const data = await res.json(); + resultsRef.current[toolId] = data.description; + + setToolResults(prev => prev.map((r, idx) => + idx === i ? { ...r, status: 'success' } : r + )); + } catch (err) { + // Don't record abort errors as tool failures + if (controller.signal.aborted) break; + setToolResults(prev => prev.map((r, idx) => + idx === i ? { ...r, status: 'error', error: err instanceof Error ? err.message : 'Failed' } : r + )); + } + } + + // Only commit results if not cancelled + if (!controller.signal.aborted && Object.keys(resultsRef.current).length > 0) { + onComplete(resultsRef.current); + } + if (!controller.signal.aborted) { + setPhase("done"); + } + }, [toolIds, existingDescriptions, skipExisting, selectedProviderId, selectedModel, onComplete]); + + const handleClose = () => { + // Abort any in-flight requests + abortControllerRef.current?.abort(); + abortControllerRef.current = null; + onOpenChange(false); + }; + + const successCount = toolResults.filter(r => r.status === 'success').length; + const errorCount = toolResults.filter(r => r.status === 'error').length; + + const toolsToProcessCount = skipExisting + ? toolIds.filter(id => !existingDescriptions[id]).length + : toolIds.length; + const existingCount = toolIds.filter(id => !!existingDescriptions[id]).length; + + return ( + + + + + + {t('cliTools.batchDescribe')} + + + {t('cliTools.batchDescribeIntro')} + + + + {phase === "select" && providerGroups.length === 0 && ( +

+ {t('cliTools.batchNoProvider' as TranslationKey)} +

+ )} + + {phase === "select" && providerGroups.length > 0 && ( +
+ {/* Provider selector */} +
+ + +
+ + {/* Model selector */} +
+ + +
+ + {/* Skip existing toggle */} + {existingCount > 0 && ( + + )} + + {/* Summary */} +

+ {t('cliTools.batchToolCount', { count: String(toolsToProcessCount), total: String(toolIds.length) })} +

+
+ )} + + {phase === "running" && ( +
+ {toolResults.map(r => ( +
+ {r.status === 'pending' && ( + + )} + {r.status === 'loading' && ( + + )} + {r.status === 'success' && ( + + )} + {r.status === 'error' && ( + + )} + {r.id} + {r.status === 'error' && r.error && ( + {r.error} + )} +
+ ))} +
+ )} + + {phase === "done" && ( +
+
+ {successCount > 0 && ( + + + {t('cliTools.batchSuccess', { count: String(successCount) })} + + )} + {errorCount > 0 && ( + + + {t('cliTools.batchFailed', { count: String(errorCount) })} + + )} + {successCount === 0 && errorCount === 0 && ( + {t('cliTools.batchNothingToProcess')} + )} +
+
+ )} + + + {phase === "select" && ( + <> + + {providerGroups.length > 0 && ( + + )} + + )} + {phase === "running" && ( + + )} + {phase === "done" && ( + + )} + +
+
+ ); +} diff --git a/src/components/cli-tools/CliToolCard.tsx b/src/components/cli-tools/CliToolCard.tsx new file mode 100644 index 0000000..130ab61 --- /dev/null +++ b/src/components/cli-tools/CliToolCard.tsx @@ -0,0 +1,119 @@ +"use client"; + +import { useState } from "react"; +import { Plus, CaretDown } from "@phosphor-icons/react"; +import { Button } from "@/components/ui/button"; +import { useTranslation } from "@/hooks/useTranslation"; +import type { TranslationKey } from "@/i18n"; +import type { CliToolDefinition, CliToolRuntimeInfo, CliToolPlatform } from "@/types"; + +interface CliToolCardProps { + tool: CliToolDefinition; + runtimeInfo?: CliToolRuntimeInfo; + variant: 'installed' | 'recommended'; + autoDescription?: { zh: string; en: string }; + onDetail: () => void; + onInstall?: (tool: CliToolDefinition, method: string) => void; + locale: string; + platform: string; +} + +export function CliToolCard({ + tool, + runtimeInfo, + variant, + autoDescription, + onDetail, + onInstall, + locale, + platform, +}: CliToolCardProps) { + const { t } = useTranslation(); + const [showMethodPicker, setShowMethodPicker] = useState(false); + const isZh = locale === 'zh'; + + const availableMethods = tool.installMethods.filter( + m => m.platforms.includes(platform as CliToolPlatform) + ); + + const summary = autoDescription + ? (isZh ? autoDescription.zh : autoDescription.en) + : (isZh ? tool.summaryZh : tool.summaryEn); + + const handleInstallClick = (e: React.MouseEvent) => { + e.stopPropagation(); + if (availableMethods.length === 1) { + onInstall?.(tool, availableMethods[0].method); + } else if (availableMethods.length > 1) { + setShowMethodPicker(!showMethodPicker); + } + }; + + const handleMethodSelect = (e: React.MouseEvent, method: string) => { + e.stopPropagation(); + setShowMethodPicker(false); + onInstall?.(tool, method); + }; + + return ( +
+ {/* Content */} +
+
+

{tool.name}

+ {/* Category tags inline */} + {tool.categories.map(cat => ( + + {t(`cliTools.category.${cat}` as TranslationKey)} + + ))} + {/* Version for installed */} + {variant === 'installed' && runtimeInfo?.version && ( + + v{runtimeInfo.version} + + )} +
+

+ {summary || t('cliTools.noDescription' as TranslationKey)} +

+
+ + {/* Right action */} + {variant === 'recommended' && onInstall && availableMethods.length > 0 && ( +
+ + {showMethodPicker && availableMethods.length > 1 && ( +
+ {availableMethods.map(m => ( + + ))} +
+ )} +
+ )} +
+ ); +} diff --git a/src/components/cli-tools/CliToolDetailDialog.tsx b/src/components/cli-tools/CliToolDetailDialog.tsx new file mode 100644 index 0000000..218e14c --- /dev/null +++ b/src/components/cli-tools/CliToolDetailDialog.tsx @@ -0,0 +1,193 @@ +"use client"; + +import { useState } from "react"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogFooter, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Copy, ArrowSquareOut, Plus, CaretDown } from "@phosphor-icons/react"; +import { useTranslation } from "@/hooks/useTranslation"; +import type { CliToolDefinition, CliToolPlatform } from "@/types"; + +interface CliToolDetailDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + tool: CliToolDefinition; + locale: string; + /** If provided, shows install button at the bottom (for recommended tools) */ + onInstall?: (tool: CliToolDefinition, method: string) => void; + platform?: string; +} + +export function CliToolDetailDialog({ + open, + onOpenChange, + tool, + locale, + onInstall, + platform, +}: CliToolDetailDialogProps) { + const { t } = useTranslation(); + const isZh = locale === 'zh'; + const [showMethodPicker, setShowMethodPicker] = useState(false); + + const copyToClipboard = (text: string) => { + navigator.clipboard.writeText(text); + }; + + const availableMethods = platform + ? tool.installMethods.filter(m => m.platforms.includes(platform as CliToolPlatform)) + : tool.installMethods; + + const handleInstallClick = () => { + if (availableMethods.length === 1) { + onInstall?.(tool, availableMethods[0].method); + } else if (availableMethods.length > 1) { + setShowMethodPicker(!showMethodPicker); + } + }; + + return ( + + + + {tool.name} + + +
+ {/* Intro */} +
+

{t('cliTools.detailIntro')}

+

+ {isZh ? tool.detailIntro.zh : tool.detailIntro.en} +

+
+ + {/* Use cases */} +
+

{t('cliTools.useCases')}

+
    + {(isZh ? tool.useCases.zh : tool.useCases.en).map((uc, i) => ( +
  • {uc}
  • + ))} +
+
+ + {/* Guide steps */} +
+

{t('cliTools.guideSteps')}

+
    + {(isZh ? tool.guideSteps.zh : tool.guideSteps.en).map((step, i) => ( +
  1. {step}
  2. + ))} +
+
+ + {/* Example prompts */} +
+

{t('cliTools.examplePrompts')}

+
+ {tool.examplePrompts.map((ep, i) => ( +
+
+
+

{ep.label}

+

+ {isZh ? ep.promptZh : ep.promptEn} +

+
+
+ +
+
+
+ ))} +
+
+ + {/* Links */} + {(tool.homepage || tool.repoUrl || tool.officialDocsUrl) && ( +
+ {tool.homepage && ( + + + {t('cliTools.homepage')} + + )} + {tool.repoUrl && ( + + + GitHub + + )} + {tool.officialDocsUrl && ( + + + {t('cliTools.docs')} + + )} +
+ )} +
+ + {/* Install button — only for recommended (not installed) tools */} + {onInstall && availableMethods.length > 0 && ( + + + {showMethodPicker && availableMethods.length > 1 && ( +
+ {availableMethods.map(m => ( + + ))} +
+ )} +
+ )} +
+
+ ); +} diff --git a/src/components/cli-tools/CliToolExtraDetailDialog.tsx b/src/components/cli-tools/CliToolExtraDetailDialog.tsx new file mode 100644 index 0000000..3508a2b --- /dev/null +++ b/src/components/cli-tools/CliToolExtraDetailDialog.tsx @@ -0,0 +1,78 @@ +"use client"; + +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { useTranslation } from "@/hooks/useTranslation"; +import type { CliToolRuntimeInfo } from "@/types"; + +interface CliToolExtraDetailDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + displayName: string; + runtimeInfo: CliToolRuntimeInfo; + autoDescription?: { zh: string; en: string }; + locale: string; +} + +export function CliToolExtraDetailDialog({ + open, + onOpenChange, + displayName, + runtimeInfo, + autoDescription, + locale, +}: CliToolExtraDetailDialogProps) { + const { t } = useTranslation(); + const isZh = locale === 'zh'; + + return ( + + + + {displayName} + + +
+ {/* AI-generated description */} + {autoDescription && ( +
+

{t('cliTools.detailIntro')}

+

+ {isZh ? autoDescription.zh : autoDescription.en} +

+
+ )} + + {/* Runtime info */} +
+

{t('cliTools.toolInfo')}

+
+ {runtimeInfo.version && ( +
+ {t('cliTools.version')}: + {runtimeInfo.version} +
+ )} + {runtimeInfo.binPath && ( +
+ {t('cliTools.path')}: + {runtimeInfo.binPath} +
+ )} +
+
+ + {!autoDescription && ( +

+ {t('cliTools.noDescription')} +

+ )} +
+
+
+ ); +} diff --git a/src/components/cli-tools/CliToolInstallDialog.tsx b/src/components/cli-tools/CliToolInstallDialog.tsx new file mode 100644 index 0000000..72e01af --- /dev/null +++ b/src/components/cli-tools/CliToolInstallDialog.tsx @@ -0,0 +1,175 @@ +"use client"; + +import { useState, useEffect, useRef, useCallback } from "react"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogFooter, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { SpinnerGap, CheckCircle, X } from "@phosphor-icons/react"; +import { useTranslation } from "@/hooks/useTranslation"; +import type { CliToolDefinition } from "@/types"; + +interface CliToolInstallDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + tool: CliToolDefinition; + method: string; + onComplete: () => void; +} + +type Phase = "running" | "success" | "error"; + +export function CliToolInstallDialog({ + open, + onOpenChange, + tool, + method, + onComplete, +}: CliToolInstallDialogProps) { + const { t } = useTranslation(); + const [phase, setPhase] = useState("running"); + const [logs, setLogs] = useState([]); + const logsEndRef = useRef(null); + const abortRef = useRef(null); + + const startProcess = useCallback(async () => { + setPhase("running"); + setLogs([]); + + const controller = new AbortController(); + abortRef.current = controller; + + try { + const res = await fetch(`/api/cli-tools/${tool.id}/install`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ method }), + signal: controller.signal, + }); + + if (!res.ok || !res.body) { + setPhase("error"); + setLogs((prev) => [...prev, `HTTP ${res.status}: ${res.statusText}`]); + return; + } + + const reader = res.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split("\n"); + buffer = lines.pop() || ""; + + let currentEvent = ""; + for (const line of lines) { + if (line.startsWith("event: ")) { + currentEvent = line.slice(7).trim(); + } else if (line.startsWith("data: ")) { + const raw = line.slice(6); + let data: string; + try { + data = JSON.parse(raw); + } catch { + data = raw; + } + + if (currentEvent === "output") { + setLogs((prev) => [...prev, data]); + } else if (currentEvent === "done") { + setPhase("success"); + } else if (currentEvent === "error") { + setPhase("error"); + setLogs((prev) => [...prev, `Error: ${data}`]); + } + } + } + } + } catch (err) { + if ((err as Error).name !== "AbortError") { + setPhase("error"); + setLogs((prev) => [...prev, (err as Error).message]); + } + } + }, [tool.id, method]); + + useEffect(() => { + if (open) { + startProcess(); + } + return () => { + abortRef.current?.abort(); + }; + }, [open, startProcess]); + + useEffect(() => { + logsEndRef.current?.scrollIntoView({ behavior: "smooth" }); + }, [logs]); + + const handleClose = () => { + abortRef.current?.abort(); + if (phase === "success") { + onComplete(); + } + onOpenChange(false); + }; + + const installMethod = tool.installMethods.find(m => m.method === method); + + return ( + + + + + {phase === "running" && ( + + )} + {phase === "success" && ( + + )} + {phase === "error" && ( + + )} + {phase === "running" + ? `${t('cliTools.installing')} ${tool.name}...` + : phase === "success" + ? t('cliTools.installSuccess') + : t('cliTools.installFailed')} + + + + {installMethod && ( +
+ $ {installMethod.command} +
+ )} + +
+ {logs.length === 0 && phase === "running" && ( + {t('cliTools.installing')}... + )} + {logs.map((line, i) => ( +
+ {line} +
+ ))} +
+
+ + + + + +
+ ); +} diff --git a/src/components/cli-tools/CliToolsManager.tsx b/src/components/cli-tools/CliToolsManager.tsx new file mode 100644 index 0000000..e5467a0 --- /dev/null +++ b/src/components/cli-tools/CliToolsManager.tsx @@ -0,0 +1,267 @@ +"use client"; + +import { useState, useEffect, useCallback } from "react"; +import { useTranslation } from "@/hooks/useTranslation"; +import type { TranslationKey } from "@/i18n"; +import type { CliToolDefinition, CliToolRuntimeInfo } from "@/types"; +import { CliToolCard } from "./CliToolCard"; +import { CliToolDetailDialog } from "./CliToolDetailDialog"; +import { CliToolExtraDetailDialog } from "./CliToolExtraDetailDialog"; +import { CliToolInstallDialog } from "./CliToolInstallDialog"; +import { CliToolBatchDescribeDialog } from "./CliToolBatchDescribeDialog"; +import { SpinnerGap, Sparkle, ArrowSquareOut, Warning } from "@phosphor-icons/react"; +import { Button } from "@/components/ui/button"; +import { EXTRA_WELL_KNOWN_BINS } from "@/lib/cli-tools-catalog"; + +type AutoDescCache = Record; + +export function CliToolsManager() { + const { t, locale } = useTranslation(); + const [catalog, setCatalog] = useState([]); + const [runtimeInfos, setRuntimeInfos] = useState([]); + const [extraDetected, setExtraDetected] = useState([]); + const [platform, setPlatform] = useState(''); + const [hasBrew, setHasBrew] = useState(true); + const [loading, setLoading] = useState(true); + const [autoDescriptions, setAutoDescriptions] = useState({}); + + // Dialog state + const [detailTool, setDetailTool] = useState<{ tool: CliToolDefinition; canInstall: boolean } | null>(null); + const [extraDetailTool, setExtraDetailTool] = useState<{ displayName: string; runtimeInfo: CliToolRuntimeInfo } | null>(null); + const [installTool, setInstallTool] = useState<{ tool: CliToolDefinition; method: string } | null>(null); + const [batchDescribeOpen, setBatchDescribeOpen] = useState(false); + + const fetchData = useCallback(async () => { + try { + const [catalogRes, installedRes] = await Promise.all([ + fetch('/api/cli-tools/catalog'), + fetch('/api/cli-tools/installed'), + ]); + const catalogData = await catalogRes.json(); + const installedData = await installedRes.json(); + setCatalog(catalogData.tools || []); + setRuntimeInfos(installedData.tools || []); + setExtraDetected(installedData.extra || []); + setPlatform(installedData.platform || ''); + setHasBrew(installedData.hasBrew !== false); + } catch (err) { + console.error('Failed to fetch CLI tools data:', err); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + fetchData(); + // Load cached auto-descriptions from localStorage + try { + const cached = localStorage.getItem('cli-tools-auto-desc'); + if (cached) setAutoDescriptions(JSON.parse(cached)); + } catch { /* ignore */ } + }, [fetchData]); + + const getRuntimeInfo = (toolId: string): CliToolRuntimeInfo | undefined => { + return runtimeInfos.find(r => r.id === toolId); + }; + + const installedCatalogTools = catalog.filter(t => { + const info = getRuntimeInfo(t.id); + return info && info.status !== 'not_installed'; + }); + + const recommendedTools = catalog.filter(t => { + const info = getRuntimeInfo(t.id); + return !info || info.status === 'not_installed'; + }); + + // Only extra-detected tool IDs for batch describe (catalog tools already have descriptions) + const extraToolIds = extraDetected.map(e => e.id); + + const handleInstall = (tool: CliToolDefinition, method: string) => { + setInstallTool({ tool, method }); + }; + + const handleInstallComplete = () => { + setInstallTool(null); + fetchData(); + }; + + const handleBatchDescribeComplete = (results: AutoDescCache) => { + const merged = { ...autoDescriptions, ...results }; + setAutoDescriptions(merged); + localStorage.setItem('cli-tools-auto-desc', JSON.stringify(merged)); + }; + + if (loading) { + return ( +
+ +
+ ); + } + + return ( +
+
+

{t('cliTools.title')}

+

{t('cliTools.description')}

+
+ + {/* Installed — catalog tools + extra system-detected tools merged */} + {(installedCatalogTools.length > 0 || extraDetected.length > 0) && ( +
+
+

{t('cliTools.installed')}

+ +
+
+ {/* Catalog tools that are installed */} + {installedCatalogTools.map(tool => ( + setDetailTool({ tool, canInstall: false })} + locale={locale} + platform={platform} + /> + ))} + {/* Extra system-detected tools (not in catalog) */} + {extraDetected.map(info => { + const entry = EXTRA_WELL_KNOWN_BINS.find(([eid]) => eid === info.id); + const displayName = entry?.[1] ?? info.id; + const desc = autoDescriptions[info.id]; + return ( +
setExtraDetailTool({ displayName, runtimeInfo: info })} + > +
+
+

{displayName}

+ {info.version && ( + + v{info.version} + + )} +
+

+ {desc + ? (locale === 'zh' ? desc.zh : desc.en) + : t('cliTools.noDescription' as TranslationKey)} +

+
+
+ ); + })} +
+
+ )} + + {/* Recommended (not installed) */} +
+

{t('cliTools.recommended')}

+ + {/* Brew not installed warning */} + {!hasBrew && (platform === 'darwin' || platform === 'linux') && ( +
+ +
+

{t('cliTools.brewNotInstalled')}

+

{t('cliTools.brewInstallGuide')}

+ + /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)" + +
+
+ )} + +
+ {recommendedTools.map(tool => ( + setDetailTool({ tool, canInstall: true })} + onInstall={handleInstall} + locale={locale} + platform={platform} + /> + ))} +
+ {recommendedTools.length === 0 && ( +

{t('cliTools.allInstalled')}

+ )} +
+ + {/* Docs link */} + + + {/* Detail dialog */} + {detailTool && ( + !open && setDetailTool(null)} + tool={detailTool.tool} + locale={locale} + onInstall={detailTool.canInstall ? handleInstall : undefined} + platform={platform} + /> + )} + + {/* Extra tool detail dialog */} + {extraDetailTool && ( + !open && setExtraDetailTool(null)} + displayName={extraDetailTool.displayName} + runtimeInfo={extraDetailTool.runtimeInfo} + autoDescription={autoDescriptions[extraDetailTool.runtimeInfo.id]} + locale={locale} + /> + )} + + {/* Install dialog */} + {installTool && ( + !open && setInstallTool(null)} + tool={installTool.tool} + method={installTool.method} + onComplete={handleInstallComplete} + /> + )} + + {/* Batch AI describe dialog */} + +
+ ); +} diff --git a/src/components/layout/ChatListPanel.tsx b/src/components/layout/ChatListPanel.tsx index 6806725..507ff66 100644 --- a/src/components/layout/ChatListPanel.tsx +++ b/src/components/layout/ChatListPanel.tsx @@ -374,7 +374,7 @@ export function ChatListPanel({ open, width }: ChatListPanelProps) { return (