diff --git a/AGENTS.md b/AGENTS.md index da84ecd..68cdd64 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -32,9 +32,9 @@ This file provides guidance to Wave Code when working with code in this reposito ### 前端 (Webview) - **`webview/src/index.tsx`**: React 应用程序的入口点。 - **`webview/src/components/ChatApp.tsx`**: 使用 `useReducer` 模式管理聊天状态的主要组件。 -- **`webview/src/components/ConfigurationDialog.tsx`**: 用于管理 AI 设置(API 密钥、模型、语言等)和插件管理的 UI。包含三个主要标签页: - - **常规设置**: API 配置、模型选择、语言设置 - - **插件管理**: 探索新插件、已安装插件、插件市场 +- **`webview/src/components/ConfigDialog.tsx`**: 常规设置弹窗(通过 `/config` 命令唤起),管理 AI 设置(API 密钥、模型、语言等)和 SSO 认证。 +- **`webview/src/components/PluginDialog.tsx`**: 插件管理弹窗(通过 `/plugin` 命令唤起),包含探索新插件、已安装插件、插件市场三个子选项卡。 +- **`webview/src/components/McpDialog.tsx`**: MCP 服务器管理弹窗(通过 `/mcp` 命令唤起),显示服务器状态和连接控制。 - **通信 (Communication)**: 使用 `vscode.postMessage` 和 `window.addEventListener('message', ...)` 与后端通信。 - **VS Code API 限制**: `acquireVsCodeApi()` 在整个 Webview 生命周期内只能被调用一次。必须在根组件(如 `index.tsx`)调用并作为 Prop 传递给子组件,严禁在子组件中重复获取,否则会导致 Webview 崩溃。 diff --git a/docs/spec.md b/docs/spec.md index f5dc111..6c96517 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -95,7 +95,16 @@ _输入框中的代码选中标签_ ### 2.4 指令系统 (Slash Commands) {#slash-commands} -通过输入 `/`,用户可以快速调用预设的指令,如 `/explain`、`/fix` 等,提高操作效率。 +通过输入 `/`,用户可以快速调用预设的指令,如 `/explain`、`/fix` 等提高操作效率。此外,Wave 还提供以下内置指令来快速打开管理弹窗: + +- **`/config`**:打开常规设置弹窗(详见 [第 10.1 节 配置设置](#configuration-settings)) +- **`/plugin`**:打开插件管理弹窗(详见 [第 11 章 插件系统](#plugin-system)) +- **`/mcp`**:打开 MCP 服务器管理弹窗(详见 [第 6.3 节 MCP 协议集成](#mcp-integration)) +- **`/model`**:打开模型设置弹窗,快速切换主模型和快速模型(详见 [第 10.1 节 配置设置](#configuration-settings)) +- **`/status`**:打开状态信息弹窗,查看版本、会话 ID、工作目录、模型配置和认证状态 +- **`/login`**:打开 SSO 认证弹窗,进行登录/登出操作(详见 [第 10.1 节 配置设置](#configuration-settings)) + +选择这些指令后会直接弹出对应弹窗,无需按回车发送。 ![指令系统](/screenshots/spec-slash-commands.png) _指令系统_ @@ -388,7 +397,7 @@ _Skill 系统_ ### 6.3 MCP 协议集成 {#mcp-integration} -支持 Model Context Protocol (MCP),允许 AI 连接到外部的 MCP 服务器,从而获取更多的上下文信息或调用外部工具。用户可通过配置弹窗中的 **MCP 服务器** 标签页查看和管理所有已配置的 MCP 服务器。 +支持 Model Context Protocol (MCP),允许 AI 连接到外部的 MCP 服务器,从而获取更多的上下文信息或调用外部工具。用户可通过 `/mcp` 命令唤起的 MCP 服务器管理弹窗查看和管理所有已配置的 MCP 服务器。 **主要特性:** @@ -405,7 +414,7 @@ _Skill 系统_ _MCP 集成_ ![MCP 服务器管理](/screenshots/spec-mcp-server-tab.png) -_MCP 服务器管理(配置弹窗中的 MCP 标签页)_ +_MCP 服务器管理(通过 `/mcp` 命令唤起)_ ### 6.4 内置 Skills {#builtin-skills} @@ -614,25 +623,38 @@ Wave 在后台自动维护项目记忆,帮助 AI 持续了解项目演变: ### 10.1 配置设置 {#configuration-settings} -用户可以自定义 AI 模型、API Key、Base URL 等关键参数,以适配不同的 AI 服务提供商。配置界面包含三个标签页:**常规设置**、**插件** 和 **MCP 服务器**。配置界面中的表单字段仅显示用户手动输入的值,不会被环境变量填充;但如果设置了相应的环境变量(如 `WAVE_BASE_URL`),其值会作为 placeholder 提示显示在输入框中。服务端链接字段默认 placeholder 为"请联系管理员获取"。 +用户可以自定义 AI 模型、API Key、Base URL 等关键参数,以适配不同的 AI 服务提供商。通过 `/config`、`/model`、`/login`、`/status`、`/plugin`、`/mcp` 六个斜杠命令分别唤起独立的弹窗进行管理。配置界面中的表单字段仅显示用户手动输入的值,不会被环境变量填充;但如果设置了相应的环境变量(如 `WAVE_BASE_URL`),其值会作为 placeholder 提示显示在输入框中。服务端链接字段默认 placeholder 为"请联系管理员获取"。 **主要特性:** -- **常规设置**:配置 AI 模型、API Key、Base URL、Headers、语言等。 +- **常规设置(`/config`)**:配置服务端链接、API Key、Base URL、Headers、语言等。 - **服务端链接**:配置 Wave AI 服务端地址,用于 SSO 认证。支持环境变量 `WAVE_SERVER_URL` 作为 fallback,默认 placeholder 提示"请联系管理员获取"。 - - **Model / Fast Model**:SSO 模式下也可配置主模型和快速模型名称,支持环境变量 `WAVE_MODEL` / `WAVE_FAST_MODEL` 作为 placeholder 提示。 - - **SSO 登录/登出**:当配置了服务端链接后,用户可通过 SSO 认证进行登录,无需手动配置 API Key。登录后所有 API 请求自动通过 Wave AI 代理路由。 - - **浏览器登录**:点击"SSO 登录"后自动打开浏览器,用户在 Wave AI 登录页完成认证(支持 SSO 企业身份提供商或账号密码登录),授权码通过 localhost 回调自动交换为 JWT 并保存。VS Code Remote SSH 环境会自动转发端口,远程服务器体验与本地一致。 - - **登录状态显示**:已认证时显示用户邮箱/ID 和登出按钮;登出后自动恢复为直接 LLM API 模式。 -- **插件**:管理插件的安装、更新、卸载和插件市场(详见 [第 11 章 插件系统](#plugin-system))。 -- **MCP 服务器**:查看和管理 MCP 服务器连接状态(详见 [第 6.3 节 MCP 协议集成](#mcp-integration))。 +- **模型设置(`/model`)**:快速切换主模型和快速模型,支持环境变量 `WAVE_MODEL` / `WAVE_FAST_MODEL` 作为 placeholder 提示。 + +![模型设置](/screenshots/spec-model-dialog.png) +_模型设置弹窗_ + +- **SSO 认证(`/login`)**:当配置了服务端链接后,用户可通过 SSO 认证进行登录,无需手动配置 API Key。登录后所有 API 请求自动通过 Wave AI 代理路由。 + - **浏览器登录**:点击"SSO 登录"后自动打开浏览器,用户在 Wave AI 登录页完成认证(支持 SSO 企业身份提供商或账号密码登录),授权码通过 localhost 回调自动交换为 JWT 并保存。VS Code Remote SSH 环境会自动转发端口,远程服务器体验与本地一致。 + - **登录状态显示**:已认证时显示用户邮箱/ID 和登出按钮;登出后自动恢复为直接 LLM API 模式。 + +![SSO 认证](/screenshots/spec-login-dialog.png) +_SSO 认证弹窗_ + +- **状态信息(`/status`)**:查看当前 Wave 的运行状态,包括版本号、Session ID、工作目录、服务端链接、Base URL、Model、Fast Model 及认证状态。 + +![状态信息](/screenshots/spec-status-dialog.png) +_状态信息弹窗_ + +- **插件管理(`/plugin`)**:管理插件的安装、更新、卸载和插件市场(详见 [第 11 章 插件系统](#plugin-system))。 +- **MCP 服务器(`/mcp`)**:查看和管理 MCP 服务器连接状态(详见 [第 6.3 节 MCP 协议集成](#mcp-integration))。 ![配置设置](/screenshots/spec-configuration.png) _配置设置_ ### 10.2 语言设置 {#language-settings} -用户可以在配置界面中选择偏好的语言(中文或英文),AI 代理将根据该设置使用相应的语言进行回复。 +用户可以在常规设置弹窗(通过 `/config` 命令唤起)中选择偏好的语言(中文或英文),AI 代理将根据该设置使用相应的语言进行回复。 ![语言设置](/screenshots/language-config-ui.png) _语言设置_ @@ -816,7 +838,7 @@ SDK 默认对名称包含 `claude` 的模型自动启用 Prompt Cache(提示 ### 11.1 概述 {#plugin-overview} -Wave 通过插件系统扩展 AI 能力,用户可以在配置界面中管理插件的安装、更新和卸载。 +Wave 通过插件系统扩展 AI 能力,用户可以通过 `/plugin` 命令唤起的插件管理弹窗管理插件的安装、更新和卸载。 **主要特性:** @@ -828,7 +850,7 @@ Wave 通过插件系统扩展 AI 能力,用户可以在配置界面中管理 ### 11.2 探索新插件 {#explore-plugins} -在"探索新插件"标签页中,用户可以通过顶部的搜索框按关键词过滤插件列表。搜索支持对插件名称和描述进行不区分大小写的匹配。对于尚未在当前环境激活的插件,用户可以选择安装作用域后点击"安装"按钮。已安装但未在当前作用域激活的插件也会在此列出,并带有"已安装"标识。 +在插件管理弹窗的"探索新插件"选项卡中,用户可以通过顶部的搜索框按关键词过滤插件列表。搜索支持对插件名称和描述进行不区分大小写的匹配。对于尚未在当前环境激活的插件,用户可以选择安装作用域后点击"安装"按钮。已安装但未在当前作用域激活的插件也会在此列出,并带有"已安装"标识。 ![探索新插件(支持关键词搜索)](/screenshots/spec-plugin-explore.png) _探索新插件(支持关键词搜索)_ @@ -838,7 +860,7 @@ _关键词过滤效果_ ### 11.3 已激活插件 {#installed-plugins} -在"已激活插件"标签页中,用户可以查看当前作用域下所有已激活的插件。用户可以对这些插件进行"更新"或"卸载"操作。每个插件会显示其激活的作用域(User、Project 或 Local)。 +在插件管理弹窗的"已激活插件"选项卡中,用户可以查看当前作用域下所有已激活的插件。用户可以对这些插件进行"更新"或"卸载"操作。每个插件会显示其激活的作用域(User、Project 或 Local)。 ![已激活插件管理](/screenshots/spec-plugin-installed.png) _已激活插件管理_ @@ -891,7 +913,7 @@ LCAP 低代码平台扩展组件开发指南。包含约 **17 个技能**,覆 创建独特的、生产级前端界面设计技能。注重美学品质,避免千篇一律的 AI 审美风格,生成具有设计感的 Web 前端代码。 -在"插件市场"标签页中,用户还可以添加自定义的插件市场源,支持 GitHub 仓库(owner/repo 格式)、Git URL 或本地文件路径。添加后可以从该市场源安装插件,也可以更新或移除市场源。 +在插件管理弹窗的"插件市场"选项卡中,用户还可以添加自定义的插件市场源,支持 GitHub 仓库(owner/repo 格式)、Git URL 或本地文件路径。添加后可以从该市场源安装插件,也可以更新或移除市场源。 ![插件市场管理](/screenshots/spec-plugin-marketplaces.png) _插件市场管理_ diff --git a/e2e/demo/config-popup.demo.ts b/e2e/demo/config-popup.demo.ts index 29b027c..5d055dc 100644 --- a/e2e/demo/config-popup.demo.ts +++ b/e2e/demo/config-popup.demo.ts @@ -3,7 +3,7 @@ import { MessageInjector } from '../utils/messageInjector.js'; test.describe('Configuration Popup Demo', () => { test('should show configuration dialog when config is missing', async ({ webviewPage }) => { - // Simulate the extension sending the showConfiguration command + // Simulate the extension sending the showConfiguration command (opens ConfigDialog) await webviewPage.evaluate(() => { (window as any).simulateExtensionMessage({ command: 'showConfiguration', @@ -25,7 +25,7 @@ test.describe('Configuration Popup Demo', () => { }); test('should NOT show configuration dialog when config is provided (simulated)', async ({ webviewPage }) => { - // In this case, the extension wouldn't send showConfiguration + // The extension wouldn't send showConfiguration when config is valid // We just verify the normal chat interface is there await expect(webviewPage.getByTestId('chat-container')).toBeVisible(); await expect(webviewPage.getByText('配置设置', { exact: true })).not.toBeVisible(); // The dialog title diff --git a/e2e/demo/language-config.demo.ts b/e2e/demo/language-config.demo.ts index bc7e88b..095dcb3 100644 --- a/e2e/demo/language-config.demo.ts +++ b/e2e/demo/language-config.demo.ts @@ -2,7 +2,7 @@ import { test, expect } from '../utils/webviewTestHarness.js'; test.describe('Language Configuration Demo', () => { test('should show language field in configuration dialog', async ({ webviewPage }) => { - // Simulate the extension sending the showConfiguration command with language + // Simulate the extension sending showConfiguration (opens ConfigDialog via /config) await webviewPage.evaluate(() => { (window as any).simulateExtensionMessage({ command: 'showConfiguration', diff --git a/e2e/demo/login-dialog.demo.ts b/e2e/demo/login-dialog.demo.ts new file mode 100644 index 0000000..f52a83c --- /dev/null +++ b/e2e/demo/login-dialog.demo.ts @@ -0,0 +1,114 @@ +import { test, expect } from '../utils/webviewTestHarness.js'; + +test.describe('Login Dialog Demo', () => { + test('should show login dialog with not-authenticated state', async ({ webviewPage }) => { + // 1. Open the login dialog via showDialog + await webviewPage.evaluate(() => { + (window as any).simulateExtensionMessage({ + command: 'showDialog', + dialogType: 'login' + }); + }); + + // 2. Simulate extension sending configuration data with serverUrl + await webviewPage.evaluate(() => { + (window as any).simulateExtensionMessage({ + command: 'configurationResponse', + configurationData: { + serverUrl: 'https://wave-ai.example.com' + } + }); + }); + + // 3. Simulate auth status (not authenticated) + await webviewPage.evaluate(() => { + (window as any).simulateExtensionMessage({ + command: 'authStatusResponse', + isAuthenticated: false, + user: null + }); + }); + + // Verify dialog title + await expect(webviewPage.getByText('SSO 认证', { exact: true })).toBeVisible(); + + // Verify server URL is displayed + await expect(webviewPage.getByText('https://wave-ai.example.com')).toBeVisible(); + + // Verify login button is visible + await expect(webviewPage.getByText('SSO 登录', { exact: true })).toBeVisible(); + + // Take screenshot + const dialog = webviewPage.locator('.configuration-dialog'); + await dialog.screenshot({ path: 'docs/public/screenshots/spec-login-dialog.png' }); + }); + + test('should show login dialog with authenticated state', async ({ webviewPage }) => { + await webviewPage.evaluate(() => { + (window as any).simulateExtensionMessage({ + command: 'showDialog', + dialogType: 'login' + }); + }); + + await webviewPage.evaluate(() => { + (window as any).simulateExtensionMessage({ + command: 'configurationResponse', + configurationData: { + serverUrl: 'https://wave-ai.example.com' + } + }); + }); + + // Simulate authenticated state + await webviewPage.evaluate(() => { + (window as any).simulateExtensionMessage({ + command: 'authStatusResponse', + isAuthenticated: true, + user: { id: 'user-xyz789', email: 'alice@example.com' } + }); + }); + + await expect(webviewPage.getByText('SSO 认证', { exact: true })).toBeVisible(); + await expect(webviewPage.getByText('alice@example.com')).toBeVisible(); + await expect(webviewPage.getByText('登出', { exact: true })).toBeVisible(); + + const dialog = webviewPage.locator('.configuration-dialog'); + await dialog.screenshot({ path: 'docs/public/screenshots/spec-login-dialog-authenticated.png' }); + }); + + test('should show warning when no server URL configured', async ({ webviewPage }) => { + await webviewPage.evaluate(() => { + (window as any).simulateExtensionMessage({ + command: 'showDialog', + dialogType: 'login' + }); + }); + + // No serverUrl configured + await webviewPage.evaluate(() => { + (window as any).simulateExtensionMessage({ + command: 'configurationResponse', + configurationData: {} + }); + }); + + await webviewPage.evaluate(() => { + (window as any).simulateExtensionMessage({ + command: 'authStatusResponse', + isAuthenticated: false, + user: null + }); + }); + + await expect(webviewPage.getByText('SSO 认证', { exact: true })).toBeVisible(); + await expect(webviewPage.getByText('请先在 /config 中配置服务端链接')).toBeVisible(); + + // Login button should be disabled + const loginBtn = webviewPage.getByText('SSO 登录', { exact: true }); + await expect(loginBtn).toBeDisabled(); + + const dialog = webviewPage.locator('.configuration-dialog'); + await dialog.screenshot({ path: 'docs/public/screenshots/spec-login-dialog-no-url.png' }); + }); +}); diff --git a/e2e/demo/mcp-server-tab.demo.ts b/e2e/demo/mcp-server-tab.demo.ts index 34abe81..40f977f 100644 --- a/e2e/demo/mcp-server-tab.demo.ts +++ b/e2e/demo/mcp-server-tab.demo.ts @@ -1,28 +1,19 @@ import { test, expect } from '../utils/webviewTestHarness.js'; -test.describe('MCP Server Tab Demo', () => { - test('should show MCP server tab with configured servers', async ({ webviewPage }) => { - // 1. Open the configuration dialog +test.describe('MCP Server Dialog Demo', () => { + test('should show MCP server dialog with configured servers', async ({ webviewPage }) => { + // 1. Open the MCP server management dialog via showDialog await webviewPage.evaluate(() => { (window as any).simulateExtensionMessage({ - command: 'showConfiguration', - configurationData: { - apiKey: 'test-key', - baseURL: 'https://api.example.com', - model: 'test-model', - fastModel: 'fast-model', - language: 'Chinese' - } + command: 'showDialog', + dialogType: 'mcp' }); }); // Verify dialog is visible - await expect(webviewPage.getByText('配置设置', { exact: true })).toBeVisible(); + await expect(webviewPage.getByText('MCP 服务器', { exact: true })).toBeVisible(); - // 2. Click on "MCP 服务器" tab - await webviewPage.getByText('MCP 服务器', { exact: true }).click(); - - // 3. Simulate receiving MCP servers list + // 2. Simulate receiving MCP servers list await webviewPage.evaluate(() => { window.postMessage({ command: 'mcpServersResponse', @@ -97,24 +88,15 @@ test.describe('MCP Server Tab Demo', () => { }); test('should show empty state when no MCP servers configured', async ({ webviewPage }) => { - // 1. Open the configuration dialog + // 1. Open the MCP server management dialog await webviewPage.evaluate(() => { (window as any).simulateExtensionMessage({ - command: 'showConfiguration', - configurationData: { - apiKey: 'test-key', - baseURL: 'https://api.example.com', - model: 'test-model', - fastModel: 'fast-model', - language: 'Chinese' - } + command: 'showDialog', + dialogType: 'mcp' }); }); - // 2. Click on "MCP 服务器" tab - await webviewPage.getByText('MCP 服务器', { exact: true }).click(); - - // 3. Simulate empty servers list + // 2. Simulate empty servers list await webviewPage.evaluate(() => { window.postMessage({ command: 'mcpServersResponse', @@ -130,24 +112,15 @@ test.describe('MCP Server Tab Demo', () => { }); test('should handle connect/disconnect actions', async ({ webviewPage }) => { - // 1. Open the configuration dialog + // 1. Open the MCP server management dialog await webviewPage.evaluate(() => { (window as any).simulateExtensionMessage({ - command: 'showConfiguration', - configurationData: { - apiKey: 'test-key', - baseURL: 'https://api.example.com', - model: 'test-model', - fastModel: 'fast-model', - language: 'Chinese' - } + command: 'showDialog', + dialogType: 'mcp' }); }); - // 2. Click on "MCP 服务器" tab - await webviewPage.getByText('MCP 服务器', { exact: true }).click(); - - // 3. Simulate servers list + // 2. Simulate servers list await webviewPage.evaluate(() => { window.postMessage({ command: 'mcpServersResponse', @@ -163,13 +136,10 @@ test.describe('MCP Server Tab Demo', () => { }, '*'); }); - // 4. Click connect button + // 3. Click connect button await webviewPage.getByRole('button', { name: '连接' }).click(); - // Verify backend receives the connect command - // (In demo mode, we verify the UI interaction rather than backend response) - - // 5. Simulate the backend response after connection + // 4. Simulate the backend response after connection await webviewPage.evaluate(() => { window.postMessage({ command: 'mcpServersResponse', diff --git a/e2e/demo/model-dialog.demo.ts b/e2e/demo/model-dialog.demo.ts new file mode 100644 index 0000000..31862da --- /dev/null +++ b/e2e/demo/model-dialog.demo.ts @@ -0,0 +1,65 @@ +import { test, expect } from '../utils/webviewTestHarness.js'; + +test.describe('Model Dialog Demo', () => { + test('should show model dialog with pre-filled values', async ({ webviewPage }) => { + // 1. Open the model dialog via showDialog + await webviewPage.evaluate(() => { + (window as any).simulateExtensionMessage({ + command: 'showDialog', + dialogType: 'model' + }); + }); + + // 2. Simulate extension sending configuration data + await webviewPage.evaluate(() => { + (window as any).simulateExtensionMessage({ + command: 'configurationResponse', + configurationData: { + model: 'claude-sonnet-4-20250514', + fastModel: 'claude-haiku-4-20250514' + } + }); + }); + + // Verify dialog is visible + await expect(webviewPage.getByText('模型设置', { exact: true })).toBeVisible(); + + // Verify model input is pre-filled + await expect(webviewPage.locator('#model-select')).toHaveValue('claude-sonnet-4-20250514'); + await expect(webviewPage.locator('#fast-model-select')).toHaveValue('claude-haiku-4-20250514'); + + // Take screenshot + const dialog = webviewPage.locator('.configuration-dialog'); + await dialog.screenshot({ path: 'docs/public/screenshots/spec-model-dialog.png' }); + }); + + test('should show model dialog with empty values and env placeholders', async ({ webviewPage }) => { + await webviewPage.evaluate(() => { + (window as any).simulateExtensionMessage({ + command: 'showDialog', + dialogType: 'model' + }); + }); + + await webviewPage.evaluate(() => { + (window as any).simulateExtensionMessage({ + command: 'configurationResponse', + configurationData: { + model: '', + fastModel: '', + envModel: 'WAVE_MODEL', + envFastModel: 'WAVE_FAST_MODEL' + } + }); + }); + + await expect(webviewPage.getByText('模型设置', { exact: true })).toBeVisible(); + + // Verify placeholders are shown + const modelInput = webviewPage.locator('#model-select'); + await expect(modelInput).toHaveAttribute('placeholder', /WAVE_MODEL/); + + const dialog = webviewPage.locator('.configuration-dialog'); + await dialog.screenshot({ path: 'docs/public/screenshots/spec-model-dialog-empty.png' }); + }); +}); diff --git a/e2e/demo/plugin-management.demo.ts b/e2e/demo/plugin-management.demo.ts index c1a085e..0249b25 100644 --- a/e2e/demo/plugin-management.demo.ts +++ b/e2e/demo/plugin-management.demo.ts @@ -5,33 +5,18 @@ test.describe('Plugin Management Screenshots', () => { // Set viewport size for better screenshots await webviewPage.setViewportSize({ width: 500, height: 700 }); - // 1. Open configuration dialog + // 1. Open plugin management dialog await webviewPage.evaluate(() => { (window as any).simulateExtensionMessage({ - command: 'setInitialState', - messages: [], - isStreaming: false, - sessions: [], - configurationData: { - apiKey: 'sk-xxxxxxxxxxxxxxxx', - baseURL: 'https://api.openai.com/v1', - model: 'gpt-4', - fastModel: 'gpt-3.5-turbo', - language: 'Chinese' - }, - permissionMode: 'default' + command: 'showDialog', + dialogType: 'plugin' }); }); - // Click configuration button - await webviewPage.click('.configuration-button'); - await webviewPage.waitForSelector('.configuration-dialog'); - - // 2. Click "插件" tab - await webviewPage.getByText('插件', { exact: true }).click(); + await expect(webviewPage.getByText('插件管理', { exact: true })).toBeVisible(); await expect(webviewPage.getByText('探索新插件', { exact: true })).toBeVisible(); - // 3. Explore plugins tab - 展示可安装的插件列表和作用域选择 + // 2. Explore plugins tab - 展示可安装的插件列表和作用域选择 await webviewPage.evaluate(() => { window.postMessage({ command: 'listPluginsResponse', @@ -85,74 +70,74 @@ test.describe('Plugin Management Screenshots', () => { }); await webviewPage.waitForSelector('.plugin-item'); - + // 确保可以看到可安装的插件列表 await expect(webviewPage.getByText('GitHub Integration')).toBeVisible(); await expect(webviewPage.getByText('Docker Helper')).toBeVisible(); - + // 截图:探索新插件列表页 await webviewPage.screenshot({ path: 'docs/public/screenshots/spec-plugin-explore-list.png' }); - + // 点击一个插件查看详情 await webviewPage.getByText('GitHub Integration').click(); - + // 等待详情页显示 await expect(webviewPage.getByText('返回列表')).toBeVisible(); await expect(webviewPage.getByText('选择安装作用域')).toBeVisible(); await expect(webviewPage.getByText('为你安装 (user)')).toBeVisible(); await expect(webviewPage.getByText('为此仓库的所有协作者安装 (project)')).toBeVisible(); await expect(webviewPage.getByText('仅为你在此仓库中安装 (local)')).toBeVisible(); - + // 截图:插件详情页,显示安装作用域选择 await webviewPage.screenshot({ path: 'docs/public/screenshots/spec-plugin-explore.png' }); - + // 返回列表 await webviewPage.getByText('返回列表').click(); await expect(webviewPage.getByText('GitHub Integration')).toBeVisible(); - // 4. Installed plugins tab - 展示已激活插件及更新和卸载按钮 + // 3. Installed plugins tab - 展示已激活插件及更新和卸载按钮 await webviewPage.getByText('已安装插件', { exact: true }).click(); - + // 等待已安装插件渲染 await webviewPage.waitForSelector('.plugin-item:has-text("Code Reviewer")'); - + // 确认更新和卸载按钮存在 await expect(webviewPage.locator('.update-btn').first()).toBeVisible(); await expect(webviewPage.locator('.uninstall-btn').first()).toBeVisible(); - + // 截图:已激活插件标签页,显示更新和卸载按钮 await webviewPage.screenshot({ path: 'docs/public/screenshots/spec-plugin-installed.png' }); - // 5. Marketplaces tab - 展示插件市场管理 + // 4. Marketplaces tab - 展示插件市场管理 await webviewPage.getByText('插件市场', { exact: true }).click(); - + // Simulate marketplace list await webviewPage.evaluate(() => { window.postMessage({ command: 'listMarketplacesResponse', marketplaces: [ - { - name: 'official', - url: 'https://github.com/wave-ai/official-plugins' + { + name: 'official', + url: 'https://github.com/wave-ai/official-plugins' }, - { - name: 'community', - url: 'https://github.com/wave-community/plugins' + { + name: 'community', + url: 'https://github.com/wave-community/plugins' }, - { - name: 'my-custom', - url: '/home/user/my-local-plugins' + { + name: 'my-custom', + url: '/home/user/my-local-plugins' } ] }, '*'); }); await webviewPage.waitForSelector('.marketplace-item'); - + // 确保输入框和按钮可见 await expect(webviewPage.locator('input[placeholder*="市场 URL"]')).toBeVisible(); await expect(webviewPage.getByText('添加', { exact: true })).toBeVisible(); - + // 截图:插件市场标签页,显示市场列表和管理功能 await webviewPage.screenshot({ path: 'docs/public/screenshots/spec-plugin-marketplaces.png' }); diff --git a/e2e/demo/plugin-search.demo.ts b/e2e/demo/plugin-search.demo.ts index 706ffc4..59b487f 100644 --- a/e2e/demo/plugin-search.demo.ts +++ b/e2e/demo/plugin-search.demo.ts @@ -2,26 +2,17 @@ import { test, expect } from '../utils/webviewTestHarness.js'; test.describe('Plugin Search UI Demo', () => { test('should show search input and filter plugins by keyword', async ({ webviewPage }) => { - // 1. Open the configuration dialog + // 1. Open the plugin management dialog await webviewPage.evaluate(() => { (window as any).simulateExtensionMessage({ - command: 'showConfiguration', - configurationData: { - apiKey: 'test-key', - baseURL: 'https://api.example.com', - model: 'test-model', - fastModel: 'fast-model', - language: 'Chinese' - } + command: 'showDialog', + dialogType: 'plugin' }); }); - await expect(webviewPage.getByText('配置设置', { exact: true })).toBeVisible(); + await expect(webviewPage.getByText('插件管理', { exact: true })).toBeVisible(); - // 2. Click on "插件" tab - await webviewPage.getByText('插件', { exact: true }).click(); - - // 3. Simulate receiving plugins list + // 2. Simulate receiving plugins list await webviewPage.evaluate(() => { window.postMessage({ command: 'listPluginsResponse', @@ -62,7 +53,7 @@ test.describe('Plugin Search UI Demo', () => { }, '*'); }); - // 4. Verify search input is visible + // 3. Verify search input is visible const searchInput = webviewPage.locator('.plugin-search input'); await expect(searchInput).toBeVisible(); await expect(searchInput).toHaveAttribute('placeholder', '搜索插件...'); @@ -70,7 +61,7 @@ test.describe('Plugin Search UI Demo', () => { // Screenshot of search input with all plugins await webviewPage.screenshot({ path: 'docs/public/screenshots/plugin-search-input.png' }); - // 5. Type "commit" to filter + // 4. Type "commit" to filter await searchInput.fill('commit'); // Verify only commit-commands is shown @@ -81,13 +72,13 @@ test.describe('Plugin Search UI Demo', () => { await webviewPage.screenshot({ path: 'docs/public/screenshots/plugin-search-filtered.png' }); - // 6. Type a non-matching query + // 5. Type a non-matching query await searchInput.fill('zzz-nonexistent'); await expect(webviewPage.getByText('没有找到匹配的插件')).toBeVisible(); await webviewPage.screenshot({ path: 'docs/public/screenshots/plugin-search-no-results.png' }); - // 7. Clear the search + // 6. Clear the search await searchInput.fill(''); await expect(webviewPage.locator('.plugin-name', { hasText: 'commit-commands' })).toBeVisible(); await expect(webviewPage.locator('.plugin-name', { hasText: 'document-skills' })).toBeVisible(); diff --git a/e2e/demo/plugin-status-badge.demo.ts b/e2e/demo/plugin-status-badge.demo.ts index 97da86b..f5cca4c 100644 --- a/e2e/demo/plugin-status-badge.demo.ts +++ b/e2e/demo/plugin-status-badge.demo.ts @@ -2,24 +2,15 @@ import { test, expect } from '../utils/webviewTestHarness.js'; test.describe('Plugin Status Badge Logic', () => { test('should only show "Installed" badge if plugin is both installed and enabled', async ({ webviewPage }) => { - // 1. Open the configuration dialog + // 1. Open the plugin management dialog await webviewPage.evaluate(() => { (window as any).simulateExtensionMessage({ - command: 'showConfiguration', - configurationData: { - apiKey: 'test-key', - baseURL: 'https://api.example.com', - model: 'test-model', - fastModel: 'fast-model', - language: 'Chinese' - } + command: 'showDialog', + dialogType: 'plugin' }); }); - // 2. Click on "插件" tab - await webviewPage.getByText('插件', { exact: true }).click(); - - // 3. Simulate receiving plugins list with different states + // 2. Simulate receiving plugins list with different states await webviewPage.evaluate(() => { window.postMessage({ command: 'listPluginsResponse', @@ -63,19 +54,19 @@ test.describe('Plugin Status Badge Logic', () => { }, '*'); }); - // 4. Verify "Installed User Scope" plugin has the [user] badge + // 3. Verify "Installed User Scope" plugin has the [user] badge const userItem = webviewPage.locator('.plugin-item').filter({ hasText: 'Installed User Scope' }); await expect(userItem.locator('.plugin-scope')).toHaveText('[user]'); - // 5. Verify "Installed Project Scope" plugin has the [project] badge + // 4. Verify "Installed Project Scope" plugin has the [project] badge const projectItem = webviewPage.locator('.plugin-item').filter({ hasText: 'Installed Project Scope' }); await expect(projectItem.locator('.plugin-scope')).toHaveText('[project]'); - // 6. Verify "Installed No Scope" plugin does NOT have the badge + // 5. Verify "Installed No Scope" plugin does NOT have the badge const noScopeItem = webviewPage.locator('.plugin-item').filter({ hasText: 'Installed No Scope' }); await expect(noScopeItem.locator('.plugin-scope')).not.toBeVisible(); - // 7. Verify "Not Installed" plugin does NOT have the badge + // 6. Verify "Not Installed" plugin does NOT have the badge const notInstalledItem = webviewPage.locator('.plugin-item').filter({ hasText: 'Not Installed' }); await expect(notInstalledItem.locator('.plugin-scope')).not.toBeVisible(); diff --git a/e2e/demo/plugins-ui.demo.ts b/e2e/demo/plugins-ui.demo.ts index 7c755d0..e3ab53f 100644 --- a/e2e/demo/plugins-ui.demo.ts +++ b/e2e/demo/plugins-ui.demo.ts @@ -1,33 +1,24 @@ import { test, expect } from '../utils/webviewTestHarness.js'; -test.describe('Plugin Configuration UI Demo', () => { - test('should show plugin tabs and content in configuration dialog', async ({ webviewPage }) => { - // 1. Open the configuration dialog +test.describe('Plugin Management Dialog Demo', () => { + test('should show plugin tabs and content in plugin dialog', async ({ webviewPage }) => { + // 1. Open the plugin management dialog via showDialog await webviewPage.evaluate(() => { (window as any).simulateExtensionMessage({ - command: 'showConfiguration', - configurationData: { - apiKey: 'test-key', - baseURL: 'https://api.example.com', - model: 'test-model', - fastModel: 'fast-model', - language: 'Chinese' - } + command: 'showDialog', + dialogType: 'plugin' }); }); // Verify dialog is visible - await expect(webviewPage.getByText('配置设置', { exact: true })).toBeVisible(); - - // 2. Click on "插件" tab - await webviewPage.getByText('插件', { exact: true }).click(); - + await expect(webviewPage.getByText('插件管理', { exact: true })).toBeVisible(); + // Verify plugin sub-tabs are visible await expect(webviewPage.getByText('探索新插件', { exact: true })).toBeVisible(); await expect(webviewPage.getByText('已安装插件', { exact: true })).toBeVisible(); await expect(webviewPage.getByText('插件市场', { exact: true })).toBeVisible(); - // 3. Simulate receiving plugins list + // 2. Simulate receiving plugins list await webviewPage.evaluate(() => { window.postMessage({ command: 'listPluginsResponse', @@ -79,9 +70,9 @@ test.describe('Plugin Configuration UI Demo', () => { // Take screenshot of "Explore" tab await webviewPage.screenshot({ path: 'docs/public/screenshots/plugins-explore-tab.png' }); - // 4. Switch to "已安装插件" tab + // 3. Switch to "已安装插件" tab await webviewPage.getByText('已安装插件', { exact: true }).click(); - + // Simulate receiving installed plugins list await webviewPage.evaluate(() => { window.postMessage({ @@ -103,9 +94,9 @@ test.describe('Plugin Configuration UI Demo', () => { await expect(webviewPage.locator('.plugin-name').filter({ hasText: 'Installed Plugin' })).toBeVisible(); await webviewPage.screenshot({ path: 'docs/public/screenshots/plugins-installed-tab.png' }); - // 5. Switch to "插件市场" tab + // 4. Switch to "插件市场" tab await webviewPage.getByText('插件市场', { exact: true }).click(); - + // Simulate receiving marketplaces list await webviewPage.evaluate(() => { window.postMessage({ @@ -115,7 +106,7 @@ test.describe('Plugin Configuration UI Demo', () => { ] }, '*'); }); - + await expect(webviewPage.getByText('wave-plugins-official', { exact: true })).toBeVisible(); await webviewPage.screenshot({ path: 'docs/public/screenshots/plugins-marketplaces-tab.png' }); }); diff --git a/e2e/demo/product-spec-confirmations.demo.ts b/e2e/demo/product-spec-confirmations.demo.ts index e4c562f..50df2f5 100644 --- a/e2e/demo/product-spec-confirmations.demo.ts +++ b/e2e/demo/product-spec-confirmations.demo.ts @@ -85,9 +85,8 @@ test.describe('Product Specification Screenshots - Confirmations', () => { await webviewPage.keyboard.press('Escape'); await webviewPage.waitForSelector('.confirmation-dialog', { state: 'hidden' }); - // 11. Configuration Dialog - // Update config - await injector.simulateExtensionMessage('configurationResponse', { + // 11. Configuration Dialog (opened via /config or showConfiguration message) + await injector.simulateExtensionMessage('showConfiguration', { configurationData: { apiKey: 'sk-xxxxxxxxxxxxxxxx', baseURL: 'https://api.openai.com/v1', @@ -95,7 +94,6 @@ test.describe('Product Specification Screenshots - Confirmations', () => { fastModel: 'gpt-3.5-turbo' } }); - await webviewPage.click('.configuration-button'); await webviewPage.waitForSelector('.configuration-dialog'); await webviewPage.screenshot({ path: 'docs/public/screenshots/spec-configuration.png' }); await webviewPage.keyboard.press('Escape'); diff --git a/e2e/demo/status-dialog.demo.ts b/e2e/demo/status-dialog.demo.ts new file mode 100644 index 0000000..2d67268 --- /dev/null +++ b/e2e/demo/status-dialog.demo.ts @@ -0,0 +1,100 @@ +import { test, expect } from '../utils/webviewTestHarness.js'; + +test.describe('Status Dialog Demo', () => { + test('should show status dialog with all info fields', async ({ webviewPage }) => { + // 1. Open the status dialog via showDialog + await webviewPage.evaluate(() => { + (window as any).simulateExtensionMessage({ + command: 'showDialog', + dialogType: 'status' + }); + }); + + // 2. Simulate extension sending configuration data + await webviewPage.evaluate(() => { + (window as any).simulateExtensionMessage({ + command: 'configurationResponse', + configurationData: { + model: 'claude-sonnet-4-20250514', + fastModel: 'claude-haiku-4-20250514', + baseURL: 'https://api.example.com/v1', + serverUrl: 'https://wave.example.com' + } + }); + }); + + // 3. Simulate extension sending status response + await webviewPage.evaluate(() => { + (window as any).simulateExtensionMessage({ + command: 'statusResponse', + version: '0.4.15', + sessionId: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890', + workdir: '/home/user/projects/my-app', + configurationData: { + model: 'claude-sonnet-4-20250514', + fastModel: 'claude-haiku-4-20250514', + baseURL: 'https://api.example.com/v1', + serverUrl: 'https://wave.example.com' + } + }); + }); + + // 4. Simulate auth status (authenticated) + await webviewPage.evaluate(() => { + (window as any).simulateExtensionMessage({ + command: 'authStatusResponse', + isAuthenticated: true, + user: { id: 'user-abc123', email: 'developer@example.com' } + }); + }); + + // Verify dialog title + await expect(webviewPage.getByText('状态信息', { exact: true })).toBeVisible(); + + // Verify key info fields are displayed + await expect(webviewPage.getByText('0.4.15')).toBeVisible(); + await expect(webviewPage.getByText('a1b2c3d4-e5f6-7890-abcd-ef1234567890')).toBeVisible(); + await expect(webviewPage.getByText('/home/user/projects/my-app')).toBeVisible(); + await expect(webviewPage.getByText('claude-sonnet-4-20250514').first()).toBeVisible(); + await expect(webviewPage.getByText('developer@example.com')).toBeVisible(); + + // Take screenshot + const dialog = webviewPage.locator('.configuration-dialog'); + await dialog.screenshot({ path: 'docs/public/screenshots/spec-status-dialog.png' }); + }); + + test('should show status dialog with unauthenticated state', async ({ webviewPage }) => { + await webviewPage.evaluate(() => { + (window as any).simulateExtensionMessage({ + command: 'showDialog', + dialogType: 'status' + }); + }); + + // Simulate status with minimal config + await webviewPage.evaluate(() => { + (window as any).simulateExtensionMessage({ + command: 'statusResponse', + version: '0.4.15', + sessionId: '', + workdir: '', + configurationData: {} + }); + }); + + // Simulate not authenticated + await webviewPage.evaluate(() => { + (window as any).simulateExtensionMessage({ + command: 'authStatusResponse', + isAuthenticated: false, + user: null + }); + }); + + await expect(webviewPage.getByText('状态信息', { exact: true })).toBeVisible(); + await expect(webviewPage.getByText('未登录')).toBeVisible(); + + const dialog = webviewPage.locator('.configuration-dialog'); + await dialog.screenshot({ path: 'docs/public/screenshots/spec-status-dialog-noauth.png' }); + }); +}); diff --git a/e2e/webview/model-status-login-commands.test.ts b/e2e/webview/model-status-login-commands.test.ts new file mode 100644 index 0000000..9df2c33 --- /dev/null +++ b/e2e/webview/model-status-login-commands.test.ts @@ -0,0 +1,224 @@ +import { test, expect } from '../utils/webviewTestHarness.js'; + +/** + * Test /model, /status, /login slash commands + * These commands are intercepted locally (never sent to agent SDK) and open dialogs. + */ + +test.describe('Model/Status/Login Slash Commands', () => { + test('should open model dialog when /model is typed and Enter pressed', async ({ webviewPage }) => { + const input = webviewPage.getByTestId('message-input'); + await input.focus(); + + // Simulate extension sending configuration data first + await webviewPage.evaluate(() => { + (window as any).simulateExtensionMessage({ + command: 'configurationResponse', + configurationData: { + model: 'gpt-4', + fastModel: 'gpt-4-mini', + baseURL: 'https://api.example.com/v1', + serverUrl: 'https://wave.example.com' + } + }); + }); + + // Type /model and press Enter + await input.type('/model'); + await webviewPage.keyboard.press('Enter'); + + // Verify model dialog is visible + await expect(webviewPage.getByText('模型设置', { exact: true })).toBeVisible(); + + // Verify model input is pre-filled + const modelInput = webviewPage.locator('#model-select'); + await expect(modelInput).toHaveValue('gpt-4'); + + const fastModelInput = webviewPage.locator('#fast-model-select'); + await expect(fastModelInput).toHaveValue('gpt-4-mini'); + + // Verify sendMessage was NOT sent to extension (it's a local command) + const messages = await webviewPage.evaluate(() => (window as any).getTestMessages()); + expect(messages.some((m: any) => m.command === 'sendMessage')).toBe(false); + }); + + test('should open status dialog when /status is typed and Enter pressed', async ({ webviewPage }) => { + const input = webviewPage.getByTestId('message-input'); + await input.focus(); + + // Type /status and press Enter + await input.type('/status'); + await webviewPage.keyboard.press('Enter'); + + // Verify status dialog is visible + await expect(webviewPage.getByText('状态信息', { exact: true })).toBeVisible(); + + // Simulate extension sending status response + await webviewPage.evaluate(() => { + (window as any).simulateExtensionMessage({ + command: 'statusResponse', + version: '0.4.15', + sessionId: 'test-session-123', + workdir: '/home/user/project', + configurationData: { + model: 'gpt-4', + fastModel: 'gpt-4-mini', + baseURL: 'https://api.example.com/v1', + serverUrl: 'https://wave.example.com' + } + }); + }); + + // Verify status info is displayed + await expect(webviewPage.getByText('0.4.15')).toBeVisible(); + await expect(webviewPage.getByText('test-session-123')).toBeVisible(); + await expect(webviewPage.getByText('/home/user/project')).toBeVisible(); + + // Verify getStatus message was sent to extension + const messages = await webviewPage.evaluate(() => (window as any).getTestMessages()); + expect(messages.some((m: any) => m.command === 'getStatus')).toBe(true); + }); + + test('should open login dialog when /login is typed and Enter pressed', async ({ webviewPage }) => { + const input = webviewPage.getByTestId('message-input'); + await input.focus(); + + // Simulate extension sending configuration data with serverUrl + await webviewPage.evaluate(() => { + (window as any).simulateExtensionMessage({ + command: 'configurationResponse', + configurationData: { + serverUrl: 'https://wave.example.com' + } + }); + }); + + // Type /login and press Enter + await input.type('/login'); + await webviewPage.keyboard.press('Enter'); + + // Verify login dialog is visible + await expect(webviewPage.getByText('SSO 认证', { exact: true })).toBeVisible(); + + // Verify server URL is displayed + await expect(webviewPage.getByText('https://wave.example.com')).toBeVisible(); + + // Verify getAuthStatus message was sent to extension + const messages = await webviewPage.evaluate(() => (window as any).getTestMessages()); + expect(messages.some((m: any) => m.command === 'getAuthStatus')).toBe(true); + }); + + test('should close dialog on Escape key', async ({ webviewPage }) => { + const input = webviewPage.getByTestId('message-input'); + await input.focus(); + + // Open model dialog + await input.type('/model'); + await webviewPage.keyboard.press('Enter'); + + await expect(webviewPage.getByText('模型设置', { exact: true })).toBeVisible(); + + // Press Escape to close + await webviewPage.keyboard.press('Escape'); + + // Verify dialog is closed + await expect(webviewPage.getByText('模型设置', { exact: true })).not.toBeVisible(); + }); + + test('should save model via setModel message', async ({ webviewPage }) => { + const input = webviewPage.getByTestId('message-input'); + await input.focus(); + + // Simulate extension sending configuration data + await webviewPage.evaluate(() => { + (window as any).simulateExtensionMessage({ + command: 'configurationResponse', + configurationData: { + model: 'gpt-4', + fastModel: 'gpt-4-mini' + } + }); + }); + + // Open model dialog + await input.type('/model'); + await webviewPage.keyboard.press('Enter'); + + // Change model value + const modelInput = webviewPage.locator('#model-select'); + await modelInput.fill('claude-opus-4'); + + // Click save button + await webviewPage.getByText('保存', { exact: true }).click(); + + // Verify setModel message was sent with correct data + const messages = await webviewPage.evaluate(() => (window as any).getTestMessages()); + const setModelMsg = messages.find((m: any) => m.command === 'setModel'); + expect(setModelMsg).toBeDefined(); + expect(setModelMsg.configurationData.model).toBe('claude-opus-4'); + expect(setModelMsg.configurationData.fastModel).toBe('gpt-4-mini'); + }); + + test('should show login dialog with not-authenticated state', async ({ webviewPage }) => { + const input = webviewPage.getByTestId('message-input'); + await input.focus(); + + // Simulate extension sending configuration data + await webviewPage.evaluate(() => { + (window as any).simulateExtensionMessage({ + command: 'configurationResponse', + configurationData: { + serverUrl: 'https://wave.example.com' + } + }); + }); + + // Type /login and press Enter + await input.type('/login'); + await webviewPage.keyboard.press('Enter'); + + // Simulate auth status response (not authenticated) + await webviewPage.evaluate(() => { + (window as any).simulateExtensionMessage({ + command: 'authStatusResponse', + isAuthenticated: false, + user: null + }); + }); + + // Verify "SSO 登录" button is visible + await expect(webviewPage.getByText('SSO 登录', { exact: true })).toBeVisible(); + }); + + test('should show login dialog with authenticated state', async ({ webviewPage }) => { + const input = webviewPage.getByTestId('message-input'); + await input.focus(); + + // Simulate extension sending configuration data + await webviewPage.evaluate(() => { + (window as any).simulateExtensionMessage({ + command: 'configurationResponse', + configurationData: { + serverUrl: 'https://wave.example.com' + } + }); + }); + + // Type /login and press Enter + await input.type('/login'); + await webviewPage.keyboard.press('Enter'); + + // Simulate auth status response (authenticated) + await webviewPage.evaluate(() => { + (window as any).simulateExtensionMessage({ + command: 'authStatusResponse', + isAuthenticated: true, + user: { id: 'user-123', email: 'test@example.com' } + }); + }); + + // Verify user email and logout button are visible + await expect(webviewPage.getByText('test@example.com')).toBeVisible(); + await expect(webviewPage.getByText('登出', { exact: true })).toBeVisible(); + }); +}); diff --git a/src/session/messageHandler.ts b/src/session/messageHandler.ts index 8adedd8..2a634f3 100644 --- a/src/session/messageHandler.ts +++ b/src/session/messageHandler.ts @@ -145,6 +145,12 @@ export class MessageHandler { case 'logout': await this.handleLogout(viewType, windowId); break; + case 'getStatus': + await this.handleGetStatus(viewType, windowId); + break; + case 'setModel': + await this.handleSetModel(message.configurationData, viewType, windowId); + break; case 'getMcpServers': await this.handleGetMcpServers(viewType, windowId); break; @@ -593,7 +599,20 @@ export class MessageHandler { private async handleSlashCommandsRequest(filterText: string, viewType?: 'sidebar' | 'tab' | 'window', windowId?: string) { const session = this.context.getChatSession(viewType || 'tab', windowId); try { - const allCommands = session.getSlashCommands(); + const sdkCommands = session.getSlashCommands(); + + // Local UI slash commands (not in SDK, intercepted in webview) + const localCommands = [ + { id: 'config', name: 'config', description: '打开配置设置' }, + { id: 'plugin', name: 'plugin', description: '打开插件管理' }, + { id: 'mcp', name: 'mcp', description: '打开 MCP 服务器管理' }, + { id: 'model', name: 'model', description: '切换 AI 模型' }, + { id: 'status', name: 'status', description: '查看当前状态' }, + { id: 'login', name: 'login', description: 'SSO 登录/登出' } + ]; + + const allCommands = [...sdkCommands, ...localCommands]; + let filteredCommands = allCommands; if (filterText && filterText.trim().length > 0) { const filter = filterText.toLowerCase(); @@ -697,6 +716,40 @@ export class MessageHandler { } } + private async handleGetStatus(viewType?: 'sidebar' | 'tab' | 'window', windowId?: string) { + const session = this.context.getChatSession(viewType || 'tab', windowId); + const config = await this.configService.loadConfiguration(); + const version = vscode.extensions.getExtension('wave-code.wave-code')?.packageJSON?.version || ''; + + this.context.postMessage({ + command: 'statusResponse', + version, + sessionId: session.sessionId || '', + workdir: session.agent?.workingDirectory || '', + configurationData: config + }, viewType, windowId); + } + + private async handleSetModel(configData: any, viewType?: 'sidebar' | 'tab' | 'window', windowId?: string) { + try { + const currentConfig = await this.configService.loadConfiguration(); + const mergedConfig = { ...currentConfig, ...configData }; + await this.configService.saveConfiguration(mergedConfig); + const config = await this.configService.loadConfiguration(); + + this.context.updateAllSessionsConfig(config); + + this.context.postMessage({ command: 'configurationUpdated' }, viewType, windowId); + this.context.postMessage({ command: 'focusInput' }, viewType, windowId); + } catch (error) { + console.error(`Failed to set model for ${viewType}:`, error); + this.context.postMessage({ + command: 'configurationError', + error: 'Failed to save model: ' + error + }, viewType, windowId); + } + } + private async handleGetMcpServers(viewType?: 'sidebar' | 'tab' | 'window', windowId?: string) { const session = this.context.getChatSession(viewType || 'tab', windowId); const servers = session.getMcpServers(); diff --git a/webview/src/components/ChatApp.tsx b/webview/src/components/ChatApp.tsx index abf2be3..168684a 100644 --- a/webview/src/components/ChatApp.tsx +++ b/webview/src/components/ChatApp.tsx @@ -5,7 +5,12 @@ import { ChatHeader } from './ChatHeader'; import { TaskList } from './TaskList'; import { QueuedMessageList } from './QueuedMessageList'; import { ConfirmationDialog } from './ConfirmationDialog'; -import ConfigurationDialog from './ConfigurationDialog'; +import ConfigDialog from './ConfigDialog'; +import PluginDialog from './PluginDialog'; +import McpDialog from './McpDialog'; +import ModelDialog from './ModelDialog'; +import StatusDialog from './StatusDialog'; +import LoginDialog from './LoginDialog'; import type { ChatAppProps, ChatState, @@ -31,8 +36,8 @@ const initialState: ChatState = { sessionsLoading: false, pendingConfirmations: [], queuedMessages: [], - // Configuration state - showConfiguration: false, + // Dialog state + activeDialog: null, configurationData: undefined, configurationLoading: false, configurationError: undefined, @@ -119,21 +124,20 @@ function chatReducer(state: ChatState, action: ChatAction): ChatState { ...state, pendingConfirmations: state.pendingConfirmations.filter(c => c.confirmationId !== action.payload) }; - case 'SHOW_CONFIGURATION': + case 'SHOW_DIALOG': return { ...state, - showConfiguration: true, - configurationData: action.payload.data, + activeDialog: action.payload.type, + configurationData: action.payload.data ?? state.configurationData, configurationLoading: false, configurationError: action.payload.error }; - case 'HIDE_CONFIGURATION': - return { - ...state, - showConfiguration: false, - configurationData: action.payload || state.configurationData, - configurationError: undefined - }; + case 'HIDE_DIALOG': + return { + ...state, + activeDialog: null, + configurationError: undefined + }; case 'SET_CONFIGURATION_LOADING': return { ...state, @@ -300,15 +304,24 @@ export const ChatApp: React.FC = ({ vscode }) => { break; case 'showConfiguration': dispatch({ - type: 'SHOW_CONFIGURATION', + type: 'SHOW_DIALOG', payload: { + type: 'config' as const, data: message.configurationData || stateRef.current.configurationData || {}, error: message.error } }); break; + case 'showDialog': + dispatch({ type: 'SHOW_DIALOG', payload: { type: message.dialogType } }); + break; case 'configurationUpdated': - dispatch({ type: 'HIDE_CONFIGURATION' }); + dispatch({ type: 'HIDE_DIALOG' }); + break; + case 'statusResponse': + if (message.configurationData) { + dispatch({ type: 'SET_CONFIGURATION_DATA', payload: message.configurationData }); + } break; case 'configurationError': dispatch({ type: 'SET_CONFIGURATION_ERROR', payload: message.error }); @@ -336,6 +349,34 @@ export const ChatApp: React.FC = ({ vscode }) => { const trimmedText = text.trim(); if (!trimmedText && (!images || images.length === 0)) return; + // Intercept local slash commands — open dialogs instead of sending to agent + if (trimmedText === '/config') { + dispatch({ type: 'SHOW_DIALOG', payload: { type: 'config', data: stateRef.current.configurationData || {} } }); + vscode.postMessage({ command: 'getConfiguration' }); + return; + } + if (trimmedText === '/plugin') { + dispatch({ type: 'SHOW_DIALOG', payload: { type: 'plugin' } }); + return; + } + if (trimmedText === '/mcp') { + dispatch({ type: 'SHOW_DIALOG', payload: { type: 'mcp' } }); + return; + } + if (trimmedText === '/model') { + dispatch({ type: 'SHOW_DIALOG', payload: { type: 'model', data: stateRef.current.configurationData || {} } }); + vscode.postMessage({ command: 'getConfiguration' }); + return; + } + if (trimmedText === '/status') { + dispatch({ type: 'SHOW_DIALOG', payload: { type: 'status', data: stateRef.current.configurationData || {} } }); + return; + } + if (trimmedText === '/login') { + dispatch({ type: 'SHOW_DIALOG', payload: { type: 'login', data: stateRef.current.configurationData || {} } }); + return; + } + // Send to extension vscode.postMessage({ command: 'sendMessage', @@ -387,16 +428,6 @@ export const ChatApp: React.FC = ({ vscode }) => { }, [state.queuedMessages, handleSendMessage, handleDeleteQueuedMessage]); // Configuration handlers - const handleConfigurationOpen = useCallback(() => { - dispatch({ - type: 'SHOW_CONFIGURATION', - payload: { data: state.configurationData || {} } - }); - vscode.postMessage({ - command: 'getConfiguration' - }); - }, [vscode, state.configurationData]); - const handleConfigurationSave = useCallback((configData: any) => { dispatch({ type: 'SET_CONFIGURATION_LOADING', payload: true }); vscode.postMessage({ @@ -405,8 +436,15 @@ export const ChatApp: React.FC = ({ vscode }) => { }); }, [vscode]); - const handleConfigurationCancel = useCallback(() => { - dispatch({ type: 'HIDE_CONFIGURATION' }); + const handleModelSave = useCallback((configData: any) => { + vscode.postMessage({ + command: 'setModel', + configurationData: configData + }); + }, [vscode]); + + const handleDialogClose = useCallback(() => { + dispatch({ type: 'HIDE_DIALOG' }); }, []); const handleToggleTaskList = useCallback(() => { @@ -552,13 +590,6 @@ export const ChatApp: React.FC = ({ vscode }) => { shouldClearInput={state.shouldClearInput} onInputCleared={handleInputCleared} vscode={vscode} - showConfiguration={state.showConfiguration} - configurationData={state.configurationData} - configurationLoading={state.configurationLoading} - configurationError={state.configurationError} - onConfigurationOpen={handleConfigurationOpen} - onConfigurationSave={handleConfigurationSave} - onConfigurationCancel={handleConfigurationCancel} selection={state.selection} inputContent={state.inputContent} permissionMode={state.permissionMode} @@ -579,15 +610,44 @@ export const ChatApp: React.FC = ({ vscode }) => { )} - + {state.activeDialog === 'config' && ( + + )} + {state.activeDialog === 'plugin' && ( + + )} + {state.activeDialog === 'mcp' && ( + + )} + {state.activeDialog === 'model' && ( + + )} + {state.activeDialog === 'status' && ( + + )} + {state.activeDialog === 'login' && ( + + )} ); }; \ No newline at end of file diff --git a/webview/src/components/ConfigDialog.tsx b/webview/src/components/ConfigDialog.tsx new file mode 100644 index 0000000..1904146 --- /dev/null +++ b/webview/src/components/ConfigDialog.tsx @@ -0,0 +1,202 @@ +/** + * ConfigDialog - General settings dialog for AI configuration + * + * Opened via the /config slash command. Contains server URL, + * API key, model, and language configuration fields. + */ + +import React, { useState, useEffect, useRef } from 'react'; +import { ConfigDialogProps, ConfigurationData } from '../types'; +import '../styles/ConfigurationDialog.css'; + +const ConfigDialog: React.FC = ({ + configurationData, + isLoading, + error, + onSave, + onCancel, +}) => { + const [formData, setFormData] = useState({ + serverUrl: '', + baseURL: '', + apiKey: '', + headers: '', + model: '', + fastModel: '', + language: 'Chinese' + }); + + const dialogRef = useRef(null); + + // Update form data when configurationData prop changes + useEffect(() => { + if (configurationData) { + setFormData(configurationData); + } + }, [configurationData]); + + // Handle clicking outside to close dialog + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (dialogRef.current && !dialogRef.current.contains(event.target as Node)) { + onCancel(); + } + }; + + const handleEscapeKey = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + onCancel(); + } + }; + + document.addEventListener('mousedown', handleClickOutside); + document.addEventListener('keydown', handleEscapeKey); + + return () => { + document.removeEventListener('mousedown', handleClickOutside); + document.removeEventListener('keydown', handleEscapeKey); + }; + }, [onCancel]); + + const handleInputChange = (field: keyof ConfigurationData, value: string) => { + setFormData(prev => ({ + ...prev, + [field]: value + })); + }; + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + onSave(formData); + }; + + return ( +
+
+
+

配置设置

+
+ +
+
+
+ + handleInputChange('serverUrl', e.target.value)} + placeholder={configurationData?.envServerUrl || 'WAVE_SERVER_URL'} + disabled={isLoading} + /> +
+ +
+ + handleInputChange('baseURL', e.target.value)} + placeholder={configurationData?.envBaseUrl || 'https://api.example.com/v1 (或设置 WAVE_BASE_URL)'} + disabled={isLoading} + /> +
+ +
+ + handleInputChange('apiKey', e.target.value)} + placeholder={configurationData?.envApiKey || '输入 API Key (或设置 WAVE_API_KEY 环境变量)'} + disabled={isLoading} + /> +
+ +
+ +