From f2145de571d93602160d2d8ade36904d987aa148 Mon Sep 17 00:00:00 2001 From: Levi Date: Sat, 5 Sep 2026 20:21:39 +0800 Subject: [PATCH] feat: add MCP support (#1471) --- README.md | 21 +- README_ja-JP.md | 21 +- README_vi-VN.md | 21 +- README_zh-CN.md | 21 +- README_zh-TW.md | 21 +- cmd/web/flags.go | 33 +- cmd/web/flags_test.go | 103 ++- cmd/web/main.go | 1 + go.mod | 24 +- go.sum | 54 +- pkg/base/model.go | 9 +- pkg/mcpserver/server.go | 525 ++++++++++++ pkg/mcpserver/server_test.go | 215 +++++ pkg/rest/api_server_manager.go | 1 + pkg/rest/invoke_test.go | 35 +- pkg/rest/model/server.go | 2 + pkg/rest/server.go | 61 +- pkg/rest/server_test.go | 107 +++ ui/flutter/assets/agent_icons/LICENSE | 21 + ui/flutter/assets/agent_icons/NOTICE.md | 13 + ui/flutter/assets/agent_icons/claude_code.svg | 1 + ui/flutter/assets/agent_icons/cline.svg | 1 + ui/flutter/assets/agent_icons/codebuddy.svg | 1 + ui/flutter/assets/agent_icons/codex.svg | 1 + ui/flutter/assets/agent_icons/cursor.svg | 1 + ui/flutter/assets/agent_icons/deepseek.svg | 1 + ui/flutter/assets/agent_icons/gemini_cli.svg | 1 + .../assets/agent_icons/github_copilot.svg | 1 + ui/flutter/assets/agent_icons/opencode.svg | 1 + ui/flutter/assets/agent_icons/pi.svg | 1 + ui/flutter/assets/agent_icons/qoder.svg | 1 + ui/flutter/assets/agent_icons/trae.svg | 1 + ui/flutter/assets/agent_icons/windsurf.svg | 1 + ui/flutter/assets/agent_icons/zcode.svg | 1 + .../lib/api/model/downloader_config.dart | 9 +- .../lib/api/model/downloader_config.g.dart | 2 + .../application/app_runtime_controller.dart | 30 +- .../lib/core/common/api_server_state.dart | 4 + ui/flutter/lib/core/common/start_config.dart | 1 + .../lib/core/common/start_config.g.dart | 2 + .../core/entry/libgopeed_boot_browser.dart | 1 + .../gopeed/entry/gopeed_transport_web.dart | 4 +- .../core/network/gopeed/gopeed_transport.dart | 39 + .../lib/core/window/app_window_chrome.dart | 9 +- .../presentation/pages/extensions_page.dart | 13 +- .../presentation/widgets/tasks_top_bar.dart | 12 +- .../presentation/pages/settings_page.dart | 336 ++++---- .../widgets/download_categories_setting.dart | 10 +- .../presentation/widgets/mcp_agent_setup.dart | 748 ++++++++++++++++++ .../presentation/widgets/settings_item.dart | 54 +- .../widgets/settings_list_editor.dart | 22 +- .../presentation/widgets/task_drawer.dart | 66 +- ui/flutter/lib/l10n/app_ar.arb | 11 +- ui/flutter/lib/l10n/app_ca.arb | 11 +- ui/flutter/lib/l10n/app_de.arb | 11 +- ui/flutter/lib/l10n/app_en.arb | 11 +- ui/flutter/lib/l10n/app_es.arb | 11 +- ui/flutter/lib/l10n/app_fa.arb | 11 +- ui/flutter/lib/l10n/app_fr.arb | 11 +- ui/flutter/lib/l10n/app_hu.arb | 11 +- ui/flutter/lib/l10n/app_id.arb | 11 +- ui/flutter/lib/l10n/app_it.arb | 11 +- ui/flutter/lib/l10n/app_ja.arb | 11 +- ui/flutter/lib/l10n/app_ko.arb | 11 +- ui/flutter/lib/l10n/app_pl.arb | 11 +- ui/flutter/lib/l10n/app_pt.arb | 11 +- ui/flutter/lib/l10n/app_ru.arb | 11 +- ui/flutter/lib/l10n/app_ta.arb | 11 +- ui/flutter/lib/l10n/app_tr.arb | 11 +- ui/flutter/lib/l10n/app_uk.arb | 11 +- ui/flutter/lib/l10n/app_vi.arb | 11 +- ui/flutter/lib/l10n/app_zh.arb | 11 +- ui/flutter/lib/l10n/app_zh_TW.arb | 11 +- .../shared/widgets/app_copy_icon_button.dart | 59 ++ .../shared/widgets/app_loading_button.dart | 23 +- .../shared/widgets/app_primary_button.dart | 14 +- ui/flutter/pubspec.lock | 40 + ui/flutter/pubspec.yaml | 2 + .../test/api/downloader_config_test.dart | 9 +- .../application/settings_controller_test.dart | 1 + .../widgets/app_loading_button_test.dart | 14 + ui/flutter/test/widget_test.dart | 396 +++++++++- ui/flutter_old/pubspec.lock | 8 +- 83 files changed, 3119 insertions(+), 363 deletions(-) create mode 100644 pkg/mcpserver/server.go create mode 100644 pkg/mcpserver/server_test.go create mode 100644 ui/flutter/assets/agent_icons/LICENSE create mode 100644 ui/flutter/assets/agent_icons/NOTICE.md create mode 100644 ui/flutter/assets/agent_icons/claude_code.svg create mode 100644 ui/flutter/assets/agent_icons/cline.svg create mode 100644 ui/flutter/assets/agent_icons/codebuddy.svg create mode 100644 ui/flutter/assets/agent_icons/codex.svg create mode 100644 ui/flutter/assets/agent_icons/cursor.svg create mode 100644 ui/flutter/assets/agent_icons/deepseek.svg create mode 100644 ui/flutter/assets/agent_icons/gemini_cli.svg create mode 100644 ui/flutter/assets/agent_icons/github_copilot.svg create mode 100644 ui/flutter/assets/agent_icons/opencode.svg create mode 100644 ui/flutter/assets/agent_icons/pi.svg create mode 100644 ui/flutter/assets/agent_icons/qoder.svg create mode 100644 ui/flutter/assets/agent_icons/trae.svg create mode 100644 ui/flutter/assets/agent_icons/windsurf.svg create mode 100644 ui/flutter/assets/agent_icons/zcode.svg create mode 100644 ui/flutter/lib/features/settings/presentation/widgets/mcp_agent_setup.dart create mode 100644 ui/flutter/lib/shared/widgets/app_copy_icon_button.dart diff --git a/README.md b/README.md index 8b021002..b74a1c76 100644 --- a/README.md +++ b/README.md @@ -35,14 +35,33 @@ Visit ✈ [Official Website](https://gopeed.com) - 🗣️ **Available in 20+ languages** — including English, Simplified and Traditional Chinese, Japanese, Korean, and many more. - 🌐 **Browser integration** — send downloads from Chrome, Edge, Firefox, and other compatible browsers directly to Gopeed. - 🧩 **JavaScript extensions** — add support for video platforms, AI model hubs, cloud storage services, and other download sources. +- 🤖 **AI integration** — use Gopeed's MCP endpoint to connect compatible AI agents and create, inspect, or manage downloads with natural language. - 🔌 **Automation-ready** — integrate with Gopeed through its REST API, CLI, authenticated web UI, webhooks, and post-download scripts. - 🛠️ **Built-in essentials** — customize headers and the User-Agent, use proxies and GitHub mirrors, receive notifications, and extract archives automatically. +## 🤖 AI Integration + +Connect Gopeed to an AI agent and manage downloads with natural language. For example, you can say: + +> Download the latest Gopeed client for Windows. + +| Tool | Description | +| --- | --- | +| `resolve_task` | Resolve a download URL or URI and return its resource metadata and files before creating a task. | +| `create_task` | Create and start a task from a resolved resource ID or a direct download request. | +| `list_tasks` | List tasks, optionally filtering them by ID or status. | +| `get_task` | Get the request, resource, options, and current progress for one task. | +| `get_task_status` | Get lightweight runtime status and per-file progress for one task. | +| `get_task_stats` | Get protocol-specific statistics, including HTTP connections or BitTorrent peers and seeding data. | +| `pause_task` | Pause a task. | +| `continue_task` | Continue a paused or failed task. | +| `delete_task` | Delete a task, optionally deleting its downloaded files. | + ## ⬇️ Download ### 🧪 Gopeed 2.0.0 Beta -Gopeed 2.0.0 is currently in public beta. It introduces the new 2.0.0 experience, but some features may still be incomplete or unstable. Please try it and report any issues you encounter. +Gopeed 2.0.0 is currently in public beta, introducing a redesigned interface, a native communication architecture that connects desktop and mobile clients directly to the Go core through FFI, a more consistent cross-platform experience, improved task management, more flexible API support, and MCP-based AI agent integration. Some features may still be incomplete or unstable, so please try it and report any issues you encounter. - [Download Gopeed 2.0.0 Beta 1](https://github.com/GopeedLab/gopeed/releases/tag/v2.0.0-beta.1) diff --git a/README_ja-JP.md b/README_ja-JP.md index a8276799..30fb6082 100644 --- a/README_ja-JP.md +++ b/README_ja-JP.md @@ -35,14 +35,33 @@ Gopeed(**Go Speed** の略)は、Go と Flutter で開発された高速で - 🗣️ **20 以上の UI 言語** — 日本語、英語、簡体字・繁体字中国語、韓国語など多くの言語に対応。 - 🌐 **ブラウザー連携** — Chrome、Edge、Firefox などのダウンロードを Gopeed に送信できます。 - 🧩 **JavaScript 拡張** — 動画サイト、AI モデルハブ、クラウドストレージなどのダウンロード元を追加できます。 +- 🤖 **AI 連携** — MCP インターフェースを通じて対応する AI Agent と連携し、自然言語でダウンロードタスクを作成・確認・管理できます。 - 🔌 **オープンなインターフェース** — REST API、CLI、認証付き Web UI、Webhook、完了後スクリプトに対応。 - 🛠️ **便利な内蔵機能** — カスタム Header/User-Agent、Proxy、GitHub ミラー、通知、自動展開。 +## 🤖 AI 連携 + +Gopeed を AI Agent と連携すると、自然言語でダウンロードを管理できます。たとえば、AI Agent に次のように依頼できます: + +> 最新の Gopeed Windows クライアントをダウンロードして + +| Tool | 説明 | +| --- | --- | +| `resolve_task` | ダウンロード URL または URI を解析し、タスク作成前にリソース情報とファイル一覧を返します。 | +| `create_task` | 解析済みのリソース ID または直接のダウンロードリクエストからタスクを作成して開始します。 | +| `list_tasks` | タスクを一覧表示し、必要に応じて ID または状態で絞り込みます。 | +| `get_task` | 一つのタスクについて、リクエスト、リソース、オプション、現在の進捗を取得します。 | +| `get_task_status` | 一つのタスクの簡易的な実行状態とファイル別の進捗を取得します。 | +| `get_task_stats` | HTTP 接続数や BitTorrent の Peer・シード情報など、プロトコル固有の統計を取得します。 | +| `pause_task` | タスクを一時停止します。 | +| `continue_task` | 一時停止または失敗したタスクを再開します。 | +| `delete_task` | タスクを削除し、必要に応じてダウンロード済みファイルも削除します。 | + ## ⬇️ インストール ### 🧪 Gopeed 2.0.0 Beta -Gopeed 2.0.0 は現在パブリックベータ版です。新しい 2.0.0 のユーザー体験を先行してお試しいただけますが、一部の機能はまだ不完全または不安定な場合があります。ぜひお試しいただき、問題があればフィードバックをお寄せください。 +Gopeed 2.0.0 は現在パブリックベータ版です。刷新されたインターフェース、デスクトップおよびモバイルから FFI で Go コアへ直接接続するネイティブ通信アーキテクチャ、より統一されたクロスプラットフォーム体験、強化されたタスク管理、より柔軟な API、MCP による AI Agent 連携機能を導入しています。一部の機能はまだ不完全または不安定な場合がありますので、ぜひお試しいただき、問題があればフィードバックをお寄せください。 - [Gopeed 2.0.0 Beta 1 をダウンロード](https://github.com/GopeedLab/gopeed/releases/tag/v2.0.0-beta.1) diff --git a/README_vi-VN.md b/README_vi-VN.md index b3c1786f..f508f657 100644 --- a/README_vi-VN.md +++ b/README_vi-VN.md @@ -35,14 +35,33 @@ Truy cập ✈ [Trang web chính thức](https://gopeed.com) - 🗣️ **Hơn 20 ngôn ngữ giao diện** — gồm tiếng Việt, tiếng Anh, tiếng Trung giản thể và phồn thể, tiếng Nhật, tiếng Hàn cùng nhiều ngôn ngữ khác. - 🌐 **Tích hợp trình duyệt** — gửi tải xuống từ Chrome, Edge, Firefox và các trình duyệt tương thích sang Gopeed. - 🧩 **Tiện ích JavaScript** — thêm nguồn tải từ nền tảng video, kho mô hình AI, lưu trữ đám mây và nhiều dịch vụ khác. +- 🤖 **Tích hợp AI** — cung cấp giao diện MCP để kết nối với các AI Agent tương thích và tạo, kiểm tra hoặc quản lý tác vụ tải xuống bằng ngôn ngữ tự nhiên. - 🔌 **Giao diện mở** — REST API, CLI, Web UI có xác thực, webhook và script sau khi tải. - 🛠️ **Công cụ tích hợp** — Header/User-Agent tùy chỉnh, proxy, mirror GitHub, thông báo và tự động giải nén. +## 🤖 Tích hợp AI + +Sau khi kết nối Gopeed với AI Agent, bạn có thể quản lý tải xuống bằng ngôn ngữ tự nhiên. Ví dụ, hãy nói với AI Agent: + +> Hãy tải xuống phiên bản Gopeed mới nhất dành cho Windows + +| Tool | Mô tả | +| --- | --- | +| `resolve_task` | Phân tích URL hoặc URI tải xuống và trả về thông tin tài nguyên cùng danh sách tệp trước khi tạo tác vụ. | +| `create_task` | Tạo và bắt đầu tác vụ từ ID tài nguyên đã phân tích hoặc từ yêu cầu tải xuống trực tiếp. | +| `list_tasks` | Liệt kê tác vụ và có thể lọc theo ID hoặc trạng thái. | +| `get_task` | Lấy yêu cầu, tài nguyên, tùy chọn và tiến độ hiện tại của một tác vụ. | +| `get_task_status` | Lấy trạng thái chạy rút gọn và tiến độ của từng tệp trong một tác vụ. | +| `get_task_stats` | Lấy số liệu thống kê theo giao thức, gồm kết nối HTTP hoặc Peer và dữ liệu seed của BitTorrent. | +| `pause_task` | Tạm dừng tác vụ. | +| `continue_task` | Tiếp tục tác vụ đã tạm dừng hoặc bị lỗi. | +| `delete_task` | Xóa tác vụ và có thể xóa cả các tệp đã tải xuống. | + ## ⬇️ Tải về ### 🧪 Gopeed 2.0.0 Beta -Gopeed 2.0.0 hiện đang trong giai đoạn beta công khai. Phiên bản này mang đến trải nghiệm 2.0.0 mới, nhưng một số tính năng có thể vẫn chưa hoàn thiện hoặc chưa ổn định. Hãy dùng thử và gửi phản hồi cho chúng tôi nếu bạn gặp vấn đề. +Gopeed 2.0.0 hiện đang trong giai đoạn beta công khai, với giao diện được thiết kế lại, kiến trúc giao tiếp native kết nối trực tiếp ứng dụng desktop và di động với lõi Go qua FFI, trải nghiệm đa nền tảng nhất quán hơn, khả năng quản lý tác vụ được cải thiện, API linh hoạt hơn và khả năng tích hợp AI Agent qua MCP. Một số tính năng có thể vẫn chưa hoàn thiện hoặc chưa ổn định, vì vậy hãy dùng thử và gửi phản hồi nếu bạn gặp vấn đề. - [Tải Gopeed 2.0.0 Beta 1](https://github.com/GopeedLab/gopeed/releases/tag/v2.0.0-beta.1) diff --git a/README_zh-CN.md b/README_zh-CN.md index e314ed78..2f70a3ce 100644 --- a/README_zh-CN.md +++ b/README_zh-CN.md @@ -35,14 +35,33 @@ Gopeed(全称 **Go Speed**,也叫“够快下载器”)是一款使用 Go - 🗣️ **20+ 种界面语言** — 支持简体中文、繁体中文、英语、日语、韩语等多种语言。 - 🌐 **浏览器接管** — 支持 Chrome、Edge、Firefox 等兼容浏览器,将下载任务直接发送到 Gopeed。 - 🧩 **JavaScript 扩展** — 扩展视频网站、AI 模型站、网盘等更多下载源。 +- 🤖 **AI 接入** — 开放 MCP 接口,可接入 AI Agent,通过自然语言创建、查询和管理下载任务。 - 🔌 **开放接口** — 通过 REST API、CLI、带身份认证的 Web UI、Webhook 和下载后脚本进行自动化。 - 🛠️ **常用内置能力** — 自定义 Header 与 User-Agent、代理、GitHub 镜像、通知和自动解压。 +## 🤖 AI 接入 + +将 Gopeed 接入 AI Agent 后,即可通过自然语言管理下载。例如,直接对 AI Agent 说: + +> 帮我下载最新的 Gopeed Windows 客户端 + +| Tool | 功能说明 | +| --- | --- | +| `resolve_task` | 解析下载 URL 或 URI,在创建任务前返回资源信息及文件列表。 | +| `create_task` | 使用已解析的资源 ID 或直接下载请求创建并启动任务。 | +| `list_tasks` | 查询任务列表,并可按任务 ID 或状态筛选。 | +| `get_task` | 获取单个任务的请求、资源、选项及当前进度等完整信息。 | +| `get_task_status` | 获取单个任务的轻量运行状态和各文件下载进度。 | +| `get_task_stats` | 获取协议相关统计信息,包括 HTTP 连接或 BitTorrent Peer 与做种数据。 | +| `pause_task` | 暂停任务。 | +| `continue_task` | 继续已暂停或失败的任务。 | +| `delete_task` | 删除任务,并可选择同时删除已下载文件。 | + ## ⬇️ 下载 ### 🧪 Gopeed 2.0.0 Beta -Gopeed 2.0.0 目前处于公开 Beta 测试阶段,带来了全新的 2.0.0 使用体验,但部分功能仍可能不够完善或稳定。欢迎提前体验,并向我们反馈使用过程中遇到的问题。 +Gopeed 2.0.0 目前处于公开 Beta 测试阶段,引入了全新界面、通过 FFI 直连 Go 核心的原生通信架构、更一致的跨平台体验、更完善的任务管理、更灵活的 API,以及用于 AI Agent 接入的 MCP 能力。部分功能仍可能不够完善或稳定,欢迎提前体验并向我们反馈问题。 - [下载 Gopeed 2.0.0 Beta 1](https://github.com/GopeedLab/gopeed/releases/tag/v2.0.0-beta.1) diff --git a/README_zh-TW.md b/README_zh-TW.md index 979c0147..6b981653 100644 --- a/README_zh-TW.md +++ b/README_zh-TW.md @@ -35,14 +35,33 @@ Gopeed(**Go Speed** 的縮寫)是一款以 Go 與 Flutter 開發的高速、 - 🗣️ **20+ 種介面語言** — 支援繁體中文、簡體中文、英文、日文、韓文等多種語言。 - 🌐 **瀏覽器整合** — 可接管 Chrome、Edge、Firefox 等相容瀏覽器的下載。 - 🧩 **JavaScript 擴充** — 新增影音網站、AI 模型平台、雲端儲存等下載來源。 +- 🤖 **AI 整合** — 提供 MCP 介面,可串接相容的 AI Agent,以自然語言建立、查詢及管理下載任務。 - 🔌 **開放介面** — 支援 REST API、CLI、認證 Web UI、Webhook 及下載後腳本。 - 🛠️ **實用內建能力** — 自訂 Header/User-Agent、Proxy、GitHub 鏡像、通知及自動解壓縮。 +## 🤖 AI 整合 + +將 Gopeed 與 AI Agent 串接後,即可透過自然語言管理下載。例如,直接對 AI Agent 說: + +> 幫我下載最新的 Gopeed Windows 用戶端 + +| Tool | 功能說明 | +| --- | --- | +| `resolve_task` | 解析下載 URL 或 URI,在建立任務前回傳資源資訊及檔案清單。 | +| `create_task` | 使用已解析的資源 ID 或直接下載請求建立並啟動任務。 | +| `list_tasks` | 查詢任務清單,並可依任務 ID 或狀態篩選。 | +| `get_task` | 取得單一任務的請求、資源、選項及目前進度等完整資訊。 | +| `get_task_status` | 取得單一任務的簡要執行狀態及各檔案下載進度。 | +| `get_task_stats` | 取得協定相關統計資訊,包括 HTTP 連線或 BitTorrent Peer 與做種資料。 | +| `pause_task` | 暫停任務。 | +| `continue_task` | 繼續已暫停或失敗的任務。 | +| `delete_task` | 刪除任務,並可選擇同時刪除已下載檔案。 | + ## ⬇️ 下載 ### 🧪 Gopeed 2.0.0 Beta -Gopeed 2.0.0 目前處於公開 Beta 測試階段,帶來全新的 2.0.0 使用體驗,但部分功能仍可能不夠完善或穩定。歡迎搶先體驗,並向我們回報使用過程中遇到的問題。 +Gopeed 2.0.0 目前處於公開 Beta 測試階段,引入了全新介面、透過 FFI 直連 Go 核心的原生通訊架構、更一致的跨平台體驗、更完善的任務管理、更靈活的 API,以及透過 MCP 串接 AI Agent 的能力。部分功能仍可能不夠完善或穩定,歡迎搶先體驗並向我們回報問題。 - [下載 Gopeed 2.0.0 Beta 1](https://github.com/GopeedLab/gopeed/releases/tag/v2.0.0-beta.1) diff --git a/cmd/web/flags.go b/cmd/web/flags.go index 22f5179a..981860bb 100644 --- a/cmd/web/flags.go +++ b/cmd/web/flags.go @@ -18,6 +18,7 @@ type args struct { Username *string `json:"username"` Password *string `json:"password"` ApiToken *string `json:"apiToken"` + MCPEnable bool `json:"mcpEnable"` StorageDir *string `json:"storageDir"` WhiteDownloadDirs []string `json:"whiteDownloadDirs"` // DownloadConfig when the first time to start the server, it will be configured as initial value @@ -43,13 +44,21 @@ func parse() *args { func loadCliArgs() *args { cfg := &args{} cfg.Address = flag.String("A", "0.0.0.0", "Bind Address") + flag.StringVar(cfg.Address, "address", "0.0.0.0", "Bind Address") cfg.Port = flag.Int("P", 9999, "Bind Port") + flag.IntVar(cfg.Port, "port", 9999, "Bind Port") cfg.Username = flag.String("u", "gopeed", "Web Authentication Username") + flag.StringVar(cfg.Username, "username", "gopeed", "Web Authentication Username") cfg.Password = flag.String("p", "", "Web Authentication Password, if no password is set, web authentication will not be enabled") + flag.StringVar(cfg.Password, "password", "", "Web Authentication Password, if no password is set, web authentication will not be enabled") cfg.ApiToken = flag.String("T", "", "API token, it must be configured when using HTTP API in the case of enabling web authentication") + flag.StringVar(cfg.ApiToken, "api-token", "", "API token, it must be configured when using HTTP API in the case of enabling web authentication") + flag.BoolVar(&cfg.MCPEnable, "mcp-enable", false, "Enable MCP endpoint") cfg.StorageDir = flag.String("d", "", "Storage directory") - whiteDownloadDirs := flag.String("w", "", "White download directories, comma-separated") + flag.StringVar(cfg.StorageDir, "storage-dir", "", "Storage directory") + whiteDownloadDirs := flag.String("white-download-dirs", "", "White download directories, comma-separated") cfg.configPath = flag.String("c", "./config.json", "Config file path") + flag.StringVar(cfg.configPath, "config", "./config.json", "Config file path") flag.Parse() // Parse white download directories from comma-separated string @@ -68,21 +77,23 @@ func loadCliArgs() *args { func overrideWithCliArgs(cfg *args, cliConfig *args) { flag.Visit(func(f *flag.Flag) { switch f.Name { - case "A": + case "A", "address": cfg.Address = cliConfig.Address - case "P": + case "P", "port": cfg.Port = cliConfig.Port - case "u": + case "u", "username": cfg.Username = cliConfig.Username - case "p": + case "p", "password": cfg.Password = cliConfig.Password - case "T": + case "T", "api-token": cfg.ApiToken = cliConfig.ApiToken - case "d": + case "mcp-enable": + cfg.MCPEnable = cliConfig.MCPEnable + case "d", "storage-dir": cfg.StorageDir = cliConfig.StorageDir - case "w": + case "white-download-dirs": cfg.WhiteDownloadDirs = cliConfig.WhiteDownloadDirs - case "c": + case "c", "config": cfg.configPath = cliConfig.configPath } }) @@ -194,6 +205,10 @@ func loadEnvVars(cfg *args) { } field.Set(reflect.ValueOf(dirs)) } + } else if field.Kind() == reflect.Bool { + if boolVal, err := strconv.ParseBool(envValue); err == nil { + field.SetBool(boolVal) + } } } } diff --git a/cmd/web/flags_test.go b/cmd/web/flags_test.go index 89e48fb7..33e206c3 100644 --- a/cmd/web/flags_test.go +++ b/cmd/web/flags_test.go @@ -1,7 +1,9 @@ package main import ( + "flag" "fmt" + "io" "os" "path/filepath" "reflect" @@ -10,6 +12,50 @@ import ( "github.com/GopeedLab/gopeed/pkg/base" ) +func TestLoadCliArgsLongNames(t *testing.T) { + originalCommandLine := flag.CommandLine + originalArgs := os.Args + t.Cleanup(func() { + flag.CommandLine = originalCommandLine + os.Args = originalArgs + }) + + flags := flag.NewFlagSet("gopeed-web-test", flag.ContinueOnError) + flags.SetOutput(io.Discard) + flag.CommandLine = flags + os.Args = []string{ + "gopeed-web", + "--address", "127.0.0.2", + "--port", "8080", + "--username", "admin", + "--password", "password", + "--api-token", "token", + "--mcp-enable", + "--storage-dir", "/tmp/gopeed", + "--white-download-dirs", "/tmp/one,/tmp/two", + "--config", "/tmp/gopeed.json", + } + + cfg := loadCliArgs() + if *cfg.Address != "127.0.0.2" || *cfg.Port != 8080 || *cfg.Username != "admin" || + *cfg.Password != "password" || *cfg.ApiToken != "token" || !cfg.MCPEnable || + *cfg.StorageDir != "/tmp/gopeed" || !reflect.DeepEqual(cfg.WhiteDownloadDirs, []string{"/tmp/one", "/tmp/two"}) || + *cfg.configPath != "/tmp/gopeed.json" { + t.Fatalf("unexpected long-name CLI config: %+v", cfg) + } + + for _, name := range []string{"A", "P", "u", "p", "T", "d", "c"} { + if flags.Lookup(name) == nil { + t.Errorf("legacy short flag %q is missing", name) + } + } + for _, name := range []string{"M", "w"} { + if flags.Lookup(name) != nil { + t.Errorf("new option unexpectedly has short flag %q", name) + } + } +} + func TestSetDefaults(t *testing.T) { // Create mock CLI configuration with command line default values cliDefaults := &args{ @@ -165,14 +211,15 @@ func TestOverrideWithCliArgs(t *testing.T) { t.Run("flag override documentation", func(t *testing.T) { // Document the expected behavior for each flag: flagBehaviors := map[string]string{ - "A": "Should override cfg.Address with cliConfig.Address", - "P": "Should override cfg.Port with cliConfig.Port", - "u": "Should override cfg.Username with cliConfig.Username", - "p": "Should override cfg.Password with cliConfig.Password", - "T": "Should override cfg.ApiToken with cliConfig.ApiToken", - "d": "Should override cfg.StorageDir with cliConfig.StorageDir", - "w": "Should override cfg.WhiteDownloadDirs with cliConfig.WhiteDownloadDirs", - "c": "Should override cfg.configPath with cliConfig.configPath", + "A/address": "Should override cfg.Address with cliConfig.Address", + "P/port": "Should override cfg.Port with cliConfig.Port", + "u/username": "Should override cfg.Username with cliConfig.Username", + "p/password": "Should override cfg.Password with cliConfig.Password", + "T/api-token": "Should override cfg.ApiToken with cliConfig.ApiToken", + "mcp-enable": "Should override cfg.MCPEnable with cliConfig.MCPEnable", + "d/storage-dir": "Should override cfg.StorageDir with cliConfig.StorageDir", + "white-download-dirs": "Should override cfg.WhiteDownloadDirs with cliConfig.WhiteDownloadDirs", + "c/config": "Should override cfg.configPath with cliConfig.configPath", } t.Log("Expected flag override behaviors:") @@ -187,21 +234,23 @@ func TestOverrideWithCliArgs(t *testing.T) { mockFlagVisit := func(config *args, cliConfig *args, flagName string) { // Simulate the switch statement in overrideWithCliArgs switch flagName { - case "A": + case "A", "address": config.Address = cliConfig.Address - case "P": + case "P", "port": config.Port = cliConfig.Port - case "u": + case "u", "username": config.Username = cliConfig.Username - case "p": + case "p", "password": config.Password = cliConfig.Password - case "T": + case "T", "api-token": config.ApiToken = cliConfig.ApiToken - case "d": + case "mcp-enable": + config.MCPEnable = cliConfig.MCPEnable + case "d", "storage-dir": config.StorageDir = cliConfig.StorageDir - case "w": + case "white-download-dirs": config.WhiteDownloadDirs = cliConfig.WhiteDownloadDirs - case "c": + case "c", "config": config.configPath = cliConfig.configPath default: t.Errorf("Unknown flag: %s", flagName) @@ -285,6 +334,20 @@ func TestOverrideWithCliArgs(t *testing.T) { } }, }, + { + flagName: "mcp-enable", + setupConfig: func() *args { + return &args{MCPEnable: false} + }, + setupCliConfig: func() *args { + return &args{MCPEnable: true} + }, + verify: func(t *testing.T, cfg *args) { + if !cfg.MCPEnable { + t.Error("MCPEnable flag override failed: got false, want true") + } + }, + }, { flagName: "d", setupConfig: func() *args { @@ -300,7 +363,7 @@ func TestOverrideWithCliArgs(t *testing.T) { }, }, { - flagName: "w", + flagName: "white-download-dirs", setupConfig: func() *args { return &args{WhiteDownloadDirs: []string{"/original/dir1", "/original/dir2"}} }, @@ -402,6 +465,7 @@ func TestLoadConfigFile(t *testing.T) { "username": "testuser", "password": "testpass", "apiToken": "testtoken", + "mcpEnable": true, "storageDir": "/test/storage", "downloadConfig": { "downloadDir": "/test/downloads", @@ -415,6 +479,7 @@ func TestLoadConfigFile(t *testing.T) { Username: stringPtr("testuser"), Password: stringPtr("testpass"), ApiToken: stringPtr("testtoken"), + MCPEnable: true, StorageDir: stringPtr("/test/storage"), DownloadConfig: &base.DownloaderStoreConfig{ DownloadDir: "/test/downloads", @@ -476,7 +541,7 @@ func TestLoadEnvVars(t *testing.T) { originalEnv := make(map[string]string) envKeys := []string{ "GOPEED_ADDRESS", "GOPEED_PORT", "GOPEED_USERNAME", - "GOPEED_PASSWORD", "GOPEED_APITOKEN", "GOPEED_STORAGEDIR", + "GOPEED_PASSWORD", "GOPEED_APITOKEN", "GOPEED_MCPENABLE", "GOPEED_STORAGEDIR", "GOPEED_DOWNLOADCONFIG", "GOPEED_WHITEDOWNLOADDIRS", } for _, key := range envKeys { @@ -508,6 +573,7 @@ func TestLoadEnvVars(t *testing.T) { "GOPEED_USERNAME": "envuser", "GOPEED_PASSWORD": "envpass", "GOPEED_APITOKEN": "envtoken", + "GOPEED_MCPENABLE": "true", "GOPEED_STORAGEDIR": "/env/storage", }, expected: &args{ @@ -516,6 +582,7 @@ func TestLoadEnvVars(t *testing.T) { Username: stringPtr("envuser"), Password: stringPtr("envpass"), ApiToken: stringPtr("envtoken"), + MCPEnable: true, StorageDir: stringPtr("/env/storage"), }, }, diff --git a/cmd/web/main.go b/cmd/web/main.go index 43273eff..5a31909d 100644 --- a/cmd/web/main.go +++ b/cmd/web/main.go @@ -50,6 +50,7 @@ func main() { StorageDir: storageDir, WhiteDownloadDirs: args.WhiteDownloadDirs, ApiToken: *args.ApiToken, + MCPEnable: args.MCPEnable, DownloadConfig: args.DownloadConfig, ProductionMode: true, WebEnable: true, diff --git a/go.mod b/go.mod index 785e2142..13db5774 100644 --- a/go.mod +++ b/go.mod @@ -16,6 +16,7 @@ require ( github.com/matoous/go-nanoid/v2 v2.0.0 github.com/mattn/go-ieproxy v0.0.12 github.com/mholt/archives v0.1.5 + github.com/modelcontextprotocol/go-sdk v1.7.0 github.com/monkeyWie/goed2k v0.0.0-20260317100435-7a7575cf2447 github.com/pkg/errors v0.9.1 github.com/rs/dnscache v0.0.0-20230804202142-fc85eb664529 @@ -37,6 +38,7 @@ require ( github.com/felixge/fgprof v0.9.5 // indirect github.com/go-task/slim-sprig/v3 v3.0.0 // indirect github.com/google/go-cmp v0.7.0 // indirect + github.com/google/jsonschema-go v0.4.3 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect github.com/hashicorp/go-multierror v1.1.1 // indirect github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect @@ -52,21 +54,25 @@ require ( github.com/quic-go/qpack v0.5.1 // indirect github.com/quic-go/quic-go v0.51.0 // indirect github.com/refraction-networking/utls v1.6.7 // indirect + github.com/segmentio/asm v1.1.3 // indirect + github.com/segmentio/encoding v0.5.4 // indirect github.com/sorairolake/lzip-go v0.3.8 // indirect github.com/spf13/afero v1.15.0 // indirect github.com/ulikunitz/xz v0.5.15 // indirect + github.com/yosida95/uritemplate/v3 v3.0.2 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.uber.org/automaxprocs v1.6.0 // indirect go.uber.org/mock v0.5.1 // indirect go4.org v0.0.0-20230225012048-214862532bf5 // indirect - golang.org/x/sync v0.19.0 // indirect + golang.org/x/oauth2 v0.35.0 // indirect + golang.org/x/sync v0.20.0 // indirect ) require ( dario.cat/mergo v1.0.0 // indirect github.com/Microsoft/go-winio v0.6.1 github.com/ProtonMail/go-crypto v0.0.0-20230717121422-5aa5874ade95 // indirect - github.com/RoaringBitmap/roaring v1.9.4 // indirect + github.com/RoaringBitmap/roaring v1.9.4 github.com/acomagu/bufpipe v1.0.4 // indirect github.com/alecthomas/atomic v0.1.0-alpha2 // indirect github.com/anacrolix/chansync v0.7.0 // indirect @@ -147,13 +153,13 @@ require ( go.opentelemetry.io/otel v1.39.0 // indirect go.opentelemetry.io/otel/metric v1.39.0 // indirect go.opentelemetry.io/otel/trace v1.39.0 // indirect - golang.org/x/crypto v0.46.0 // indirect - golang.org/x/mod v0.31.0 // indirect - golang.org/x/net v0.48.0 // indirect - golang.org/x/sys v0.40.0 // indirect - golang.org/x/text v0.33.0 - golang.org/x/time v0.14.0 // indirect - golang.org/x/tools v0.40.0 // indirect + golang.org/x/crypto v0.48.0 // indirect + golang.org/x/mod v0.33.0 // indirect + golang.org/x/net v0.50.0 + golang.org/x/sys v0.41.0 // indirect + golang.org/x/text v0.34.0 + golang.org/x/time v0.15.0 // indirect + golang.org/x/tools v0.42.0 // indirect gopkg.in/warnings.v0 v0.1.2 // indirect lukechampine.com/blake3 v1.4.1 // indirect modernc.org/libc v1.67.4 // indirect diff --git a/go.sum b/go.sum index 7b328f6d..9c4680f4 100644 --- a/go.sum +++ b/go.sum @@ -23,10 +23,6 @@ filippo.io/edwards25519 v1.0.0-rc.1 h1:m0VOOB23frXZvAOK44usCgLWvtsxIoMCTBGJZlpmG filippo.io/edwards25519 v1.0.0-rc.1/go.mod h1:N1IkdkCkiLB6tki+MYJoSx2JTY9NUlxZE7eHn5EwJns= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= -github.com/GopeedLab/webview_go v0.0.0-20260416010719-fc58e7d53769 h1:iuMR7aHvckis8PKYUDXDa8elcmf34GksWpaYak1EYKw= -github.com/GopeedLab/webview_go v0.0.0-20260416010719-fc58e7d53769/go.mod h1:FvV19lu9rQ9NcbczN5y8pbWecURRRbWa8tbfFARBDa0= -github.com/GopeedLab/webview_go v0.0.0-20260423023243-b3f067ccab2f h1:yu//jtofUpHZB+IpB1xIH8z4o4JsFJ6pMvgA1Ww/ElY= -github.com/GopeedLab/webview_go v0.0.0-20260423023243-b3f067ccab2f/go.mod h1:FvV19lu9rQ9NcbczN5y8pbWecURRRbWa8tbfFARBDa0= github.com/GopeedLab/webview_go v0.0.0-20260423085439-7a2f88b6e9b5 h1:86LVTaNDM/j/uTAD2HedkXNO9LIQlt642VAdbU/0D/M= github.com/GopeedLab/webview_go v0.0.0-20260423085439-7a2f88b6e9b5/go.mod h1:FvV19lu9rQ9NcbczN5y8pbWecURRRbWa8tbfFARBDa0= github.com/Masterminds/semver/v3 v3.2.1 h1:RN9w6+7QoMeJVGyfmbcgs28Br8cvmnucEXnY0rYXWg0= @@ -248,6 +244,8 @@ github.com/gobwas/ws v1.2.1/go.mod h1:hRKAFb8wOxFROYNsT1bqfWnhX+b5MFeJM9r2ZSwg/K github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= github.com/gogo/protobuf v1.2.0/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= @@ -283,6 +281,8 @@ github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeN github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/jsonschema-go v0.4.3 h1:/DBOLZTfDow7pe2GmaJNhltueGTtDKICi8V8p+DQPd0= +github.com/google/jsonschema-go v0.4.3/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs= github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= @@ -388,6 +388,8 @@ github.com/minio/minlz v1.0.1 h1:OUZUzXcib8diiX+JYxyRLIdomyZYzHct6EShOKtQY2A= github.com/minio/minlz v1.0.1/go.mod h1:qT0aEB35q79LLornSzeDH75LBf3aH1MV+jB5w9Wasec= github.com/minio/sha256-simd v1.0.1 h1:6kaan5IFmwTNynnKKpDHe6FWHohJOHhCPchzK49dzMM= github.com/minio/sha256-simd v1.0.1/go.mod h1:Pz6AKMiUdngCLpeTL/RJY1M9rUuPMYujV5xJjtbRSN8= +github.com/modelcontextprotocol/go-sdk v1.7.0 h1:yqjY2dsbKAC0LSuWZVBMrHgiG8ukXv6NRo0JiALay44= +github.com/modelcontextprotocol/go-sdk v1.7.0/go.mod h1:dL7u98E/zjJTGzEq+j30jQ8K2k1mb6LeAH4inEcSGts= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= @@ -511,6 +513,10 @@ github.com/rs/zerolog v1.31.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWR github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd/go.mod h1:hPqNNc0+uJM6H+SuU8sEs5K5IQeKccPqeSjfgcKGgPk= github.com/ryszard/goskiplist v0.0.0-20150312221310-2dfbae5fcf46 h1:GHRpF1pTW19a8tTFrMLUcfWwyC0pnifVo2ClaLq+hP8= github.com/ryszard/goskiplist v0.0.0-20150312221310-2dfbae5fcf46/go.mod h1:uAQ5PCi+MFsC7HjREoAz1BU+Mq60+05gifQSsHSDG/8= +github.com/segmentio/asm v1.1.3 h1:WM03sfUOENvvKexOLp+pCqgb/WDjsi7EK8gIsICtzhc= +github.com/segmentio/asm v1.1.3/go.mod h1:Ld3L4ZXGNcSLRg4JBsZ3//1+f/TjYl0Mzen/DQy1EJg= +github.com/segmentio/encoding v0.5.4 h1:OW1VRern8Nw6ITAtwSZ7Idrl3MXCFwXHPgqESYfvNt0= +github.com/segmentio/encoding v0.5.4/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0= github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0= github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM= github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= @@ -564,6 +570,8 @@ github.com/xiaoqidun/setft v0.0.0-20220310121541-be86327699ad h1:QtuWk6dsrNXc/ug github.com/xiaoqidun/setft v0.0.0-20220310121541-be86327699ad/go.mod h1:Jj8p9bgKGTPQ+M8CdUMS9p7Mmdoxa3OAcAjJQBu0CcI= github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= +github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= +github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= github.com/yuin/goldmark v1.4.1/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= go.etcd.io/bbolt v1.4.3 h1:dEadXpI6G79deX5prL3QRNP6JB8UxVkqo4UPnHaNXJo= @@ -600,8 +608,8 @@ golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0 golang.org/x/crypto v0.3.1-0.20221117191849-2c476679df9a/go.mod h1:hebNnKkNXi2UzZN1eVRvBB7co0a+JxK6XbPiWVs/3J4= golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU= golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4= -golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU= -golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0= +golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= +golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= @@ -634,8 +642,8 @@ golang.org/x/mod v0.5.1/go.mod h1:5OXOZSfqPIIbmVBIIKWRFfZjPR0E5r58TLhUjH0a2Ro= golang.org/x/mod v0.6.0-dev.0.20211013180041-c96bc1413d57/go.mod h1:3p9vT2HGsQu2K1YbXdKPJLVgG5VJdoTa1poYQBtP1AY= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= -golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI= -golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg= +golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= +golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= @@ -663,13 +671,15 @@ golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc= golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE= -golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= -golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= +golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60= +golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20191202225959-858c2ad4c8b6/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= +golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ= +golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -679,8 +689,8 @@ golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= -golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -720,8 +730,8 @@ golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= -golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= +golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc= @@ -729,8 +739,8 @@ golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U= golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= golang.org/x/term v0.13.0/go.mod h1:LTmsnFJwVN6bCy1rVCoS+qHT1HhALEFxKncY3WNNh4U= -golang.org/x/term v0.38.0 h1:PQ5pkm/rLO6HnxFR7N2lJHOZX6Kez5Y1gDSJla6jo7Q= -golang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg= +golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg= +golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM= golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= @@ -743,12 +753,12 @@ golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= -golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE= -golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8= +golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= +golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= -golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= -golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= +golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= +golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= golang.org/x/tools v0.0.0-20180828015842-6cd1fcedba52/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= @@ -777,8 +787,8 @@ golang.org/x/tools v0.0.0-20200212150539-ea181f53ac56/go.mod h1:TB2adYChydJhpapK golang.org/x/tools v0.1.8-0.20211029000441-d6a9af8af023/go.mod h1:nABZi5QlRsZVlzPpHl034qft6wpY4eDcsTt5AaioBiU= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= -golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA= -golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc= +golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= +golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/pkg/base/model.go b/pkg/base/model.go index 26417845..46bcace2 100644 --- a/pkg/base/model.go +++ b/pkg/base/model.go @@ -282,10 +282,11 @@ func (cfg *DownloaderStoreConfig) Merge(beforeCfg *DownloaderStoreConfig) *Downl } type APIServerConfig struct { - Enable bool `json:"enable"` - Network string `json:"network"` - Address string `json:"address"` - Token string `json:"token"` + Enable bool `json:"enable"` + MCPEnable bool `json:"mcpEnable"` + Network string `json:"network"` + Address string `json:"address"` + Token string `json:"token"` } func (cfg *APIServerConfig) Init() *APIServerConfig { diff --git a/pkg/mcpserver/server.go b/pkg/mcpserver/server.go new file mode 100644 index 00000000..62ca0a0c --- /dev/null +++ b/pkg/mcpserver/server.go @@ -0,0 +1,525 @@ +// Package mcpserver exposes Gopeed's task operations over the Model Context +// Protocol. +package mcpserver + +import ( + "context" + "fmt" + "net/http" + "reflect" + "strings" + "time" + + "github.com/GopeedLab/gopeed/pkg/base" + "github.com/GopeedLab/gopeed/pkg/download" + "github.com/google/jsonschema-go/jsonschema" + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +const developmentVersion = "dev" + +const serverInstructions = "Gopeed is a download manager for HTTP, HTTPS, BitTorrent, magnet, and ED2K resources. " + + "When the user asks to download a resource and provides a supported URL or URI, use create_task. " + + "Use resolve_task first when the user needs resource metadata or wants to select files before creating the task. " + + "If the user has not provided a concrete URL or URI, ask for one instead of inventing it." + +// NewHandler returns a stateless Streamable HTTP MCP handler. +func NewHandler(downloader *download.Downloader) http.Handler { + server := NewServer(downloader) + handler := mcp.NewStreamableHTTPHandler(func(*http.Request) *mcp.Server { + return server + }, &mcp.StreamableHTTPOptions{ + Stateless: true, + JSONResponse: true, + }) + return http.NewCrossOriginProtection().Handler(handler) +} + +// NewServer builds an MCP server backed by downloader. +func NewServer(downloader *download.Downloader) *mcp.Server { + if downloader == nil { + panic("mcpserver: nil downloader") + } + + version := base.Version + if version == "" { + version = developmentVersion + } + server := mcp.NewServer(&mcp.Implementation{Name: "gopeed", Version: version}, &mcp.ServerOptions{ + Instructions: serverInstructions, + }) + registerTools(server, downloader) + return server +} + +type requestExtra map[string]any + +type httpRequestExtra struct { + Method string `json:"method,omitempty" jsonschema:"Optional HTTP method; defaults to GET"` + Header map[string]string `json:"header,omitempty" jsonschema:"Optional HTTP request headers"` + Body string `json:"body,omitempty" jsonschema:"Optional HTTP request body"` +} + +type btRequestExtra struct { + Trackers []string `json:"trackers" jsonschema:"BitTorrent tracker URLs to add to the task"` +} + +type httpTaskExtra struct { + Connections int `json:"connections,omitempty" jsonschema:"Number of HTTP download connections"` + AutoTorrent *bool `json:"autoTorrent,omitempty" jsonschema:"Automatically create a BitTorrent task after downloading a torrent file"` + DeleteTorrentAfterDownload *bool `json:"deleteTorrentAfterDownload,omitempty" jsonschema:"Delete the torrent file after automatically creating its BitTorrent task"` + AutoExtract *bool `json:"autoExtract,omitempty" jsonschema:"Automatically extract the downloaded archive"` + ArchivePassword string `json:"archivePassword,omitempty" jsonschema:"Password for an encrypted archive"` + DeleteAfterExtract bool `json:"deleteAfterExtract,omitempty" jsonschema:"Delete archive files after successful extraction"` +} + +// downloadRequest is the MCP-facing form of base.Request. The REST model uses +// any for Extra, which produces a boolean JSON Schema and is misread as a +// string by some MCP clients. The MCP schema replaces it with a oneOf union of +// the supported protocol-specific request objects. +type downloadRequest struct { + URL string `json:"url" jsonschema:"Download URL or URI. Supports HTTP, HTTPS, magnet, BitTorrent torrent sources, and ED2K links"` + RawURL string `json:"rawUrl,omitempty" jsonschema:"Original user-provided URL or URI when it differs from url"` + Extra requestExtra `json:"extra,omitempty" jsonschema:"Optional protocol-specific request settings"` + Labels map[string]string `json:"labels,omitempty" jsonschema:"Optional labels to attach to the download task"` + Proxy *base.RequestProxy `json:"proxy,omitempty" jsonschema:"Optional proxy settings for this download request"` + SkipVerifyCert bool `json:"skipVerifyCert,omitempty" jsonschema:"Skip TLS certificate verification for this request"` +} + +func (r *downloadRequest) baseRequest() (*base.Request, error) { + if r == nil { + return nil, nil + } + if err := r.validateExtra(); err != nil { + return nil, err + } + return &base.Request{ + RawURL: r.RawURL, + URL: r.URL, + Extra: r.Extra, + Labels: r.Labels, + Proxy: r.Proxy, + SkipVerifyCert: r.SkipVerifyCert, + }, nil +} + +func (r *downloadRequest) validateExtra() error { + if len(r.Extra) == 0 { + return nil + } + if _, isBTExtra := r.Extra["trackers"]; isBTExtra { + if !isBitTorrentRequest(r.URL) { + return fmt.Errorf("req.extra contains BitTorrent settings, but req.url is not a magnet or torrent resource") + } + return nil + } + + scheme := strings.ToUpper(strings.SplitN(r.URL, ":", 2)[0]) + if scheme != "HTTP" && scheme != "HTTPS" { + return fmt.Errorf("req.extra contains HTTP settings, but req.url is not an HTTP or HTTPS URL") + } + return nil +} + +func isBitTorrentRequest(rawURL string) bool { + upperURL := strings.ToUpper(rawURL) + path := strings.SplitN(upperURL, "?", 2)[0] + return strings.HasPrefix(upperURL, "MAGNET:") || + strings.HasSuffix(path, ".TORRENT") || + strings.HasPrefix(upperURL, "DATA:APPLICATION/X-BITTORRENT;BASE64,") +} + +// downloadOptions is the MCP-facing form of base.Options. Only HTTP currently +// defines protocol-specific task options, so Extra has one concrete schema. +type downloadOptions struct { + Name string `json:"name,omitempty" jsonschema:"Optional output file or directory name"` + Path string `json:"path,omitempty" jsonschema:"Optional download directory"` + AsDefaultPath bool `json:"asDefaultPath,omitempty" jsonschema:"Save path as the default download directory after task creation"` + SelectFiles []int `json:"selectFiles,omitempty" jsonschema:"Optional zero-based file indexes to download from a multi-file resource"` + Extra *httpTaskExtra `json:"extra,omitempty" jsonschema:"Optional HTTP task settings"` +} + +func requestExtraSchema() *jsonschema.Schema { + httpSchema, err := jsonschema.For[httpRequestExtra](nil) + if err != nil { + panic(fmt.Sprintf("mcpserver: build HTTP request extra schema: %v", err)) + } + httpSchema.Title = "HTTP request settings" + httpSchema.Description = "Protocol-specific request settings for HTTP and HTTPS downloads. Include at least one setting." + httpSchema.AnyOf = []*jsonschema.Schema{ + {Required: []string{"method"}}, + {Required: []string{"header"}}, + {Required: []string{"body"}}, + } + + btSchema, err := jsonschema.For[btRequestExtra](nil) + if err != nil { + panic(fmt.Sprintf("mcpserver: build BitTorrent request extra schema: %v", err)) + } + btSchema.Title = "BitTorrent request settings" + btSchema.Description = "Protocol-specific request settings for magnet and BitTorrent resources." + // A slice is nullable in Go, but an explicitly supplied trackers property + // should be an array in the MCP contract. + btSchema.Properties["trackers"].Types = nil + btSchema.Properties["trackers"].Type = "array" + + return &jsonschema.Schema{ + Type: "object", + Description: "Protocol-specific request settings. Must match exactly one of the HTTP or BitTorrent schemas.", + OneOf: []*jsonschema.Schema{httpSchema, btSchema}, + } +} + +func downloadInputSchema[T any]() *jsonschema.Schema { + schema, err := jsonschema.For[T](&jsonschema.ForOptions{ + TypeSchemas: map[reflect.Type]*jsonschema.Schema{ + reflect.TypeFor[requestExtra](): requestExtraSchema(), + }, + }) + if err != nil { + panic(fmt.Sprintf("mcpserver: build tool input schema: %v", err)) + } + return schema +} + +func (o *downloadOptions) baseOptions() *base.Options { + if o == nil { + return nil + } + return &base.Options{ + Name: o.Name, + Path: o.Path, + AsDefaultPath: o.AsDefaultPath, + SelectFiles: o.SelectFiles, + Extra: o.Extra, + } +} + +type resolveTaskInput struct { + Req *downloadRequest `json:"req" jsonschema:"Download request to resolve"` + Opts *downloadOptions `json:"opts,omitempty" jsonschema:"Optional download settings used while resolving"` +} + +type resolveTaskOutput struct { + ID string `json:"id,omitempty"` + Res *base.Resource `json:"res"` +} + +type createTaskInput struct { + RID string `json:"rid,omitempty" jsonschema:"Optional resource ID returned by resolve_task"` + Req *downloadRequest `json:"req,omitempty" jsonschema:"Direct download request; required when rid is omitted"` + Opts *downloadOptions `json:"opts,omitempty" jsonschema:"Optional task settings"` +} + +type createTaskOutput struct { + ID string `json:"id"` +} + +type taskIDInput struct { + ID string `json:"id" jsonschema:"Gopeed task ID"` +} + +type deleteTaskInput struct { + ID string `json:"id" jsonschema:"Gopeed task ID"` + Force bool `json:"force,omitempty" jsonschema:"Also delete downloaded files from disk"` +} + +type listTasksInput struct { + IDs []string `json:"id,omitempty" jsonschema:"Only return tasks with these IDs"` + Statuses []string `json:"status,omitempty" jsonschema:"Only return tasks in these states: ready, running, pause, wait, error, or done"` + NotStatuses []string `json:"notStatus,omitempty" jsonschema:"Exclude tasks in these states: ready, running, pause, wait, error, or done"` +} + +type TaskSummary struct { + ID string `json:"id"` + Name string `json:"name"` + Protocol string `json:"protocol"` + Status base.Status `json:"status"` + Uploading bool `json:"uploading"` + Downloaded int64 `json:"downloaded"` + Total int64 `json:"total"` + Speed int64 `json:"speed"` + Uploaded int64 `json:"uploaded"` + UploadSpeed int64 `json:"uploadSpeed"` + ExtractStatus download.ExtractStatus `json:"extractStatus,omitempty"` + ExtractProgress int `json:"extractProgress,omitempty"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +type taskDetails struct { + TaskSummary + Req *base.Request `json:"req,omitempty"` + Res *base.Resource `json:"res,omitempty"` + Opts *base.Options `json:"opts,omitempty"` +} + +type listTasksOutput struct { + Tasks []TaskSummary `json:"tasks"` +} + +type getTaskOutput struct { + Task taskDetails `json:"task"` +} + +type getTaskStatusOutput struct { + Status *download.TaskRuntimeStatus `json:"status"` +} + +type getTaskStatsOutput struct { + Stats any `json:"stats"` +} + +type taskActionOutput struct { + ID string `json:"id"` + Success bool `json:"success"` +} + +func registerTools(server *mcp.Server, downloader *download.Downloader) { + closedWorld := false + openWorld := true + nondestructive := false + destructive := true + + mcp.AddTool(server, &mcp.Tool{ + Name: "resolve_task", + Title: "Resolve task", + Description: "Resolve an HTTP, HTTPS, BitTorrent, magnet, or ED2K download request and return its resource metadata and files. Use this before create_task when the user wants to inspect the resource or select files.", + InputSchema: downloadInputSchema[resolveTaskInput](), + Annotations: &mcp.ToolAnnotations{ReadOnlyHint: true, OpenWorldHint: &openWorld}, + }, func(_ context.Context, _ *mcp.CallToolRequest, input *resolveTaskInput) (*mcp.CallToolResult, *resolveTaskOutput, error) { + if input.Req == nil { + return nil, nil, fmt.Errorf("req is required") + } + req, err := input.Req.baseRequest() + if err != nil { + return nil, nil, err + } + result, err := downloader.Resolve(req, input.Opts.baseOptions()) + if err != nil { + return nil, nil, err + } + return nil, &resolveTaskOutput{ID: result.ID, Res: result.Res}, nil + }) + + mcp.AddTool(server, &mcp.Tool{ + Name: "create_task", + Title: "Create task", + Description: "Create and start a Gopeed download task. Use this when the user asks to download an HTTP or HTTPS URL, a BitTorrent torrent, a magnet URI, or an ED2K link. Accepts either a resource ID from resolve_task or a direct request.", + InputSchema: downloadInputSchema[createTaskInput](), + Annotations: &mcp.ToolAnnotations{DestructiveHint: &nondestructive, OpenWorldHint: &openWorld}, + }, func(_ context.Context, _ *mcp.CallToolRequest, input *createTaskInput) (*mcp.CallToolResult, *createTaskOutput, error) { + var ( + id string + err error + ) + switch { + case input.RID != "": + id, err = downloader.Create(input.RID) + case input.Req != nil: + var req *base.Request + req, err = input.Req.baseRequest() + if err == nil { + id, err = downloader.CreateDirect(req, input.Opts.baseOptions()) + } + default: + return nil, nil, fmt.Errorf("rid or req is required") + } + if err != nil { + return nil, nil, err + } + return nil, &createTaskOutput{ID: id}, nil + }) + + mcp.AddTool(server, &mcp.Tool{ + Name: "list_tasks", + Title: "List tasks", + Description: "List Gopeed tasks, optionally filtered by ID or task status.", + Annotations: &mcp.ToolAnnotations{ReadOnlyHint: true, OpenWorldHint: &closedWorld}, + }, func(_ context.Context, _ *mcp.CallToolRequest, input *listTasksInput) (*mcp.CallToolResult, *listTasksOutput, error) { + statuses, err := parseStatuses(input.Statuses) + if err != nil { + return nil, nil, err + } + notStatuses, err := parseStatuses(input.NotStatuses) + if err != nil { + return nil, nil, err + } + tasks := downloader.GetTasksByFilter(&download.TaskFilter{ + IDs: input.IDs, + Statuses: statuses, + NotStatuses: notStatuses, + }) + result := make([]TaskSummary, 0, len(tasks)) + for _, task := range tasks { + summary, err := summarizeTask(downloader, task) + if err != nil { + return nil, nil, err + } + result = append(result, summary) + } + return nil, &listTasksOutput{Tasks: result}, nil + }) + + mcp.AddTool(server, &mcp.Tool{ + Name: "get_task", + Title: "Get task", + Description: "Get one Gopeed task including its request, resource, options, and current progress.", + Annotations: &mcp.ToolAnnotations{ReadOnlyHint: true, OpenWorldHint: &closedWorld}, + }, func(_ context.Context, _ *mcp.CallToolRequest, input *taskIDInput) (*mcp.CallToolResult, *getTaskOutput, error) { + task, err := requireTask(downloader, input.ID) + if err != nil { + return nil, nil, err + } + summary, err := summarizeTask(downloader, task) + if err != nil { + return nil, nil, err + } + details := taskDetails{TaskSummary: summary} + if task.Meta != nil { + details.Req = task.Meta.Req + details.Res = task.Meta.Res + details.Opts = task.Meta.Opts + } + return nil, &getTaskOutput{Task: details}, nil + }) + + mcp.AddTool(server, &mcp.Tool{ + Name: "get_task_status", + Title: "Get task status", + Description: "Get the lightweight runtime status and per-file progress for one Gopeed task.", + Annotations: &mcp.ToolAnnotations{ReadOnlyHint: true, OpenWorldHint: &closedWorld}, + }, func(_ context.Context, _ *mcp.CallToolRequest, input *taskIDInput) (*mcp.CallToolResult, *getTaskStatusOutput, error) { + if err := validateTaskID(input.ID); err != nil { + return nil, nil, err + } + status, err := downloader.RuntimeStatus(input.ID) + if err != nil { + return nil, nil, err + } + return nil, &getTaskStatusOutput{Status: status}, nil + }) + + mcp.AddTool(server, &mcp.Tool{ + Name: "get_task_stats", + Title: "Get task statistics", + Description: "Get protocol-specific statistics for one Gopeed task, such as HTTP connections or BitTorrent peers and seeding data.", + Annotations: &mcp.ToolAnnotations{ReadOnlyHint: true, OpenWorldHint: &closedWorld}, + }, func(_ context.Context, _ *mcp.CallToolRequest, input *taskIDInput) (*mcp.CallToolResult, *getTaskStatsOutput, error) { + if err := validateTaskID(input.ID); err != nil { + return nil, nil, err + } + stats, err := downloader.Stats(input.ID) + if err != nil { + return nil, nil, err + } + return nil, &getTaskStatsOutput{Stats: stats}, nil + }) + + registerTaskAction(server, downloader, "pause_task", "Pause task", "Pause one Gopeed task.", &nondestructive, &closedWorld, downloader.Pause) + registerTaskAction(server, downloader, "continue_task", "Continue task", "Continue a paused or failed Gopeed task.", &nondestructive, &openWorld, downloader.Continue) + + mcp.AddTool(server, &mcp.Tool{ + Name: "delete_task", + Title: "Delete task", + Description: "Delete one Gopeed task. Downloaded files are preserved unless force is true.", + Annotations: &mcp.ToolAnnotations{DestructiveHint: &destructive, IdempotentHint: true, OpenWorldHint: &closedWorld}, + }, func(_ context.Context, _ *mcp.CallToolRequest, input *deleteTaskInput) (*mcp.CallToolResult, *taskActionOutput, error) { + if _, err := requireTask(downloader, input.ID); err != nil { + return nil, nil, err + } + if err := downloader.Delete(&download.TaskFilter{IDs: []string{input.ID}}, input.Force); err != nil { + return nil, nil, err + } + return nil, &taskActionOutput{ID: input.ID, Success: true}, nil + }) +} + +func registerTaskAction( + server *mcp.Server, + downloader *download.Downloader, + name string, + title string, + description string, + destructiveHint *bool, + openWorldHint *bool, + action func(*download.TaskFilter) error, +) { + mcp.AddTool(server, &mcp.Tool{ + Name: name, + Title: title, + Description: description, + Annotations: &mcp.ToolAnnotations{ + DestructiveHint: destructiveHint, + IdempotentHint: true, + OpenWorldHint: openWorldHint, + }, + }, func(_ context.Context, _ *mcp.CallToolRequest, input *taskIDInput) (*mcp.CallToolResult, *taskActionOutput, error) { + if _, err := requireTask(downloader, input.ID); err != nil { + return nil, nil, err + } + if err := action(&download.TaskFilter{IDs: []string{input.ID}}); err != nil { + return nil, nil, err + } + return nil, &taskActionOutput{ID: input.ID, Success: true}, nil + }) +} + +func requireTask(downloader *download.Downloader, id string) (*download.Task, error) { + if err := validateTaskID(id); err != nil { + return nil, err + } + task := downloader.GetTask(id) + if task == nil { + return nil, fmt.Errorf("task %q not found", id) + } + return task, nil +} + +func validateTaskID(id string) error { + if strings.TrimSpace(id) == "" { + return fmt.Errorf("id is required") + } + return nil +} + +func summarizeTask(downloader *download.Downloader, task *download.Task) (TaskSummary, error) { + status, err := downloader.RuntimeStatus(task.ID) + if err != nil { + return TaskSummary{}, err + } + summary := TaskSummary{ + ID: task.ID, + Protocol: task.Protocol, + Status: status.Status, + Uploading: task.Uploading, + Downloaded: status.Downloaded, + Total: status.Total, + Speed: status.Speed, + Uploaded: status.Uploaded, + UploadSpeed: status.UploadSpeed, + ExtractStatus: status.ExtractStatus, + ExtractProgress: status.ExtractProgress, + CreatedAt: task.CreatedAt, + UpdatedAt: task.UpdatedAt, + } + if task.Meta != nil && task.Meta.Res != nil && len(task.Meta.Res.Files) > 0 { + summary.Name = task.Name() + } + return summary, nil +} + +func parseStatuses(values []string) ([]base.Status, error) { + statuses := make([]base.Status, 0, len(values)) + for _, value := range values { + status := base.Status(value) + switch status { + case base.DownloadStatusReady, base.DownloadStatusRunning, base.DownloadStatusPause, + base.DownloadStatusWait, base.DownloadStatusError, base.DownloadStatusDone: + statuses = append(statuses, status) + default: + return nil, fmt.Errorf("invalid task status %q", value) + } + } + return statuses, nil +} diff --git a/pkg/mcpserver/server_test.go b/pkg/mcpserver/server_test.go new file mode 100644 index 00000000..56d208e1 --- /dev/null +++ b/pkg/mcpserver/server_test.go @@ -0,0 +1,215 @@ +package mcpserver + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "reflect" + "testing" + + "github.com/GopeedLab/gopeed/pkg/download" + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +func TestStreamableHTTPTools(t *testing.T) { + downloadServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if got := r.Header.Get("X-Gopeed-Test"); got != "mcp-object" { + t.Errorf("download request header = %q, want %q", got, "mcp-object") + } + _, _ = w.Write([]byte("ok")) + })) + defer downloadServer.Close() + + downloader := download.NewDownloader(&download.DownloaderConfig{ + Storage: download.NewMemStorage(), + StorageDir: t.TempDir(), + }) + if err := downloader.Setup(); err != nil { + t.Fatal(err) + } + defer downloader.Close() + + httpServer := httptest.NewServer(NewHandler(downloader)) + defer httpServer.Close() + + client := mcp.NewClient(&mcp.Implementation{Name: "gopeed-test", Version: "1.0.0"}, nil) + session, err := client.Connect(context.Background(), &mcp.StreamableClientTransport{Endpoint: httpServer.URL}, nil) + if err != nil { + t.Fatal(err) + } + defer session.Close() + + tools, err := session.ListTools(context.Background(), nil) + if err != nil { + t.Fatal(err) + } + got := make([]string, 0, len(tools.Tools)) + var getTaskTool *mcp.Tool + var resolveTaskTool *mcp.Tool + var createTaskTool *mcp.Tool + for _, tool := range tools.Tools { + got = append(got, tool.Name) + switch tool.Name { + case "get_task": + getTaskTool = tool + case "resolve_task": + resolveTaskTool = tool + case "create_task": + createTaskTool = tool + } + } + want := []string{ + "continue_task", + "create_task", + "delete_task", + "get_task", + "get_task_stats", + "get_task_status", + "list_tasks", + "pause_task", + "resolve_task", + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("tool names = %v, want %v", got, want) + } + if getTaskTool == nil { + t.Fatal("get_task tool not found") + } + if resolveTaskTool == nil || createTaskTool == nil { + t.Fatal("download tools not found") + } + if instructions := session.InitializeResult().Instructions; instructions != serverInstructions { + t.Fatalf("server instructions = %q, want %q", instructions, serverInstructions) + } + for _, tool := range []*mcp.Tool{resolveTaskTool, createTaskTool} { + inputSchema, err := json.Marshal(tool.InputSchema) + if err != nil { + t.Fatal(err) + } + if bytes.Contains(inputSchema, []byte(`"extra":true`)) { + t.Fatalf("%s input schema contains unconstrained boolean schema for extra: %s", tool.Name, inputSchema) + } + var schema map[string]any + if err := json.Unmarshal(inputSchema, &schema); err != nil { + t.Fatal(err) + } + reqExtra := nestedSchemaProperty(t, schema, "req", "extra") + branches, ok := reqExtra["oneOf"].([]any) + if reqExtra["type"] != "object" || !ok || len(branches) != 2 { + t.Fatalf("%s req.extra schema = %#v, want an object with two oneOf branches", tool.Name, reqExtra) + } + optsExtra := nestedSchemaProperty(t, schema, "opts", "extra") + if !schemaIncludesType(optsExtra, "object") || optsExtra["additionalProperties"] != false { + t.Fatalf("%s opts.extra schema = %#v, want concrete HTTP task settings", tool.Name, optsExtra) + } + } + outputSchema, err := json.Marshal(getTaskTool.OutputSchema) + if err != nil { + t.Fatal(err) + } + for _, property := range [][]byte{[]byte(`"task"`), []byte(`"id"`), []byte(`"req"`), []byte(`"res"`), []byte(`"opts"`)} { + if !bytes.Contains(outputSchema, property) { + t.Fatalf("get_task output schema %s does not contain property %s", outputSchema, property) + } + } + + resolveResult, err := session.CallTool(context.Background(), &mcp.CallToolParams{ + Name: "resolve_task", + Arguments: map[string]any{ + "req": map[string]any{ + "url": downloadServer.URL, + "extra": map[string]any{ + "method": "GET", + "header": map[string]any{"X-Gopeed-Test": "mcp-object"}, + }, + }, + }, + }) + if err != nil { + t.Fatal(err) + } + if resolveResult.IsError { + t.Fatalf("resolve_task returned tool error: %+v", resolveResult.Content) + } + + invalidExtraResult, err := session.CallTool(context.Background(), &mcp.CallToolParams{ + Name: "resolve_task", + Arguments: map[string]any{ + "req": map[string]any{ + "url": downloadServer.URL, + "extra": map[string]any{ + "trackers": []any{"udp://tracker.example.com:80"}, + }, + }, + }, + }) + if err != nil { + t.Fatal(err) + } + if !invalidExtraResult.IsError { + t.Fatal("resolve_task accepted BitTorrent settings for an HTTP URL") + } + + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{ + Name: "list_tasks", + Arguments: map[string]any{}, + }) + if err != nil { + t.Fatal(err) + } + if result.IsError { + t.Fatalf("list_tasks returned tool error: %+v", result.Content) + } + structured, ok := result.StructuredContent.(map[string]any) + if !ok { + t.Fatalf("structured content type = %T", result.StructuredContent) + } + if tasks, ok := structured["tasks"].([]any); !ok || len(tasks) != 0 { + t.Fatalf("tasks = %#v, want empty array", structured["tasks"]) + } +} + +func nestedSchemaProperty(t *testing.T, schema map[string]any, path ...string) map[string]any { + t.Helper() + current := schema + for _, name := range path { + properties, ok := current["properties"].(map[string]any) + if !ok { + t.Fatalf("schema has no properties while resolving %v: %#v", path, current) + } + current, ok = properties[name].(map[string]any) + if !ok { + t.Fatalf("schema property %q is missing while resolving %v: %#v", name, path, properties) + } + } + return current +} + +func schemaIncludesType(schema map[string]any, want string) bool { + switch value := schema["type"].(type) { + case string: + return value == want + case []any: + for _, item := range value { + if item == want { + return true + } + } + } + return false +} + +func TestStreamableHTTPRejectsCrossOriginRequest(t *testing.T) { + req := httptest.NewRequest(http.MethodPost, "http://localhost/mcp", nil) + req.Header.Set("Origin", "https://example.com") + recorder := httptest.NewRecorder() + + downloader := download.NewDownloader(&download.DownloaderConfig{Storage: download.NewMemStorage()}) + NewHandler(downloader).ServeHTTP(recorder, req) + + if recorder.Code != http.StatusForbidden { + t.Fatalf("status = %d, want %d", recorder.Code, http.StatusForbidden) + } +} diff --git a/pkg/rest/api_server_manager.go b/pkg/rest/api_server_manager.go index 01e1e2a5..030bed88 100644 --- a/pkg/rest/api_server_manager.go +++ b/pkg/rest/api_server_manager.go @@ -196,6 +196,7 @@ func (m *apiServerManager) stateLocked(desired *base.APIServerConfig) *model.API state.Enabled = desired.Enable } if m.activeConfig != nil { + state.MCPEnabled = m.activeConfig.MCPEnable state.Network = m.activeConfig.Network state.Address = m.activeConfig.Address } diff --git a/pkg/rest/invoke_test.go b/pkg/rest/invoke_test.go index 6bafc4da..1d9dfe2e 100644 --- a/pkg/rest/invoke_test.go +++ b/pkg/rest/invoke_test.go @@ -5,6 +5,7 @@ import ( "fmt" "net/http" "path/filepath" + "strings" "testing" "github.com/GopeedLab/gopeed/pkg/base" @@ -45,10 +46,11 @@ func TestAPIServerLifecycleUsesPersistedConfigWithoutRestartingCore(t *testing.T } putAPIServerConfig(t, &base.APIServerConfig{ - Enable: true, - Network: "tcp", - Address: "127.0.0.1:0", - Token: "secret", + Enable: true, + MCPEnable: true, + Network: "tcp", + Address: "127.0.0.1:0", + Token: "secret", }) state, err := StartAPIServer() if err != nil { @@ -58,6 +60,9 @@ func TestAPIServerLifecycleUsesPersistedConfigWithoutRestartingCore(t *testing.T if port == 0 { t.Fatal("enabling the REST API did not open a TCP listener") } + if !state.MCPEnabled { + t.Fatal("persisted MCP endpoint setting was not applied") + } request, err := http.NewRequest(http.MethodGet, fmt.Sprintf("http://127.0.0.1:%d/api/v1/info", port), nil) if err != nil { @@ -73,10 +78,30 @@ func TestAPIServerLifecycleUsesPersistedConfigWithoutRestartingCore(t *testing.T t.Fatalf("REST API returned status %d", response.StatusCode) } + mcpRequest, err := http.NewRequest( + http.MethodPost, + fmt.Sprintf("http://127.0.0.1:%d/mcp", port), + strings.NewReader(`{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"gopeed-test","version":"1.0.0"}}}`), + ) + if err != nil { + t.Fatal(err) + } + mcpRequest.Header.Set("Accept", "application/json, text/event-stream") + mcpRequest.Header.Set("Content-Type", "application/json") + mcpRequest.Header.Set("Authorization", "Bearer secret") + mcpResponse, err := http.DefaultClient.Do(mcpRequest) + if err != nil { + t.Fatal(err) + } + mcpResponse.Body.Close() + if mcpResponse.StatusCode != http.StatusOK { + t.Fatalf("MCP endpoint returned status %d", mcpResponse.StatusCode) + } + putAPIServerConfig(t, &base.APIServerConfig{Enable: false}) if state, err := StopAPIServer(); err != nil { t.Fatal(err) - } else if state.Running || state.RunningPort != 0 || state.PendingApply { + } else if state.Running || state.MCPEnabled || state.RunningPort != 0 || state.PendingApply { t.Fatalf("unexpected stopped API server state: %#v", state) } diff --git a/pkg/rest/model/server.go b/pkg/rest/model/server.go index 6f05434b..441a0ffd 100644 --- a/pkg/rest/model/server.go +++ b/pkg/rest/model/server.go @@ -19,6 +19,7 @@ type StartConfig struct { Network string `json:"network"` Address string `json:"address"` ApiEnable *bool `json:"apiEnable,omitempty"` + MCPEnable bool `json:"mcpEnable"` RefreshInterval int `json:"refreshInterval"` Storage Storage `json:"storage"` StorageDir string `json:"storageDir"` @@ -84,6 +85,7 @@ type WebAuth struct { // persisted APIServerConfig remains the desired state. type APIServerState struct { Enabled bool `json:"enabled"` + MCPEnabled bool `json:"mcpEnabled"` Running bool `json:"running"` Network string `json:"network"` Address string `json:"address"` diff --git a/pkg/rest/server.go b/pkg/rest/server.go index 6cff2c0d..a62562ba 100644 --- a/pkg/rest/server.go +++ b/pkg/rest/server.go @@ -2,6 +2,7 @@ package rest import ( "crypto/rand" + "crypto/subtle" "encoding/base64" "encoding/json" "fmt" @@ -19,6 +20,7 @@ import ( goapi "github.com/GopeedLab/gopeed/pkg/api" "github.com/GopeedLab/gopeed/pkg/base" "github.com/GopeedLab/gopeed/pkg/download" + "github.com/GopeedLab/gopeed/pkg/mcpserver" "github.com/GopeedLab/gopeed/pkg/rest/model" "github.com/GopeedLab/gopeed/pkg/util" "github.com/gorilla/handlers" @@ -119,6 +121,7 @@ func Start(startCfg *model.StartConfig) (port int, err error) { apiConfig.Init() } startCfg.ApiEnable = &apiConfig.Enable + startCfg.MCPEnable = apiConfig.MCPEnable startCfg.Network = apiConfig.Network startCfg.Address = apiConfig.Address startCfg.ApiToken = apiConfig.Token @@ -130,10 +133,11 @@ func Start(startCfg *model.StartConfig) (port int, err error) { return 0, nil } config := &base.APIServerConfig{ - Enable: true, - Network: startCfg.Network, - Address: startCfg.Address, - Token: startCfg.ApiToken, + Enable: true, + MCPEnable: startCfg.MCPEnable, + Network: startCfg.Network, + Address: startCfg.Address, + Token: startCfg.ApiToken, } port, startErr := apiServer.startWithConfigLocked(startCfg, config) if startErr != nil && startCfg.NativeMode { @@ -213,6 +217,13 @@ func buildServer(startCfg *model.StartConfig, continueOnStartup bool) (*http.Ser }) } r.Path("/api/web/proxy").HandlerFunc(DoProxy) + if startCfg.MCPEnable { + r.Path("/mcp").Handler(mcpserver.NewHandler(Downloader)) + } else { + r.Path("/mcp").Handler(http.NotFoundHandler()) + } + // Reserve the endpoint namespace so the web UI catch-all cannot serve it. + r.PathPrefix("/mcp/").Handler(http.NotFoundHandler()) enableApiToken := startCfg.ApiToken != "" enableWebAuth := startCfg.WebEnable && startCfg.WebAuth != nil @@ -260,17 +271,17 @@ func buildServer(startCfg *model.StartConfig, continueOnStartup bool) (*http.Ser r.Use(func(h http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - protectedPath := strings.HasPrefix(r.URL.Path, "/api/") || isProtectedWebFilePath(r.URL.Path) + protectedPath := strings.HasPrefix(r.URL.Path, "/api/") || isProtectedWebFilePath(r.URL.Path) || + (startCfg.MCPEnable && isMCPPath(r.URL.Path)) if r.URL.Path == "/api/web/login" || !protectedPath { h.ServeHTTP(w, r) return } if enableApiToken { - apiTokenHeader := r.Header["X-Api-Token"] // If an API token header is set, validate it before other authentication methods. - if len(apiTokenHeader) > 0 { - if apiTokenHeader[0] == startCfg.ApiToken { + if provided, valid := validateAPITokenHeaders(r, startCfg.ApiToken); provided { + if valid { h.ServeHTTP(w, r) return } @@ -314,9 +325,10 @@ func buildServer(startCfg *model.StartConfig, continueOnStartup bool) (*http.Ser }) server := &http.Server{Handler: handlers.CORS( - handlers.AllowedHeaders([]string{"Content-Type", "Authorization", "X-Api-Token", "X-Target-Uri"}), + handlers.AllowedHeaders([]string{"Accept", "Content-Type", "Authorization", "X-Api-Token", "X-Target-Uri", "Mcp-Method", "Mcp-Name", "Mcp-Protocol-Version", "Mcp-Session-Id", "Last-Event-ID"}), handlers.AllowedMethods([]string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"}), handlers.AllowedOrigins([]string{"*"}), + handlers.ExposedHeaders([]string{"Mcp-Session-Id"}), handlers.AllowCredentials(), )(r)} return server, listener, nil @@ -328,6 +340,7 @@ func startConfigForAPIServer(config *base.APIServerConfig) *model.StartConfig { Network: config.Network, Address: config.Address, ApiEnable: &enabled, + MCPEnable: config.MCPEnable, ApiToken: config.Token, NativeMode: true, Storage: model.StorageMem, @@ -346,10 +359,34 @@ func cloneAPIServerConfig(config *base.APIServerConfig) *base.APIServerConfig { func sameAPIServerConfig(left, right *base.APIServerConfig) bool { return left != nil && right != nil && - left.Enable == right.Enable && left.Network == right.Network && + left.Enable == right.Enable && left.MCPEnable == right.MCPEnable && left.Network == right.Network && left.Address == right.Address && left.Token == right.Token } +func validateAPITokenHeaders(r *http.Request, expected string) (provided bool, valid bool) { + for _, token := range r.Header.Values("X-Api-Token") { + provided = true + if constantTimeTokenEqual(strings.TrimSpace(token), expected) { + valid = true + } + } + for _, authorization := range r.Header.Values("Authorization") { + parts := strings.Fields(authorization) + if len(parts) == 0 || !strings.EqualFold(parts[0], "Bearer") { + continue + } + provided = true + if len(parts) == 2 && constantTimeTokenEqual(parts[1], expected) { + valid = true + } + } + return provided, valid +} + +func constantTimeTokenEqual(actual, expected string) bool { + return len(actual) == len(expected) && subtle.ConstantTimeCompare([]byte(actual), []byte(expected)) == 1 +} + func initializeCore(startCfg *model.StartConfig) error { if APIService != nil { return nil @@ -385,6 +422,10 @@ func isProtectedWebFilePath(urlPath string) bool { return urlPath == "/fs" || strings.HasPrefix(urlPath, "/fs/") } +func isMCPPath(urlPath string) bool { + return urlPath == "/mcp" || strings.HasPrefix(urlPath, "/mcp/") +} + func requestUsesHTTPS(r *http.Request) bool { if r.TLS != nil { return true diff --git a/pkg/rest/server_test.go b/pkg/rest/server_test.go index 35d6abed..65dd55b2 100644 --- a/pkg/rest/server_test.go +++ b/pkg/rest/server_test.go @@ -780,6 +780,7 @@ func TestApiToken(t *testing.T) { var cfg = &model.StartConfig{} cfg.Init() cfg.ApiToken = "123456" + cfg.MCPEnable = true fileListener := doStart(cfg) defer func() { if err := fileListener.Close(); err != nil { @@ -800,6 +801,112 @@ func TestApiToken(t *testing.T) { t.Errorf("TestApiToken() got = %v, want %v", status, http.StatusOK) } + status, _ = doHttpRequest0(http.MethodGet, "/api/v1/config", map[string]string{ + "Authorization": "Bearer " + cfg.ApiToken, + }, nil) + if status != http.StatusOK { + t.Errorf("Bearer API token status = %v, want %v", status, http.StatusOK) + } + + status, _ = doHttpRequest0(http.MethodGet, "/api/v1/config", map[string]string{ + "Authorization": "bearer " + cfg.ApiToken, + }, nil) + if status != http.StatusOK { + t.Errorf("case-insensitive Bearer API token status = %v, want %v", status, http.StatusOK) + } + + status, _ = doHttpRequest0(http.MethodGet, "/api/v1/config", map[string]string{ + "Authorization": "Bearer invalid", + }, nil) + if status != http.StatusUnauthorized { + t.Errorf("invalid Bearer API token status = %v, want %v", status, http.StatusUnauthorized) + } + + request, err := http.NewRequest(http.MethodGet, fmt.Sprintf("http://127.0.0.1:%d/api/v1/config", restPort), nil) + if err != nil { + t.Fatal(err) + } + request.Header.Set("X-Api-Token", "invalid") + request.Header.Set("Authorization", "Bearer "+cfg.ApiToken) + response, err := http.DefaultClient.Do(request) + if err != nil { + t.Fatal(err) + } + response.Body.Close() + if response.StatusCode != http.StatusOK { + t.Errorf("one valid token header status = %v, want %v", response.StatusCode, http.StatusOK) + } + + mcpInitialize := map[string]any{ + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": map[string]any{ + "protocolVersion": "2025-11-25", + "capabilities": map[string]any{}, + "clientInfo": map[string]any{"name": "gopeed-test", "version": "1.0.0"}, + }, + } + status, _ = doHttpRequest0(http.MethodPost, "/mcp", map[string]string{ + "Accept": "application/json, text/event-stream", + "Content-Type": "application/json", + }, mcpInitialize) + if status != http.StatusUnauthorized { + t.Errorf("unauthenticated MCP status = %v, want %v", status, http.StatusUnauthorized) + } + status, _ = doHttpRequest0(http.MethodPost, "/mcp", map[string]string{ + "Accept": "application/json, text/event-stream", + "Content-Type": "application/json", + "Authorization": "Bearer " + cfg.ApiToken, + }, mcpInitialize) + if status != http.StatusOK { + t.Errorf("Bearer-authenticated MCP status = %v, want %v", status, http.StatusOK) + } + +} + +func TestMCPRouteToggle(t *testing.T) { + initialize := map[string]any{ + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": map[string]any{ + "protocolVersion": "2025-11-25", + "capabilities": map[string]any{}, + "clientInfo": map[string]any{ + "name": "gopeed-test", + "version": "1.0.0", + }, + }, + } + headers := map[string]string{ + "Accept": "application/json, text/event-stream", + "Content-Type": "application/json", + } + + t.Run("disabled", func(t *testing.T) { + cfg := (&model.StartConfig{Storage: model.StorageMem, ApiToken: "secret"}).Init() + fileListener := doStart(cfg) + defer fileListener.Close() + defer Stop() + + status, _ := doHttpRequest0(http.MethodPost, "/mcp", headers, initialize) + if status != http.StatusNotFound { + t.Fatalf("disabled MCP status = %d, want %d", status, http.StatusNotFound) + } + }) + + t.Run("enabled", func(t *testing.T) { + cfg := (&model.StartConfig{Storage: model.StorageMem, MCPEnable: true}).Init() + fileListener := doStart(cfg) + defer fileListener.Close() + defer Stop() + + status, _ := doHttpRequest0(http.MethodPost, "/mcp", headers, initialize) + if status != http.StatusOK { + t.Fatalf("enabled MCP status = %d, want %d", status, http.StatusOK) + } + }) } func TestBuildServerRejectsWebAPITokenWithoutWebAuth(t *testing.T) { diff --git a/ui/flutter/assets/agent_icons/LICENSE b/ui/flutter/assets/agent_icons/LICENSE new file mode 100644 index 00000000..1dd53d2a --- /dev/null +++ b/ui/flutter/assets/agent_icons/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2023 LobeHub + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/ui/flutter/assets/agent_icons/NOTICE.md b/ui/flutter/assets/agent_icons/NOTICE.md new file mode 100644 index 00000000..982a7f25 --- /dev/null +++ b/ui/flutter/assets/agent_icons/NOTICE.md @@ -0,0 +1,13 @@ +# Agent icon assets + +The SVG assets in this directory are sourced from +[`@lobehub/icons-static-svg` 1.94.0](https://www.npmjs.com/package/@lobehub/icons-static-svg), +published by the [Lobe Icons](https://github.com/lobehub/lobe-icons) project under the MIT License. + +Product mappings used by Gopeed: + +- `deepseek.svg` represents DeepSeek Harness. +- `zcode.svg` is the Z.ai brand icon used for ZCode. +- `codebuddy.svg` is the CodeBuddy brand icon used for WorkBuddy because Lobe Icons does not currently include a dedicated WorkBuddy icon. + +Product names and logos may be trademarks of their respective owners. Their appearance here identifies compatible MCP clients and does not imply endorsement. diff --git a/ui/flutter/assets/agent_icons/claude_code.svg b/ui/flutter/assets/agent_icons/claude_code.svg new file mode 100644 index 00000000..d55522ce --- /dev/null +++ b/ui/flutter/assets/agent_icons/claude_code.svg @@ -0,0 +1 @@ +Claude Code diff --git a/ui/flutter/assets/agent_icons/cline.svg b/ui/flutter/assets/agent_icons/cline.svg new file mode 100644 index 00000000..d7fb67b5 --- /dev/null +++ b/ui/flutter/assets/agent_icons/cline.svg @@ -0,0 +1 @@ +Cline diff --git a/ui/flutter/assets/agent_icons/codebuddy.svg b/ui/flutter/assets/agent_icons/codebuddy.svg new file mode 100644 index 00000000..04b3e823 --- /dev/null +++ b/ui/flutter/assets/agent_icons/codebuddy.svg @@ -0,0 +1 @@ +CodeBuddy diff --git a/ui/flutter/assets/agent_icons/codex.svg b/ui/flutter/assets/agent_icons/codex.svg new file mode 100644 index 00000000..9ce43929 --- /dev/null +++ b/ui/flutter/assets/agent_icons/codex.svg @@ -0,0 +1 @@ +Codex diff --git a/ui/flutter/assets/agent_icons/cursor.svg b/ui/flutter/assets/agent_icons/cursor.svg new file mode 100644 index 00000000..79b44c5e --- /dev/null +++ b/ui/flutter/assets/agent_icons/cursor.svg @@ -0,0 +1 @@ +Cursor diff --git a/ui/flutter/assets/agent_icons/deepseek.svg b/ui/flutter/assets/agent_icons/deepseek.svg new file mode 100644 index 00000000..52eec25c --- /dev/null +++ b/ui/flutter/assets/agent_icons/deepseek.svg @@ -0,0 +1 @@ +DeepSeek diff --git a/ui/flutter/assets/agent_icons/gemini_cli.svg b/ui/flutter/assets/agent_icons/gemini_cli.svg new file mode 100644 index 00000000..1b163748 --- /dev/null +++ b/ui/flutter/assets/agent_icons/gemini_cli.svg @@ -0,0 +1 @@ +Gemini CLI diff --git a/ui/flutter/assets/agent_icons/github_copilot.svg b/ui/flutter/assets/agent_icons/github_copilot.svg new file mode 100644 index 00000000..e152afe5 --- /dev/null +++ b/ui/flutter/assets/agent_icons/github_copilot.svg @@ -0,0 +1 @@ +GithubCopilot diff --git a/ui/flutter/assets/agent_icons/opencode.svg b/ui/flutter/assets/agent_icons/opencode.svg new file mode 100644 index 00000000..5c1bb853 --- /dev/null +++ b/ui/flutter/assets/agent_icons/opencode.svg @@ -0,0 +1 @@ +opencode diff --git a/ui/flutter/assets/agent_icons/pi.svg b/ui/flutter/assets/agent_icons/pi.svg new file mode 100644 index 00000000..04e9e042 --- /dev/null +++ b/ui/flutter/assets/agent_icons/pi.svg @@ -0,0 +1 @@ +Pi diff --git a/ui/flutter/assets/agent_icons/qoder.svg b/ui/flutter/assets/agent_icons/qoder.svg new file mode 100644 index 00000000..f316f8bb --- /dev/null +++ b/ui/flutter/assets/agent_icons/qoder.svg @@ -0,0 +1 @@ +Qoder diff --git a/ui/flutter/assets/agent_icons/trae.svg b/ui/flutter/assets/agent_icons/trae.svg new file mode 100644 index 00000000..dbf31a65 --- /dev/null +++ b/ui/flutter/assets/agent_icons/trae.svg @@ -0,0 +1 @@ +TRAE diff --git a/ui/flutter/assets/agent_icons/windsurf.svg b/ui/flutter/assets/agent_icons/windsurf.svg new file mode 100644 index 00000000..8128948c --- /dev/null +++ b/ui/flutter/assets/agent_icons/windsurf.svg @@ -0,0 +1 @@ +Windsurf diff --git a/ui/flutter/assets/agent_icons/zcode.svg b/ui/flutter/assets/agent_icons/zcode.svg new file mode 100644 index 00000000..1a47fd72 --- /dev/null +++ b/ui/flutter/assets/agent_icons/zcode.svg @@ -0,0 +1 @@ +Z.ai diff --git a/ui/flutter/lib/api/model/downloader_config.dart b/ui/flutter/lib/api/model/downloader_config.dart index b871bb2c..39b4bbee 100644 --- a/ui/flutter/lib/api/model/downloader_config.dart +++ b/ui/flutter/lib/api/model/downloader_config.dart @@ -32,11 +32,18 @@ class DownloaderConfig { @JsonSerializable() class ApiServerConfig { bool enable; + bool mcpEnable; String network; String address; String token; - ApiServerConfig({this.enable = false, this.network = 'tcp', this.address = '127.0.0.1:9999', this.token = ''}); + ApiServerConfig({ + this.enable = false, + this.mcpEnable = false, + this.network = 'tcp', + this.address = '127.0.0.1:9999', + this.token = '', + }); factory ApiServerConfig.fromJson(Map? json) => json == null ? ApiServerConfig() : _$ApiServerConfigFromJson(json); diff --git a/ui/flutter/lib/api/model/downloader_config.g.dart b/ui/flutter/lib/api/model/downloader_config.g.dart index a4ecba0b..9c82c3ce 100644 --- a/ui/flutter/lib/api/model/downloader_config.g.dart +++ b/ui/flutter/lib/api/model/downloader_config.g.dart @@ -39,6 +39,7 @@ Map _$DownloaderConfigToJson(DownloaderConfig instance) => json) => ApiServerConfig( enable: json['enable'] as bool? ?? false, + mcpEnable: json['mcpEnable'] as bool? ?? false, network: json['network'] as String? ?? 'tcp', address: json['address'] as String? ?? '127.0.0.1:9999', token: json['token'] as String? ?? '', @@ -46,6 +47,7 @@ ApiServerConfig _$ApiServerConfigFromJson(Map json) => ApiServe Map _$ApiServerConfigToJson(ApiServerConfig instance) => { 'enable': instance.enable, + 'mcpEnable': instance.mcpEnable, 'network': instance.network, 'address': instance.address, 'token': instance.token, diff --git a/ui/flutter/lib/app/application/app_runtime_controller.dart b/ui/flutter/lib/app/application/app_runtime_controller.dart index 5dfbfbf4..76dedd3e 100644 --- a/ui/flutter/lib/app/application/app_runtime_controller.dart +++ b/ui/flutter/lib/app/application/app_runtime_controller.dart @@ -13,6 +13,7 @@ import '../../core/common/start_config.dart'; import '../../core/common/task_event.dart'; import '../../core/entry/app_initializer.dart'; import '../../core/libgopeed_boot.dart'; +import '../../core/network/gopeed/gopeed_transport.dart'; import '../../features/auth/application/web_auth_controller.dart'; import '../../l10n/l10n.dart'; import '../../util/log_util.dart'; @@ -64,11 +65,22 @@ class AppRuntimeController extends AsyncNotifier { if (current == null) return; final config = await ref.read(gopeedServiceProvider).getConfig(); final apiState = kIsWeb ? current.apiServerState : (await LibgopeedBoot.instance.getApiServerState()).state; - final startConfig = _copyStartConfig(current.startConfig) - ..apiEnable = config.api.enable - ..network = config.api.network - ..address = config.api.address - ..apiToken = config.api.token; + final startConfig = _copyStartConfig(current.startConfig); + if (kIsWeb) { + // The Web client connection may point at a separate development server + // from the page itself. Downloader config describes the remote process + // and must not replace that client-side endpoint after reload. + startConfig + ..apiEnable = true + ..mcpEnable = config.api.mcpEnable; + } else { + startConfig + ..apiEnable = config.api.enable + ..mcpEnable = config.api.mcpEnable + ..network = config.api.network + ..address = config.api.address + ..apiToken = config.api.token; + } state = AsyncValue.data( AppRuntimeState( startConfig: startConfig, @@ -112,6 +124,7 @@ class AppRuntimeController extends AsyncNotifier { var runningPort = 0; var apiServerState = const ApiServerState( enabled: false, + mcpEnabled: false, running: false, network: '', address: '', @@ -141,7 +154,7 @@ class AppRuntimeController extends AsyncNotifier { if (kIsWeb) { final webAuth = ref.read(webAuthControllerProvider); - api.init('tcp', Uri.base.authority, '', onUnauthorized: webAuth.requireLogin); + api.init('tcp', startConfig.address, '', onUnauthorized: webAuth.requireLogin); } else { api.init(startConfig.network, _runningAddress(startConfig, runningPort), startConfig.apiToken); } @@ -150,6 +163,7 @@ class AppRuntimeController extends AsyncNotifier { if (!kIsWeb) { startConfig ..apiEnable = config.api.enable + ..mcpEnable = config.api.mcpEnable ..network = config.api.network ..address = config.api.address ..apiToken = config.api.token; @@ -206,7 +220,7 @@ class AppRuntimeController extends AsyncNotifier { if (kIsWeb) { cfg ..network = 'tcp' - ..address = Uri.base.authority; + ..address = webApiBaseUrl(Uri.base); _defaultStartConfig = cfg; return cfg; } @@ -404,6 +418,7 @@ class AppRuntimeController extends AsyncNotifier { final downloaderConfig = await ref.read(gopeedServiceProvider).getConfig(); downloaderConfig.api ..enable = nextConfig.apiEnable + ..mcpEnable = nextConfig.mcpEnable ..network = nextConfig.network ..address = nextConfig.address ..token = nextConfig.apiToken; @@ -459,6 +474,7 @@ class AppRuntimeController extends AsyncNotifier { ..network = config.network ..address = config.address ..apiEnable = config.apiEnable + ..mcpEnable = config.mcpEnable ..storage = config.storage ..storageDir = config.storageDir ..refreshInterval = config.refreshInterval diff --git a/ui/flutter/lib/core/common/api_server_state.dart b/ui/flutter/lib/core/common/api_server_state.dart index 7fa4a0c1..e09d1317 100644 --- a/ui/flutter/lib/core/common/api_server_state.dart +++ b/ui/flutter/lib/core/common/api_server_state.dart @@ -1,6 +1,7 @@ class ApiServerState { const ApiServerState({ required this.enabled, + required this.mcpEnabled, required this.running, required this.network, required this.address, @@ -10,6 +11,7 @@ class ApiServerState { }); final bool enabled; + final bool mcpEnabled; final bool running; final String network; final String address; @@ -20,6 +22,7 @@ class ApiServerState { factory ApiServerState.fromJson(Map json) { return ApiServerState( enabled: json['enabled'] as bool? ?? false, + mcpEnabled: json['mcpEnabled'] as bool? ?? false, running: json['running'] as bool? ?? false, network: json['network'] as String? ?? '', address: json['address'] as String? ?? '', @@ -50,6 +53,7 @@ class ApiServerOperationResult { state: rawState == null ? const ApiServerState( enabled: false, + mcpEnabled: false, running: false, network: '', address: '', diff --git a/ui/flutter/lib/core/common/start_config.dart b/ui/flutter/lib/core/common/start_config.dart index 3a7c6aec..ed442eab 100644 --- a/ui/flutter/lib/core/common/start_config.dart +++ b/ui/flutter/lib/core/common/start_config.dart @@ -20,6 +20,7 @@ class StartConfig { late String network; late String address; bool apiEnable = false; + bool mcpEnable = false; late String storage; late String storageDir; late int refreshInterval; diff --git a/ui/flutter/lib/core/common/start_config.g.dart b/ui/flutter/lib/core/common/start_config.g.dart index 866eb000..5d64420d 100644 --- a/ui/flutter/lib/core/common/start_config.g.dart +++ b/ui/flutter/lib/core/common/start_config.g.dart @@ -23,6 +23,7 @@ StartConfig _$StartConfigFromJson(Map json) => StartConfig() ..network = json['network'] as String ..address = json['address'] as String ..apiEnable = json['apiEnable'] as bool + ..mcpEnable = json['mcpEnable'] as bool? ?? false ..storage = json['storage'] as String ..storageDir = json['storageDir'] as String ..refreshInterval = (json['refreshInterval'] as num).toInt() @@ -38,6 +39,7 @@ Map _$StartConfigToJson(StartConfig instance) => 'network': instance.network, 'address': instance.address, 'apiEnable': instance.apiEnable, + 'mcpEnable': instance.mcpEnable, 'storage': instance.storage, 'storageDir': instance.storageDir, 'refreshInterval': instance.refreshInterval, diff --git a/ui/flutter/lib/core/entry/libgopeed_boot_browser.dart b/ui/flutter/lib/core/entry/libgopeed_boot_browser.dart index 04f18448..0ee384a2 100644 --- a/ui/flutter/lib/core/entry/libgopeed_boot_browser.dart +++ b/ui/flutter/lib/core/entry/libgopeed_boot_browser.dart @@ -43,6 +43,7 @@ class LibgopeedBootBrowser implements LibgopeedBoot { const _unsupportedApiServerResult = ApiServerOperationResult( state: ApiServerState( enabled: false, + mcpEnabled: false, running: false, network: '', address: '', diff --git a/ui/flutter/lib/core/network/gopeed/entry/gopeed_transport_web.dart b/ui/flutter/lib/core/network/gopeed/entry/gopeed_transport_web.dart index 0fca3167..fddb9474 100644 --- a/ui/flutter/lib/core/network/gopeed/entry/gopeed_transport_web.dart +++ b/ui/flutter/lib/core/network/gopeed/entry/gopeed_transport_web.dart @@ -8,7 +8,9 @@ class RestGopeedTransport implements GopeedTransport { RestGopeedTransport(GopeedTransportConfig config) : _dio = Dio( BaseOptions( - baseUrl: '${Uri.base.origin}/', + // Keep release requests relative so deployments under a reverse + // proxy path continue to work, matching the legacy Web client. + baseUrl: webTransportBaseUrl(config.address, Uri.base), contentType: Headers.jsonContentType, sendTimeout: const Duration(seconds: 5), connectTimeout: const Duration(seconds: 5), diff --git a/ui/flutter/lib/core/network/gopeed/gopeed_transport.dart b/ui/flutter/lib/core/network/gopeed/gopeed_transport.dart index 9aed7d29..aa2848ac 100644 --- a/ui/flutter/lib/core/network/gopeed/gopeed_transport.dart +++ b/ui/flutter/lib/core/network/gopeed/gopeed_transport.dart @@ -1,4 +1,5 @@ import 'package:dio/dio.dart'; +import 'package:flutter/foundation.dart'; import 'entry/gopeed_transport_stub.dart' if (dart.library.html) 'entry/gopeed_transport_web.dart' @@ -18,6 +19,44 @@ class GopeedTransportConfig { final void Function()? onUnauthorized; } +const defaultWebApiPort = 9999; + +/// Resolves the API base URL used by Flutter Web. +/// +/// This preserves the legacy behavior: debug builds connect to the local +/// backend on port 9999, while release builds use the page's server origin. +String webApiBaseUrl(Uri pageUri, {bool? debugMode}) { + final useDebugPort = debugMode ?? kDebugMode; + if (useDebugPort) return 'http://127.0.0.1:$defaultWebApiPort/'; + return '${pageUri.origin}/'; +} + +/// Returns the Dio base URL, preserving relative requests in release builds. +String webTransportBaseUrl(String address, Uri pageUri, {bool? debugMode}) { + final useDebugPort = debugMode ?? kDebugMode; + return useDebugPort ? normalizeWebApiBaseUrl(address, pageUri) : ''; +} + +/// Normalizes either an absolute API URL or a host:port address. +String normalizeWebApiBaseUrl(String address, Uri pageUri) { + var value = address.trim(); + if (value.isEmpty) return '${pageUri.origin}/'; + + final parsed = Uri.tryParse(value); + if (parsed != null && parsed.hasScheme && parsed.host.isNotEmpty) { + value = Uri( + scheme: parsed.scheme, + userInfo: parsed.userInfo, + host: parsed.host, + port: parsed.hasPort ? parsed.port : null, + path: parsed.path, + ).toString(); + } else { + value = '${pageUri.scheme}://${value.replaceAll(RegExp(r'/+$'), '')}'; + } + return value.endsWith('/') ? value : '$value/'; +} + abstract interface class GopeedTransport { Future request(String path, {String method = 'GET', dynamic data, Map? queryParameters}); diff --git a/ui/flutter/lib/core/window/app_window_chrome.dart b/ui/flutter/lib/core/window/app_window_chrome.dart index d670096e..bd7e2807 100644 --- a/ui/flutter/lib/core/window/app_window_chrome.dart +++ b/ui/flutter/lib/core/window/app_window_chrome.dart @@ -10,7 +10,14 @@ abstract final class AppWindowChrome { static bool get usesCustomChrome => !kIsWeb && (defaultTargetPlatform == TargetPlatform.windows || defaultTargetPlatform == TargetPlatform.linux); - static bool get reservesHeaderInset => isDesktopWindow; + static bool get reservesHeaderInset => reservesHeaderInsetFor(web: kIsWeb, platform: defaultTargetPlatform); + + static bool reservesHeaderInsetFor({required bool web, required TargetPlatform platform}) { + return web || + platform == TargetPlatform.windows || + platform == TargetPlatform.macOS || + platform == TargetPlatform.linux; + } static bool get clipsRoundedCorners => !kIsWeb && defaultTargetPlatform == TargetPlatform.windows; } diff --git a/ui/flutter/lib/features/extensions/presentation/pages/extensions_page.dart b/ui/flutter/lib/features/extensions/presentation/pages/extensions_page.dart index cc462018..9c2f57ab 100644 --- a/ui/flutter/lib/features/extensions/presentation/pages/extensions_page.dart +++ b/ui/flutter/lib/features/extensions/presentation/pages/extensions_page.dart @@ -696,14 +696,11 @@ class _Toolbar extends StatelessWidget { Expanded( child: Row( children: [ - Expanded( - child: Align( - alignment: Alignment.centerLeft, - child: ConstrainedBox( - key: const ValueKey('extension-search-field-container'), - constraints: const BoxConstraints(maxWidth: 240), - child: search, - ), + Flexible( + child: ConstrainedBox( + key: const ValueKey('extension-search-field-container'), + constraints: const BoxConstraints(maxWidth: 240), + child: search, ), ), const SizedBox(width: 10), diff --git a/ui/flutter/lib/features/home/presentation/widgets/tasks_top_bar.dart b/ui/flutter/lib/features/home/presentation/widgets/tasks_top_bar.dart index 569588c6..25063d30 100644 --- a/ui/flutter/lib/features/home/presentation/widgets/tasks_top_bar.dart +++ b/ui/flutter/lib/features/home/presentation/widgets/tasks_top_bar.dart @@ -60,7 +60,7 @@ class TasksTopBar extends StatelessWidget { const SizedBox(width: 16), ConstrainedBox( key: const ValueKey('tasks-create-button-container'), - constraints: const BoxConstraints(maxWidth: 190), + constraints: const BoxConstraints(minWidth: 120, maxWidth: 190), child: _AddTaskButton(onPressed: onAddTask), ), const SizedBox(width: 16), @@ -139,8 +139,14 @@ class _AddTaskButton extends StatelessWidget { Widget build(BuildContext context) { return AppPrimaryButton( onPressed: onPressed, - leading: const Icon(Icons.add), - child: Text(context.l10n.create, maxLines: 1, overflow: TextOverflow.ellipsis), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.add), + const SizedBox(width: AppDesignTokens.space8), + Flexible(child: Text(context.l10n.create, maxLines: 1, overflow: TextOverflow.ellipsis)), + ], + ), ); } } diff --git a/ui/flutter/lib/features/settings/presentation/pages/settings_page.dart b/ui/flutter/lib/features/settings/presentation/pages/settings_page.dart index c6aecf80..89d16495 100644 --- a/ui/flutter/lib/features/settings/presentation/pages/settings_page.dart +++ b/ui/flutter/lib/features/settings/presentation/pages/settings_page.dart @@ -16,6 +16,7 @@ import '../../../../app/application/app_platform_controller.dart'; import '../../../../app/application/app_runtime_controller.dart'; import '../../../../app/application/location_keep_alive.dart'; import '../../../../core/common/start_config.dart'; +import '../../../../core/network/gopeed/gopeed_transport.dart'; import '../../../../core/utils/breakpoints.dart'; import '../../../../core/window/app_window_chrome.dart'; import '../../../../shared/theme/app_design_tokens.dart'; @@ -23,6 +24,7 @@ import '../../../../shared/theme/app_palette.dart'; import '../../../../shared/widgets/app_choice_segmented_control.dart'; import '../../../../shared/widgets/app_loading_button.dart'; import '../../../../shared/widgets/app_path_picker_field.dart'; +import '../../../../shared/widgets/app_tooltip.dart'; import '../../../../shared/widgets/app_toast.dart'; import '../../../../shared/widgets/responsive_menu_layout.dart'; import '../../../../l10n/l10n.dart'; @@ -34,6 +36,7 @@ import '../../../home/presentation/widgets/primary_rail.dart'; import '../../application/settings_controller.dart'; import '../widgets/app_update_dialog.dart'; import '../widgets/download_categories_setting.dart'; +import '../widgets/mcp_agent_setup.dart'; import '../widgets/settings_language_select.dart'; import '../widgets/settings_content_frame.dart'; import '../widgets/settings_item.dart'; @@ -42,6 +45,11 @@ import '../widgets/theme_settings_control.dart'; enum _SettingsSection { basic, downloads, advanced } +String webApiDisplayAddress(Uri pageUri) { + final host = pageUri.host.toLowerCase() == 'localhost' ? '127.0.0.1' : pageUri.host; + return Uri(host: host, port: pageUri.hasPort ? pageUri.port : null).authority; +} + class SettingsPage extends ConsumerStatefulWidget { const SettingsPage({super.key, this.sectionKey}); @@ -69,7 +77,6 @@ class _SettingsPageState extends ConsumerState { final _proxyUserController = TextEditingController(); final _proxyPasswordController = TextEditingController(); final _customTrackersController = TextEditingController(); - final _apiAddressController = TextEditingController(); final _apiHostController = TextEditingController(); final _apiPortController = TextEditingController(); final _apiTokenController = TextEditingController(); @@ -82,7 +89,6 @@ class _SettingsPageState extends ConsumerState { bool _syncingControllers = false; bool _syncingStartControllers = false; bool _savingStartConfig = false; - String? _apiNetworkDraft; List get _textControllers => [ _downloadDirController, @@ -105,7 +111,6 @@ class _SettingsPageState extends ConsumerState { ]; List get _startTextControllers => [ - _apiAddressController, _apiHostController, _apiPortController, _apiTokenController, @@ -149,7 +154,9 @@ class _SettingsPageState extends ConsumerState { _ErrorState(error: error, onRetry: () => ref.read(settingsControllerProvider.notifier).reload()), data: (state) { _syncConfig(state.config); - if (runtimeState != null) { + if (kIsWeb) { + _syncWebStartConfig(state.config.api, runtimeState); + } else if (runtimeState != null) { _syncStartConfig(runtimeState.startConfig); } return ResponsiveMenuLayout<_SettingsSection>( @@ -279,6 +286,7 @@ class _SettingsPageState extends ConsumerState { ) { final apiConfigDirty = _startConfig != null && _startDraftSignature() != _loadedStartSignature; final apiServerVisualState = _apiServerVisualState(runtimeState); + final palette = AppPalette.of(context); switch (section) { case _SettingsSection.basic: return _SettingsSectionStack( @@ -678,6 +686,90 @@ class _SettingsPageState extends ConsumerState { case _SettingsSection.advanced: return _SettingsSectionStack( children: [ + _SettingsBlock( + title: 'API', + child: _SettingsGroup( + children: [ + SettingsItem( + title: context.l10n.apiServerRuntimeStatus, + subtitle: _apiServerRuntimeDetails(runtimeState), + child: _ApiServerStatusLight( + key: const ValueKey('api-server-runtime-status'), + state: apiServerVisualState, + label: switch (apiServerVisualState) { + _ApiServerVisualState.loading => context.l10n.readingRuntimeStatus, + _ApiServerVisualState.running => context.l10n.apiServerRunning, + _ApiServerVisualState.stopped => context.l10n.apiServerStopped, + _ApiServerVisualState.failed => context.l10n.apiServerFailed, + }, + ), + ), + SettingsItem( + title: context.l10n.listenAddress, + child: _HostPortControl( + hostController: _apiHostController, + portController: _apiPortController, + hostKey: const ValueKey('api-host-input'), + portKey: const ValueKey('api-port-input'), + enabled: !kIsWeb, + ), + ), + if (!kIsWeb) + SettingsItem( + title: context.l10n.apiToken, + child: _TextSettingControl(controller: _apiTokenController, obscureText: true), + ), + McpSettingsItem( + enabled: _startConfig?.mcpEnable ?? false, + readOnly: kIsWeb, + onChanged: (value) => setState(() => _startConfig?.mcpEnable = value), + onOpenAgentSetup: () => unawaited(_openMcpAgentSetup(runtimeState)), + ), + Align( + alignment: Alignment.centerRight, + child: Wrap( + spacing: 8, + runSpacing: 8, + alignment: WrapAlignment.end, + children: [ + if (kIsWeb) + AppTooltip( + message: context.l10n.webApiConfigReadOnlyHelp, + child: Padding( + key: const ValueKey('web-api-config-help'), + padding: const EdgeInsets.all(8), + child: Icon(Icons.help_outline, size: 17, color: palette.textMuted), + ), + ), + AppLoadingButton( + key: const ValueKey('toggle-api-server-button'), + onPressed: kIsWeb || runtimeState == null ? null : _toggleApiServer, + loading: _savingStartConfig, + icon: Icon( + kIsWeb || (runtimeState?.apiServerState.running ?? false) + ? Icons.stop_circle_outlined + : Icons.play_circle_outline, + ), + child: Text( + kIsWeb || (runtimeState?.apiServerState.running ?? false) + ? context.l10n.stopApiServer + : context.l10n.startApiServer, + ), + ), + AppLoadingButton( + key: const ValueKey('save-api-config-button'), + onPressed: kIsWeb || runtimeState == null || !apiConfigDirty ? null : _saveApiServerConfig, + loading: _savingStartConfig, + icon: const Icon(Icons.save_outlined), + variant: AppLoadingButtonVariant.primary, + child: Text(context.l10n.save), + ), + ], + ), + ), + ], + ), + ), _SettingsBlock( title: context.l10n.network, child: _SettingsGroup( @@ -762,94 +854,6 @@ class _SettingsPageState extends ConsumerState { ], ), ), - if (!kIsWeb) - _SettingsBlock( - title: 'API', - child: _SettingsGroup( - children: [ - SettingsItem( - title: context.l10n.apiServerRuntimeStatus, - subtitle: _apiServerRuntimeDetails(runtimeState), - child: _ApiServerStatusLight( - key: const ValueKey('api-server-runtime-status'), - state: apiServerVisualState, - label: switch (apiServerVisualState) { - _ApiServerVisualState.loading => context.l10n.readingRuntimeStatus, - _ApiServerVisualState.running => context.l10n.apiServerRunning, - _ApiServerVisualState.stopped => context.l10n.apiServerStopped, - _ApiServerVisualState.failed => context.l10n.apiServerFailed, - }, - ), - ), - SettingsItem( - title: context.l10n.protocol, - child: AppChoiceSegmentedControl( - value: _apiNetworkValue, - buttonKeyPrefix: 'settings-choice', - options: [ - const AppChoiceOption(value: 'tcp', label: 'TCP', icon: Icons.lan_outlined), - if (Util.supportUnixSocket()) - const AppChoiceOption(value: 'unix', label: 'Unix Socket', icon: Icons.cable_outlined), - ], - onChanged: _changeApiNetwork, - ), - ), - if (_apiNetworkValue == 'unix') - SettingsItem( - title: context.l10n.socketPath, - child: _TextSettingControl(controller: _apiAddressController), - ) - else - SettingsItem( - title: context.l10n.listenAddress, - child: _HostPortControl( - hostController: _apiHostController, - portController: _apiPortController, - hostKey: const ValueKey('api-host-input'), - portKey: const ValueKey('api-port-input'), - ), - ), - if (_apiNetworkValue == 'tcp') - SettingsItem( - title: context.l10n.apiToken, - child: _TextSettingControl(controller: _apiTokenController, obscureText: true), - ), - Align( - alignment: Alignment.centerRight, - child: Wrap( - spacing: 8, - runSpacing: 8, - alignment: WrapAlignment.end, - children: [ - AppLoadingButton( - key: const ValueKey('toggle-api-server-button'), - onPressed: runtimeState == null ? null : _toggleApiServer, - loading: _savingStartConfig, - icon: Icon( - runtimeState?.apiServerState.running ?? false - ? Icons.stop_circle_outlined - : Icons.play_circle_outline, - ), - child: Text( - runtimeState?.apiServerState.running ?? false - ? context.l10n.stopApiServer - : context.l10n.startApiServer, - ), - ), - AppLoadingButton( - key: const ValueKey('save-api-config-button'), - onPressed: runtimeState == null || !apiConfigDirty ? null : _saveApiServerConfig, - loading: _savingStartConfig, - icon: const Icon(Icons.save_outlined), - variant: AppLoadingButtonVariant.primary, - child: Text(context.l10n.save), - ), - ], - ), - ), - ], - ), - ), _SettingsBlock( title: context.l10n.developer, child: _SettingsGroup( @@ -905,8 +909,14 @@ class _SettingsPageState extends ConsumerState { subtitle: _logDirectoryLabel, child: shad.SecondaryButton( onPressed: kIsWeb ? null : _openLogDirectory, - leading: const Icon(Icons.folder_open_outlined), - child: Text(context.l10n.open), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.folder_open_outlined), + const SizedBox(width: AppDesignTokens.space8), + Text(context.l10n.open), + ], + ), ), ), ], @@ -957,15 +967,14 @@ class _SettingsPageState extends ConsumerState { _startConfig!.address == config.address && _startConfig!.apiToken == config.apiToken) { _startConfig!.apiEnable = config.apiEnable; + _startConfig!.mcpEnable = config.mcpEnable; _loadedStartSignature = signature; return; } _loadedStartSignature = signature; _startConfig = _copyStartConfig(config); - _apiNetworkDraft = config.network == 'unix' && Util.supportUnixSocket() ? 'unix' : 'tcp'; _syncingStartControllers = true; - _apiAddressController.text = _startConfig!.address; - if (_apiNetworkDraft == 'tcp' && _looksLikeTcpAddress(_startConfig!.address)) { + if (config.network == 'tcp' && _looksLikeTcpAddress(_startConfig!.address)) { final apiAddress = _splitHostPort(_startConfig!.address); _apiHostController.text = apiAddress.host; _apiPortController.text = apiAddress.port; @@ -977,6 +986,19 @@ class _SettingsPageState extends ConsumerState { _syncingStartControllers = false; } + void _syncWebStartConfig(ApiServerConfig config, AppRuntimeState? runtimeState) { + final webConfig = StartConfig() + ..network = 'tcp' + ..address = webApiDisplayAddress(_webApiUri(runtimeState)) + ..apiEnable = true + ..mcpEnable = config.mcpEnable + ..storage = '' + ..storageDir = '' + ..refreshInterval = 0 + ..apiToken = config.token; + _syncStartConfig(webConfig); + } + void _scheduleTextSave() { if (_syncingControllers || _config == null) { return; @@ -1055,10 +1077,8 @@ class _SettingsPageState extends ConsumerState { } void _applyStartControllers(StartConfig config) { - config.network = _apiNetworkDraft ?? config.network; - config.address = config.network == 'unix' - ? _apiAddressController.text.trim() - : _joinHostPort(_apiHostController.text, _apiPortController.text); + config.network = 'tcp'; + config.address = _joinHostPort(_apiHostController.text, _apiPortController.text); config.apiToken = _apiTokenController.text.trim(); } @@ -1184,10 +1204,6 @@ class _SettingsPageState extends ConsumerState { Future _validateStartConfig(StartConfig candidate) async { final l10n = context.l10n; - if (candidate.network == 'unix') { - return candidate.address.trim().isEmpty ? l10n.socketPathRequired : null; - } - final address = _splitHostPort(candidate.address); if (address.host.isEmpty) return l10n.serverRequired; final port = int.tryParse(address.port); @@ -1196,29 +1212,6 @@ class _SettingsPageState extends ConsumerState { return null; } - void _changeApiNetwork(String value) { - final config = _startConfig; - if (config == null || _apiNetworkDraft == value) return; - setState(() { - _apiNetworkDraft = value; - _syncingStartControllers = true; - if (value == 'tcp' && (_apiHostController.text.isEmpty || _apiPortController.text.isEmpty)) { - if (_looksLikeTcpAddress(config.address)) { - final address = _splitHostPort(config.address); - _apiHostController.text = address.host; - _apiPortController.text = address.port; - } else { - _apiHostController.text = '127.0.0.1'; - _apiPortController.text = '9999'; - } - } - if (value == 'unix' && (_apiAddressController.text.isEmpty || _looksLikeTcpAddress(_apiAddressController.text))) { - _apiAddressController.text = '${Util.getStorageDir()}/$unixSocketPath'; - } - _syncingStartControllers = false; - }); - } - Future _runAction(Future Function() action) async { try { await action(); @@ -1422,15 +1415,12 @@ class _SettingsPageState extends ConsumerState { } String _startSignature(StartConfig config) { - return '${config.apiEnable}|${config.network}|${config.address}|${config.apiToken}'; + return '${config.apiEnable}|${config.mcpEnable}|${config.network}|${config.address}|${config.apiToken}'; } String _startDraftSignature() { - final network = _apiNetworkDraft ?? _startConfig?.network ?? 'tcp'; - final address = network == 'unix' - ? _apiAddressController.text.trim() - : _joinHostPort(_apiHostController.text, _apiPortController.text); - return '${_startConfig?.apiEnable ?? false}|$network|$address|${_apiTokenController.text.trim()}'; + final address = _joinHostPort(_apiHostController.text, _apiPortController.text); + return '${_startConfig?.apiEnable ?? false}|${_startConfig?.mcpEnable ?? false}|tcp|$address|${_apiTokenController.text.trim()}'; } StartConfig _copyStartConfig(StartConfig config) { @@ -1438,6 +1428,7 @@ class _SettingsPageState extends ConsumerState { ..network = config.network ..address = config.address ..apiEnable = config.apiEnable + ..mcpEnable = config.mcpEnable ..storage = config.storage ..storageDir = config.storageDir ..refreshInterval = config.refreshInterval @@ -1445,14 +1436,6 @@ class _SettingsPageState extends ConsumerState { ..webViewRpcConfig = config.webViewRpcConfig; } - String get _apiNetworkValue { - final network = _apiNetworkDraft ?? _startConfig?.network ?? 'tcp'; - if (network == 'unix' && Util.supportUnixSocket()) { - return 'unix'; - } - return 'tcp'; - } - String get _proxyMode { final proxy = _config?.proxy; if (proxy == null || !proxy.enable) return 'none'; @@ -1506,6 +1489,40 @@ class _SettingsPageState extends ConsumerState { } } + Future _openMcpAgentSetup(AppRuntimeState? runtimeState) async { + final mcpRunning = kIsWeb + ? (_startConfig?.mcpEnable ?? false) + : runtimeState?.apiServerState.running == true && runtimeState?.apiServerState.mcpEnabled == true; + await showMcpAgentSetupDialog( + context, + endpoint: _mcpEndpoint(runtimeState), + apiToken: _apiTokenController.text.trim(), + mcpRunning: mcpRunning, + allowManualApiTokenTemplate: kIsWeb, + ); + } + + String _mcpEndpoint(AppRuntimeState? runtimeState) { + if (kIsWeb) { + return webMcpEndpoint(_webApiUri(runtimeState)); + } + + final apiState = runtimeState?.apiServerState; + final address = apiState != null && apiState.running && apiState.network == 'tcp' + ? apiState.runningAddress() + : _joinHostPort(_apiHostController.text, _apiPortController.text); + final parts = _splitHostPort(address); + var host = parts.host; + if (host.startsWith('[') && host.endsWith(']')) { + host = host.substring(1, host.length - 1); + } + if (host == '0.0.0.0' || host == '::') { + host = host == '::' ? '::1' : '127.0.0.1'; + } + final port = int.tryParse(parts.port); + return Uri(scheme: 'http', host: host, port: port, path: '/mcp').toString(); + } + bool _looksLikeTcpAddress(String address) { final separator = address.lastIndexOf(':'); if (separator <= 0 || separator == address.length - 1) { @@ -1556,10 +1573,14 @@ class _SettingsPageState extends ConsumerState { String? _apiServerRuntimeDetails(AppRuntimeState? runtimeState) { if (runtimeState == null) return null; + if (kIsWeb) { + final apiUri = _webApiUri(runtimeState); + return '${apiUri.scheme}://${webApiDisplayAddress(apiUri)}'; + } final apiState = runtimeState.apiServerState; final details = []; if (apiState.running) { - details.add('${apiState.network}://${apiState.runningAddress()}'); + details.add('http://${apiState.runningAddress()}'); } if (apiState.lastError.isNotEmpty) { details.add(apiState.lastError); @@ -1569,8 +1590,14 @@ class _SettingsPageState extends ConsumerState { return details.isEmpty ? null : details.join('\n'); } + Uri _webApiUri(AppRuntimeState? runtimeState) { + final address = runtimeState?.startConfig.address ?? ''; + return Uri.parse(normalizeWebApiBaseUrl(address, Uri.base)); + } + _ApiServerVisualState _apiServerVisualState(AppRuntimeState? runtimeState) { if (runtimeState == null) return _ApiServerVisualState.loading; + if (kIsWeb) return _ApiServerVisualState.running; final apiState = runtimeState.apiServerState; if (apiState.running) return _ApiServerVisualState.running; if (apiState.lastError.isNotEmpty) return _ApiServerVisualState.failed; @@ -1875,12 +1902,14 @@ class _HostPortControl extends StatelessWidget { required this.portController, required this.hostKey, required this.portKey, + this.enabled = true, }); final TextEditingController hostController; final TextEditingController portController; final Key hostKey; final Key portKey; + final bool enabled; @override Widget build(BuildContext context) { @@ -1890,7 +1919,12 @@ class _HostPortControl extends StatelessWidget { child: Row( children: [ Expanded( - child: shad.TextField(key: hostKey, controller: hostController, hintText: context.l10n.server), + child: shad.TextField( + key: hostKey, + controller: hostController, + hintText: context.l10n.server, + enabled: enabled, + ), ), const SizedBox(width: AppDesignTokens.space8), SizedBox( @@ -1903,6 +1937,7 @@ class _HostPortControl extends StatelessWidget { step: 1, decimalPlaces: 0, hintText: context.l10n.port, + enabled: enabled, ), ), ], @@ -1920,6 +1955,7 @@ class _NumberInput extends StatefulWidget { this.fieldKey, this.max, this.hintText, + this.enabled = true, }); final TextEditingController controller; @@ -1929,6 +1965,7 @@ class _NumberInput extends StatefulWidget { final int decimalPlaces; final Key? fieldKey; final String? hintText; + final bool enabled; @override State<_NumberInput> createState() => _NumberInputState(); @@ -1993,6 +2030,7 @@ class _NumberInputState extends State<_NumberInput> { ? const TextInputType.numberWithOptions(decimal: true) : TextInputType.number, inputFormatters: formatters, + enabled: widget.enabled, features: [ shad.InputFeature.spinner( step: widget.step, @@ -2130,8 +2168,14 @@ class _BrowserExtensionLinks extends StatelessWidget { shad.SecondaryButton( key: ValueKey('browser-extension-${link.label.toLowerCase()}'), onPressed: () => onOpen(link.uri), - leading: const Icon(Icons.open_in_new, size: 15), - child: Text(link.label), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.open_in_new, size: 15), + const SizedBox(width: AppDesignTokens.space8), + Text(link.label), + ], + ), ), ], ); diff --git a/ui/flutter/lib/features/settings/presentation/widgets/download_categories_setting.dart b/ui/flutter/lib/features/settings/presentation/widgets/download_categories_setting.dart index e5112a2c..4f93d0ae 100644 --- a/ui/flutter/lib/features/settings/presentation/widgets/download_categories_setting.dart +++ b/ui/flutter/lib/features/settings/presentation/widgets/download_categories_setting.dart @@ -64,8 +64,14 @@ class DownloadCategoriesControl extends StatelessWidget { child: shad.SecondaryButton( key: const ValueKey('add-download-category'), onPressed: onAdd, - leading: const Icon(Icons.add, size: 17), - child: Text(context.l10n.add), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.add, size: 17), + const SizedBox(width: AppDesignTokens.space8), + Text(context.l10n.add), + ], + ), ), ), ], diff --git a/ui/flutter/lib/features/settings/presentation/widgets/mcp_agent_setup.dart b/ui/flutter/lib/features/settings/presentation/widgets/mcp_agent_setup.dart new file mode 100644 index 00000000..3ed0360e --- /dev/null +++ b/ui/flutter/lib/features/settings/presentation/widgets/mcp_agent_setup.dart @@ -0,0 +1,748 @@ +import 'dart:convert'; + +import 'package:flutter/material.dart' show Icons, Scrollbar, ScrollbarOrientation, SelectableText; +import 'package:flutter/widgets.dart'; +import 'package:flutter_svg/flutter_svg.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart' as shad; + +import '../../../../l10n/l10n.dart'; +import '../../../../shared/theme/app_design_tokens.dart'; +import '../../../../shared/theme/app_palette.dart'; +import '../../../../shared/widgets/app_copy_icon_button.dart'; +import '../../../../shared/widgets/app_tooltip.dart'; +import 'settings_item.dart'; + +class McpSettingsItem extends StatelessWidget { + const McpSettingsItem({ + super.key, + required this.enabled, + required this.readOnly, + required this.onChanged, + required this.onOpenAgentSetup, + }); + + final bool enabled; + final bool readOnly; + final ValueChanged onChanged; + final VoidCallback onOpenAgentSetup; + + @override + Widget build(BuildContext context) { + final palette = AppPalette.of(context); + return SettingsItem( + title: 'MCP', + subtitle: context.l10n.mcpDescription, + subtitleAction: _McpAgentLink(onPressed: onOpenAgentSetup), + titleTrailing: Icon( + key: const ValueKey('mcp-ai-icon'), + Icons.auto_awesome_outlined, + size: 14, + color: palette.brandProgress, + ), + child: shad.Switch( + key: const ValueKey('mcp-endpoint-switch'), + value: enabled, + enabled: !readOnly, + onChanged: onChanged, + ), + ); + } +} + +class _McpAgentLink extends StatefulWidget { + const _McpAgentLink({required this.onPressed}); + + final VoidCallback onPressed; + + @override + State<_McpAgentLink> createState() => _McpAgentLinkState(); +} + +class _McpAgentLinkState extends State<_McpAgentLink> { + bool _hovered = false; + + @override + Widget build(BuildContext context) { + final palette = AppPalette.of(context); + final color = _hovered ? palette.brandProgress : palette.brand; + return Semantics( + link: true, + label: context.l10n.connectAgent, + button: true, + child: MouseRegion( + cursor: SystemMouseCursors.click, + onEnter: (_) => setState(() => _hovered = true), + onExit: (_) => setState(() => _hovered = false), + child: GestureDetector( + key: const ValueKey('open-mcp-agent-setup'), + behavior: HitTestBehavior.opaque, + onTap: widget.onPressed, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 2), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + context.l10n.connectAgent, + style: TextStyle( + color: color, + fontSize: 12, + fontWeight: FontWeight.w600, + decoration: TextDecoration.underline, + decorationColor: color, + decorationThickness: _hovered ? 1.5 : 1, + ), + ), + const SizedBox(width: 3), + Icon(Icons.arrow_forward, size: 13, color: color), + ], + ), + ), + ), + ), + ); + } +} + +enum _McpAgent { + codex, + claudeCode, + cursor, + githubCopilot, + windsurf, + geminiCli, + cline, + openCode, + trae, + qoder, + workBuddy, + zCode, + deepSeekHarness, + pi, +} + +extension on _McpAgent { + String get label => switch (this) { + _McpAgent.codex => 'Codex', + _McpAgent.claudeCode => 'Claude Code', + _McpAgent.cursor => 'Cursor', + _McpAgent.githubCopilot => 'GitHub Copilot', + _McpAgent.windsurf => 'Windsurf', + _McpAgent.geminiCli => 'Gemini CLI', + _McpAgent.cline => 'Cline', + _McpAgent.openCode => 'OpenCode', + _McpAgent.trae => 'TRAE', + _McpAgent.qoder => 'Qoder', + _McpAgent.workBuddy => 'WorkBuddy', + _McpAgent.zCode => 'ZCode', + _McpAgent.deepSeekHarness => 'DeepSeek Harness', + _McpAgent.pi => 'Pi', + }; + + String get iconAsset => switch (this) { + _McpAgent.codex => 'assets/agent_icons/codex.svg', + _McpAgent.claudeCode => 'assets/agent_icons/claude_code.svg', + _McpAgent.cursor => 'assets/agent_icons/cursor.svg', + _McpAgent.githubCopilot => 'assets/agent_icons/github_copilot.svg', + _McpAgent.windsurf => 'assets/agent_icons/windsurf.svg', + _McpAgent.geminiCli => 'assets/agent_icons/gemini_cli.svg', + _McpAgent.cline => 'assets/agent_icons/cline.svg', + _McpAgent.openCode => 'assets/agent_icons/opencode.svg', + _McpAgent.trae => 'assets/agent_icons/trae.svg', + _McpAgent.qoder => 'assets/agent_icons/qoder.svg', + _McpAgent.workBuddy => 'assets/agent_icons/codebuddy.svg', + _McpAgent.zCode => 'assets/agent_icons/zcode.svg', + _McpAgent.deepSeekHarness => 'assets/agent_icons/deepseek.svg', + _McpAgent.pi => 'assets/agent_icons/pi.svg', + }; + + bool get usesThemeTint => switch (this) { + _McpAgent.cursor || + _McpAgent.githubCopilot || + _McpAgent.windsurf || + _McpAgent.cline || + _McpAgent.openCode || + _McpAgent.zCode || + _McpAgent.pi => true, + _ => false, + }; +} + +String webMcpEndpoint(Uri pageUri) { + return Uri( + scheme: pageUri.scheme, + host: pageUri.host, + port: pageUri.hasPort ? pageUri.port : null, + path: '/mcp', + ).toString(); +} + +Future showMcpAgentSetupDialog( + BuildContext context, { + required String endpoint, + required String apiToken, + required bool mcpRunning, + bool allowManualApiTokenTemplate = false, +}) async { + var selectedAgent = _McpAgent.codex; + var useManualApiTokenTemplate = false; + final overlay = const shad.DialogOverlayHandler().show( + context: context, + alignment: Alignment.center, + barrierDismissable: true, + builder: (dialogContext) => StatefulBuilder( + builder: (dialogContext, setDialogState) { + final palette = AppPalette.of(dialogContext); + final screenSize = MediaQuery.sizeOf(dialogContext); + final dialogWidth = (screenSize.width - 80).clamp(220.0, 620.0); + final contentMaxHeight = (screenSize.height - 140).clamp(120.0, 460.0); + final templateToken = allowManualApiTokenTemplate && useManualApiTokenTemplate ? '' : ''; + final displayToken = allowManualApiTokenTemplate ? templateToken : _maskedToken(apiToken); + final clipboardToken = allowManualApiTokenTemplate ? templateToken : apiToken; + final displaySnippet = _agentSnippet(selectedAgent, endpoint: endpoint, apiToken: displayToken); + final clipboardSnippet = _agentSnippet(selectedAgent, endpoint: endpoint, apiToken: clipboardToken); + + return shad.AlertDialog( + key: const ValueKey('mcp-agent-setup-dialog'), + padding: const EdgeInsets.all(AppDesignTokens.space24), + title: SizedBox( + width: dialogWidth, + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: 34, + height: 34, + decoration: BoxDecoration( + color: palette.brandSoft, + borderRadius: BorderRadius.circular(AppDesignTokens.controlRadius), + ), + alignment: Alignment.center, + child: Icon(Icons.auto_awesome_outlined, size: 19, color: palette.brandProgress), + ), + const SizedBox(width: AppDesignTokens.space12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(dialogContext.l10n.connectMcpAgent), + const SizedBox(height: 4), + Text( + dialogContext.l10n.mcpAgentDialogDescription, + style: TextStyle(color: palette.textSecondary, fontSize: 12, fontWeight: FontWeight.w400), + ), + ], + ), + ), + AppTooltip( + message: dialogContext.l10n.close, + child: shad.IconButton.ghost( + key: const ValueKey('close-mcp-agent-setup'), + size: shad.ButtonSize.xSmall, + onPressed: () => shad.closeOverlay(dialogContext), + icon: const Icon(Icons.close, size: 18), + ), + ), + ], + ), + ), + content: SizedBox( + width: dialogWidth, + child: ConstrainedBox( + constraints: BoxConstraints(maxHeight: contentMaxHeight), + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (!mcpRunning) ...[ + Container( + key: const ValueKey('mcp-not-running-hint'), + padding: const EdgeInsets.symmetric( + horizontal: AppDesignTokens.space12, + vertical: AppDesignTokens.space8, + ), + decoration: BoxDecoration( + color: palette.brandSoft, + borderRadius: BorderRadius.circular(AppDesignTokens.controlRadius), + border: Border.all(color: palette.brandTrack), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(Icons.info_outline, size: 16, color: palette.brandProgress), + const SizedBox(width: AppDesignTokens.space8), + Expanded( + child: Text( + dialogContext.l10n.mcpNotRunningHint, + style: TextStyle(color: palette.textSecondary, fontSize: 12, height: 1.4), + ), + ), + ], + ), + ), + const SizedBox(height: AppDesignTokens.space16), + ], + _McpAgentGrid( + selected: selectedAgent, + onSelected: (value) => setDialogState(() => selectedAgent = value), + ), + const SizedBox(height: AppDesignTokens.space16), + Row( + children: [ + _McpAgentLogo(agent: selectedAgent, size: 20), + const SizedBox(width: AppDesignTokens.space8), + Expanded( + child: Row( + children: [ + Flexible( + child: Text( + '${selectedAgent.label} · MCP', + style: TextStyle( + color: palette.textPrimary, + fontSize: 13, + fontWeight: FontWeight.w600, + ), + ), + ), + if (allowManualApiTokenTemplate) ...[ + const SizedBox(width: 2), + _McpApiTokenTemplateHint( + key: ValueKey('mcp-api-token-template-help-$useManualApiTokenTemplate'), + onUseTemplate: () => setDialogState(() => useManualApiTokenTemplate = true), + ), + ], + ], + ), + ), + AppCopyIconButton(key: const ValueKey('copy-mcp-agent-snippet'), text: clipboardSnippet), + ], + ), + const SizedBox(height: AppDesignTokens.space8), + _McpSnippetBox(snippet: displaySnippet), + ], + ), + ), + ), + ), + ); + }, + ), + ); + await overlay.future; +} + +class _McpApiTokenTemplateHint extends StatelessWidget { + const _McpApiTokenTemplateHint({super.key, required this.onUseTemplate}); + + final VoidCallback onUseTemplate; + + @override + Widget build(BuildContext context) { + final palette = AppPalette.of(context); + return shad.HoverCard( + wait: const Duration(milliseconds: 180), + debounce: const Duration(milliseconds: 350), + popoverAlignment: Alignment.topLeft, + anchorAlignment: Alignment.bottomLeft, + popoverOffset: const Offset(0, 6), + hoverBuilder: (hoverContext) { + final hoverPalette = AppPalette.of(hoverContext); + return Container( + key: const ValueKey('mcp-api-token-template-hint'), + width: 288, + padding: const EdgeInsets.all(AppDesignTokens.space12), + decoration: BoxDecoration( + color: hoverPalette.cardBg, + borderRadius: BorderRadius.circular(AppDesignTokens.controlRadius), + border: Border.all(color: hoverPalette.border), + boxShadow: [ + BoxShadow( + color: const Color(0xFF000000).withValues(alpha: 0.16), + blurRadius: 16, + offset: const Offset(0, 6), + ), + ], + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + hoverContext.l10n.webMcpApiTokenHint, + style: TextStyle(color: hoverPalette.textSecondary, fontSize: 12, height: 1.45), + ), + const SizedBox(height: 6), + Semantics( + link: true, + button: true, + label: hoverContext.l10n.useMcpApiTokenTemplate, + child: MouseRegion( + cursor: SystemMouseCursors.click, + child: GestureDetector( + key: const ValueKey('use-mcp-api-token-template'), + behavior: HitTestBehavior.opaque, + onTap: onUseTemplate, + child: Text( + hoverContext.l10n.useMcpApiTokenTemplate, + style: TextStyle( + color: hoverPalette.brand, + fontSize: 12, + fontWeight: FontWeight.w600, + decoration: TextDecoration.underline, + decorationColor: hoverPalette.brand, + ), + ), + ), + ), + ), + ], + ), + ); + }, + child: Semantics( + label: context.l10n.webMcpApiTokenHint, + child: SizedBox( + key: const ValueKey('mcp-api-token-template-help'), + width: 28, + height: 28, + child: Icon(Icons.help_outline, size: 16, color: palette.textMuted), + ), + ), + ); + } +} + +class _McpAgentGrid extends StatelessWidget { + const _McpAgentGrid({required this.selected, required this.onSelected}); + + final _McpAgent selected; + final ValueChanged<_McpAgent> onSelected; + + @override + Widget build(BuildContext context) { + return LayoutBuilder( + builder: (context, constraints) { + final columns = switch (constraints.maxWidth) { + >= 520 => 8, + >= 380 => 6, + >= 260 => 5, + _ => 4, + }; + return GridView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: columns, + crossAxisSpacing: AppDesignTokens.space8, + mainAxisSpacing: AppDesignTokens.space8, + ), + itemCount: _McpAgent.values.length, + itemBuilder: (context, index) { + final agent = _McpAgent.values[index]; + return _McpAgentButton( + key: ValueKey('mcp-agent-${_agentKey(agent)}'), + agent: agent, + selected: agent == selected, + onPressed: () => onSelected(agent), + ); + }, + ); + }, + ); + } +} + +class _McpAgentButton extends StatefulWidget { + const _McpAgentButton({super.key, required this.agent, required this.selected, required this.onPressed}); + + final _McpAgent agent; + final bool selected; + final VoidCallback onPressed; + + @override + State<_McpAgentButton> createState() => _McpAgentButtonState(); +} + +class _McpAgentButtonState extends State<_McpAgentButton> { + bool _hovered = false; + + @override + Widget build(BuildContext context) { + final palette = AppPalette.of(context); + final background = widget.selected + ? palette.brandSoft + : _hovered + ? palette.cardHoverBg + : palette.cardBg; + return Semantics( + button: true, + selected: widget.selected, + label: widget.agent.label, + child: AppTooltip( + message: widget.agent.label, + child: MouseRegion( + cursor: SystemMouseCursors.click, + onEnter: (_) => setState(() => _hovered = true), + onExit: (_) => setState(() => _hovered = false), + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: widget.onPressed, + child: AnimatedContainer( + duration: const Duration(milliseconds: 140), + curve: Curves.easeOut, + decoration: BoxDecoration( + color: background, + borderRadius: BorderRadius.circular(AppDesignTokens.controlRadius + 2), + border: Border.all( + color: widget.selected ? palette.brandProgress : palette.border, + width: widget.selected ? 1.5 : 1, + ), + ), + alignment: Alignment.center, + child: _McpAgentLogo(agent: widget.agent, size: 28), + ), + ), + ), + ), + ); + } +} + +class _McpAgentLogo extends StatelessWidget { + const _McpAgentLogo({required this.agent, required this.size}); + + final _McpAgent agent; + final double size; + + @override + Widget build(BuildContext context) { + final palette = AppPalette.of(context); + return SvgPicture.asset( + agent.iconAsset, + width: size, + height: size, + fit: BoxFit.contain, + excludeFromSemantics: true, + colorFilter: agent.usesThemeTint ? ColorFilter.mode(palette.textPrimary, BlendMode.srcIn) : null, + ); + } +} + +class _McpSnippetBox extends StatefulWidget { + const _McpSnippetBox({required this.snippet}); + + final String snippet; + + @override + State<_McpSnippetBox> createState() => _McpSnippetBoxState(); +} + +class _McpSnippetBoxState extends State<_McpSnippetBox> { + final _horizontalController = ScrollController(); + final _verticalController = ScrollController(); + + @override + void didUpdateWidget(covariant _McpSnippetBox oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.snippet == oldWidget.snippet) return; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (_horizontalController.hasClients) _horizontalController.jumpTo(0); + if (_verticalController.hasClients) _verticalController.jumpTo(0); + }); + } + + @override + void dispose() { + _horizontalController.dispose(); + _verticalController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final palette = AppPalette.of(context); + return Container( + key: const ValueKey('mcp-agent-snippet'), + height: 176, + width: double.infinity, + padding: const EdgeInsets.all(14), + clipBehavior: Clip.hardEdge, + decoration: BoxDecoration( + color: palette.surfaceSoft, + borderRadius: BorderRadius.circular(6), + border: Border.all(color: palette.border), + ), + child: LayoutBuilder( + builder: (context, constraints) { + final contentHeight = (constraints.maxHeight - AppDesignTokens.space12).clamp(0.0, double.infinity); + return Scrollbar( + key: const ValueKey('mcp-snippet-horizontal-scrollbar'), + controller: _horizontalController, + thumbVisibility: true, + scrollbarOrientation: ScrollbarOrientation.bottom, + notificationPredicate: (notification) => notification.metrics.axis == Axis.horizontal, + child: Padding( + padding: const EdgeInsets.only(bottom: AppDesignTokens.space12), + child: SingleChildScrollView( + controller: _horizontalController, + scrollDirection: Axis.horizontal, + child: SizedBox( + height: contentHeight, + child: Scrollbar( + key: const ValueKey('mcp-snippet-vertical-scrollbar'), + controller: _verticalController, + thumbVisibility: true, + scrollbarOrientation: ScrollbarOrientation.right, + notificationPredicate: (notification) => notification.metrics.axis == Axis.vertical, + child: SingleChildScrollView( + controller: _verticalController, + child: IntrinsicWidth( + child: ConstrainedBox( + constraints: BoxConstraints(minWidth: constraints.maxWidth), + child: SelectableText( + widget.snippet, + style: TextStyle( + color: palette.textPrimary, + fontSize: 12, + height: 1.55, + fontFamily: 'monospace', + ), + ), + ), + ), + ), + ), + ), + ), + ), + ); + }, + ), + ); + } +} + +String _agentKey(_McpAgent agent) => switch (agent) { + _McpAgent.cursor => 'cursor', + _McpAgent.claudeCode => 'claude-code', + _McpAgent.codex => 'codex', + _McpAgent.githubCopilot => 'github-copilot', + _McpAgent.windsurf => 'windsurf', + _McpAgent.geminiCli => 'gemini-cli', + _McpAgent.cline => 'cline', + _McpAgent.openCode => 'opencode', + _McpAgent.trae => 'trae', + _McpAgent.qoder => 'qoder', + _McpAgent.workBuddy => 'workbuddy', + _McpAgent.zCode => 'zcode', + _McpAgent.deepSeekHarness => 'deepseek-harness', + _McpAgent.pi => 'pi', +}; + +String _agentSnippet(_McpAgent agent, {required String endpoint, required String apiToken}) { + return switch (agent) { + _McpAgent.cursor => _mcpServersSnippet(endpoint: endpoint, apiToken: apiToken), + _McpAgent.codex => _codexSnippet(endpoint: endpoint, apiToken: apiToken), + _McpAgent.claudeCode => + apiToken.isNotEmpty + ? 'claude mcp add --transport http gopeed $endpoint \\\n' + " --header ${_shellSingleQuote('Authorization: Bearer $apiToken')}" + : 'claude mcp add --transport http gopeed $endpoint', + _McpAgent.githubCopilot => _mcpServersSnippet( + rootKey: 'servers', + type: 'http', + endpoint: endpoint, + apiToken: apiToken, + ), + _McpAgent.windsurf => _mcpServersSnippet(urlKey: 'serverUrl', endpoint: endpoint, apiToken: apiToken), + _McpAgent.geminiCli => _mcpServersSnippet(urlKey: 'httpUrl', endpoint: endpoint, apiToken: apiToken), + _McpAgent.cline => _mcpServersSnippet( + type: 'streamableHttp', + endpoint: endpoint, + apiToken: apiToken, + extra: const {'disabled': false, 'autoApprove': []}, + ), + _McpAgent.openCode => _openCodeSnippet(endpoint: endpoint, apiToken: apiToken), + _McpAgent.trae => _mcpServersSnippet(endpoint: endpoint, apiToken: apiToken), + _McpAgent.qoder => _mcpServersSnippet(type: 'http', endpoint: endpoint, apiToken: apiToken), + _McpAgent.workBuddy => _mcpServersSnippet( + type: 'streamableHttp', + endpoint: endpoint, + apiToken: apiToken, + extra: const {'timeout': 30000}, + ), + _McpAgent.zCode => _mcpServersSnippet(type: 'http', endpoint: endpoint, apiToken: apiToken), + _McpAgent.deepSeekHarness => _deepSeekHarnessSnippet(endpoint: endpoint, apiToken: apiToken), + _McpAgent.pi => _piSnippet(endpoint: endpoint, apiToken: apiToken), + }; +} + +String _codexSnippet({required String endpoint, required String apiToken}) { + if (apiToken.isEmpty) { + return 'codex mcp add gopeed --url ${_shellSingleQuote(endpoint)}'; + } + return 'export GOPEED_API_TOKEN=${_shellSingleQuote(apiToken)}\n\n' + 'codex mcp add gopeed --url ${_shellSingleQuote(endpoint)} \\\n' + ' --bearer-token-env-var GOPEED_API_TOKEN'; +} + +String _mcpServersSnippet({ + String rootKey = 'mcpServers', + String urlKey = 'url', + String? type, + required String endpoint, + required String apiToken, + Map extra = const {}, +}) { + final server = {}; + if (type != null) { + server['type'] = type; + } + server[urlKey] = endpoint; + if (apiToken.isNotEmpty) { + server['headers'] = {'Authorization': 'Bearer $apiToken'}; + } + server.addAll(extra); + return const JsonEncoder.withIndent(' ').convert({ + rootKey: {'gopeed': server}, + }); +} + +String _openCodeSnippet({required String endpoint, required String apiToken}) { + final server = {'type': 'remote', 'url': endpoint, 'oauth': false}; + if (apiToken.isNotEmpty) { + server['headers'] = {'Authorization': 'Bearer $apiToken'}; + } + return const JsonEncoder.withIndent(' ').convert({ + r'$schema': 'https://opencode.ai/config.json', + 'mcp': { + 'servers': {'gopeed': server}, + }, + }); +} + +String _deepSeekHarnessSnippet({required String endpoint, required String apiToken}) { + final headers = apiToken.isNotEmpty + ? '\n headers:\n Authorization: ${jsonEncode('Bearer $apiToken')}' + : '\n headers: {}'; + return '- id: mcp-gopeed\n' + " name: '@deepseek-ai/dsh-mcp-client'\n" + ' config:\n' + ' serverName: gopeed\n' + ' transport: streamable-http\n' + ' url: $endpoint$headers'; +} + +String _piSnippet({required String endpoint, required String apiToken}) { + final config = _mcpServersSnippet(type: 'http', endpoint: endpoint, apiToken: apiToken); + return 'pi install npm:pi-codemcp\n\n# ~/.pi/agent/mcp.json\n$config'; +} + +String _maskedToken(String token) { + if (token.isEmpty) return ''; + if (token.length <= 8) return '••••••••'; + return '${token.substring(0, 4)}••••••••${token.substring(token.length - 4)}'; +} + +String _shellSingleQuote(String value) => "'${value.replaceAll("'", "'\"'\"'")}'"; diff --git a/ui/flutter/lib/features/settings/presentation/widgets/settings_item.dart b/ui/flutter/lib/features/settings/presentation/widgets/settings_item.dart index e1d2de81..1c2abf5e 100644 --- a/ui/flutter/lib/features/settings/presentation/widgets/settings_item.dart +++ b/ui/flutter/lib/features/settings/presentation/widgets/settings_item.dart @@ -5,10 +5,19 @@ import '../../../../shared/theme/app_design_tokens.dart'; import '../../../../shared/theme/app_palette.dart'; class SettingsItem extends StatelessWidget { - const SettingsItem({super.key, required this.title, required this.child, this.subtitle}); + const SettingsItem({ + super.key, + required this.title, + required this.child, + this.subtitle, + this.subtitleAction, + this.titleTrailing, + }); final String title; final String? subtitle; + final Widget? subtitleAction; + final Widget? titleTrailing; final Widget child; @override @@ -29,7 +38,12 @@ class SettingsItem extends StatelessWidget { children: [ const SizedBox(width: leadingSpace), Expanded( - child: _SettingsItemLabel(title: title, subtitle: subtitle), + child: _SettingsItemLabel( + title: title, + subtitle: subtitle, + subtitleAction: subtitleAction, + titleTrailing: titleTrailing, + ), ), const SizedBox(width: columnGap), ConstrainedBox( @@ -45,7 +59,12 @@ class SettingsItem extends StatelessWidget { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - _SettingsItemLabel(title: title, subtitle: subtitle), + _SettingsItemLabel( + title: title, + subtitle: subtitle, + subtitleAction: subtitleAction, + titleTrailing: titleTrailing, + ), const SizedBox(height: 10), Align(alignment: Alignment.centerLeft, child: child), ], @@ -54,10 +73,12 @@ class SettingsItem extends StatelessWidget { } class _SettingsItemLabel extends StatelessWidget { - const _SettingsItemLabel({required this.title, this.subtitle}); + const _SettingsItemLabel({required this.title, this.subtitle, this.subtitleAction, this.titleTrailing}); final String title; final String? subtitle; + final Widget? subtitleAction; + final Widget? titleTrailing; @override Widget build(BuildContext context) { @@ -66,13 +87,30 @@ class _SettingsItemLabel extends StatelessWidget { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - title, - style: TextStyle(color: palette.textPrimary, fontSize: 13, fontWeight: FontWeight.w700), + Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Flexible( + child: Text( + title, + style: TextStyle(color: palette.textPrimary, fontSize: 13, fontWeight: FontWeight.w700), + ), + ), + if (titleTrailing != null) ...[const SizedBox(width: 5), titleTrailing!], + ], ), if (subtitleText != null && subtitleText.isNotEmpty) ...[ const SizedBox(height: 4), - Text(subtitleText, style: TextStyle(color: palette.textSecondary, fontSize: 12, height: 1.3)), + Wrap( + crossAxisAlignment: WrapCrossAlignment.center, + spacing: 6, + runSpacing: 2, + children: [ + Text(subtitleText, style: TextStyle(color: palette.textSecondary, fontSize: 12, height: 1.3)), + ?subtitleAction, + ], + ), ], ], ); diff --git a/ui/flutter/lib/features/settings/presentation/widgets/settings_list_editor.dart b/ui/flutter/lib/features/settings/presentation/widgets/settings_list_editor.dart index 53209d9f..4dfc501c 100644 --- a/ui/flutter/lib/features/settings/presentation/widgets/settings_list_editor.dart +++ b/ui/flutter/lib/features/settings/presentation/widgets/settings_list_editor.dart @@ -99,8 +99,14 @@ class SettingsListEditor extends StatelessWidget { child: shad.SecondaryButton( key: addButtonKey, onPressed: onAdd, - leading: const Icon(Icons.add, size: 17), - child: Text(addLabel ?? context.l10n.add), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.add, size: 17), + const SizedBox(width: AppDesignTokens.space8), + Text(addLabel ?? context.l10n.add), + ], + ), ), ), ], @@ -280,8 +286,16 @@ Future showGithubMirrorDialog(BuildContext context, {GithubM for (final type in GithubMirrorType.values) shad.SecondaryButton( onPressed: () => setDialogState(() => selectedType = type), - leading: selectedType == type ? const Icon(Icons.check, size: 15) : null, - child: Text(type.name), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (selectedType == type) ...[ + const Icon(Icons.check, size: 15), + const SizedBox(width: AppDesignTokens.space8), + ], + Text(type.name), + ], + ), ), ], ), diff --git a/ui/flutter/lib/features/tasks/presentation/widgets/task_drawer.dart b/ui/flutter/lib/features/tasks/presentation/widgets/task_drawer.dart index 7ace3d50..0e6f6f57 100644 --- a/ui/flutter/lib/features/tasks/presentation/widgets/task_drawer.dart +++ b/ui/flutter/lib/features/tasks/presentation/widgets/task_drawer.dart @@ -3,12 +3,12 @@ import 'dart:async'; import 'package:flutter/foundation.dart' show TargetPlatform, defaultTargetPlatform, kIsWeb; import 'package:flutter/material.dart' show Colors, Icons; import 'package:flutter/widgets.dart'; -import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart' as shad; import '../../../../shared/theme/app_design_tokens.dart'; import '../../../../shared/theme/app_palette.dart'; +import '../../../../shared/widgets/app_copy_icon_button.dart'; import '../../../../shared/widgets/app_tooltip.dart'; import '../../../../shared/widgets/detail/app_detail_surface.dart'; import '../../../../core/utils/text_wrap.dart'; @@ -81,8 +81,6 @@ class TaskDetailsView extends ConsumerStatefulWidget { class _TaskDetailsViewState extends ConsumerState { final _urlController = TextEditingController(); int _tabIndex = 0; - bool _copied = false; - bool _storagePathCopied = false; bool _editingUrl = false; bool _updatingUrl = false; @@ -97,8 +95,6 @@ class _TaskDetailsViewState extends ConsumerState { super.didUpdateWidget(oldWidget); if (widget.task.id != oldWidget.task.id) { _tabIndex = 0; - _copied = false; - _storagePathCopied = false; _editingUrl = false; _updatingUrl = false; _urlController.text = widget.task.url; @@ -171,8 +167,6 @@ class _TaskDetailsViewState extends ConsumerState { padding: EdgeInsets.all(contentPadding), child: _TaskInfoTab( task: widget.task, - copied: _copied, - storagePathCopied: _storagePathCopied, canOpenStorage: _canOpenStoragePath, editingUrl: _editingUrl, updatingUrl: _updatingUrl, @@ -189,14 +183,6 @@ class _TaskDetailsViewState extends ConsumerState { _editingUrl = false; }), onSaveUrl: () => unawaited(_saveUrl()), - onCopy: () async { - await Clipboard.setData(ClipboardData(text: widget.task.url)); - if (mounted) setState(() => _copied = true); - }, - onCopyStorage: () async { - await Clipboard.setData(ClipboardData(text: widget.task.storagePath)); - if (mounted) setState(() => _storagePathCopied = true); - }, ), ), 1 => Padding( @@ -281,14 +267,10 @@ class _DrawerTabButton extends StatelessWidget { class _TaskInfoTab extends StatelessWidget { const _TaskInfoTab({ required this.task, - required this.copied, - required this.storagePathCopied, required this.canOpenStorage, required this.editingUrl, required this.updatingUrl, required this.urlController, - required this.onCopy, - required this.onCopyStorage, required this.onOpenStorage, required this.onEditUrl, required this.onCancelEditUrl, @@ -296,14 +278,10 @@ class _TaskInfoTab extends StatelessWidget { }); final TaskRecord task; - final bool copied; - final bool storagePathCopied; final bool canOpenStorage; final bool editingUrl; final bool updatingUrl; final TextEditingController urlController; - final VoidCallback onCopy; - final VoidCallback onCopyStorage; final VoidCallback onOpenStorage; final VoidCallback? onEditUrl; final VoidCallback onCancelEditUrl; @@ -357,30 +335,23 @@ class _TaskInfoTab extends StatelessWidget { label: context.l10n.downloadLink, value: task.url, valueKey: const ValueKey('task-details-url-value'), - actionKey: const ValueKey('task-details-copy-url'), - actionLabel: copied ? context.l10n.copied : context.l10n.copy, - actionIcon: copied ? Icons.check : Icons.copy_outlined, + action: AppCopyIconButton(key: const ValueKey('task-details-copy-url'), text: task.url), secondaryActionLabel: onEditUrl == null ? null : context.l10n.edit, secondaryActionIcon: Icons.edit_outlined, onSecondaryTap: onEditUrl, - onTap: onCopy, ), _CopyableInfoBlock( label: context.l10n.storagePath, value: task.storagePath, valueKey: const ValueKey('task-details-storage-value'), - actionKey: ValueKey(canOpenStorage ? 'task-details-open-storage' : 'task-details-copy-storage'), - actionLabel: canOpenStorage - ? context.l10n.open - : storagePathCopied - ? context.l10n.copied - : context.l10n.copy, - actionIcon: canOpenStorage - ? Icons.folder_open_outlined - : storagePathCopied - ? Icons.check - : Icons.copy_outlined, - onTap: canOpenStorage ? onOpenStorage : onCopyStorage, + action: canOpenStorage + ? _TaskDetailIconButton( + key: const ValueKey('task-details-open-storage'), + label: context.l10n.open, + icon: const Icon(Icons.folder_open_outlined, size: 16), + onPressed: onOpenStorage, + ) + : AppCopyIconButton(key: const ValueKey('task-details-copy-storage'), text: task.storagePath), ), ], ); @@ -428,10 +399,7 @@ class _CopyableInfoBlock extends StatelessWidget { required this.label, required this.value, required this.valueKey, - required this.actionKey, - required this.actionLabel, - required this.actionIcon, - required this.onTap, + required this.action, this.secondaryActionLabel, this.secondaryActionIcon, this.onSecondaryTap, @@ -440,10 +408,7 @@ class _CopyableInfoBlock extends StatelessWidget { final String label; final String value; final Key valueKey; - final Key actionKey; - final String actionLabel; - final IconData actionIcon; - final VoidCallback onTap; + final Widget action; final String? secondaryActionLabel; final IconData? secondaryActionIcon; final VoidCallback? onSecondaryTap; @@ -481,12 +446,7 @@ class _CopyableInfoBlock extends StatelessWidget { ), const SizedBox(width: 4), ], - _TaskDetailIconButton( - key: actionKey, - label: actionLabel, - icon: Icon(actionIcon, size: 16), - onPressed: onTap, - ), + action, ], ), ), diff --git a/ui/flutter/lib/l10n/app_ar.arb b/ui/flutter/lib/l10n/app_ar.arb index 7d34e2dd..3569cc64 100644 --- a/ui/flutter/lib/l10n/app_ar.arb +++ b/ui/flutter/lib/l10n/app_ar.arb @@ -432,5 +432,14 @@ "booleanValueHint": "true / false", "androidForegroundServiceChannel": "خدمة Gopeed في الخلفية", "androidForegroundNotificationTitle": "Gopeed قيد التشغيل", - "androidForegroundNotificationText": "يمكن متابعة التنزيلات في الخلفية" + "androidForegroundNotificationText": "يمكن متابعة التنزيلات في الخلفية", + "mcpDescription": "تعاون مع وكلاء الذكاء الاصطناعي واستكشف إمكانات بلا حدود", + "connectAgent": "دليل الإعداد", + "connectMcpAgent": "ربط وكيل MCP", + "mcpAgentDialogDescription": "اختر الوكيل الذي تستخدمه، ثم انسخ إعدادات الاتصال الخاصة به.", + "mcpNotRunningHint": "خدمة MCP غير مفعّلة. يرجى تفعيلها", + "webApiConfigReadOnlyHelp": "لا يدعم إصدار الويب تعديل إعدادات API مباشرة. عدّل خيارات بدء تشغيل الخادم أو ملف الإعدادات، ثم أعد تشغيل الخدمة لتطبيق التغييرات.", + "webMcpApiTokenHint": "إذا قمت بتكوين رمز API، فيجب تمرير معلومات المصادقة عند ربط الوكيل", + "useMcpApiTokenTemplate": "التبديل إلى إعدادات المصادقة", + "mcpRequiresTcp": "تتوفر إرشادات ربط الوكيل عندما يكون بروتوكول API هو TCP." } diff --git a/ui/flutter/lib/l10n/app_ca.arb b/ui/flutter/lib/l10n/app_ca.arb index 97f75fb5..7743b429 100644 --- a/ui/flutter/lib/l10n/app_ca.arb +++ b/ui/flutter/lib/l10n/app_ca.arb @@ -432,5 +432,14 @@ "booleanValueHint": "true / false", "androidForegroundServiceChannel": "Servei en segon pla de Gopeed", "androidForegroundNotificationTitle": "Gopeed s'està executant", - "androidForegroundNotificationText": "Les baixades poden continuar en segon pla" + "androidForegroundNotificationText": "Les baixades poden continuar en segon pla", + "mcpDescription": "Col·labora amb agents d’IA i explora possibilitats infinites", + "connectAgent": "Guia de connexió", + "connectMcpAgent": "Connecta un agent MCP", + "mcpAgentDialogDescription": "Tria l’agent que utilitzes i copia’n la configuració de connexió.", + "mcpNotRunningHint": "El servei MCP no està activat. Activa’l", + "webApiConfigReadOnlyHelp": "La versió web no permet modificar la configuració de l’API en línia. Actualitza les opcions d’inici del servidor o el fitxer de configuració i reinicia el servei perquè els canvis tinguin efecte.", + "webMcpApiTokenHint": "Si heu configurat un token d’API, cal proporcionar la informació d’autenticació en connectar l’agent", + "useMcpApiTokenTemplate": "Canvia a la configuració autenticada", + "mcpRequiresTcp": "Les instruccions de connexió d’agents estan disponibles quan el protocol de l’API és TCP." } diff --git a/ui/flutter/lib/l10n/app_de.arb b/ui/flutter/lib/l10n/app_de.arb index 9331a2ec..2a347170 100644 --- a/ui/flutter/lib/l10n/app_de.arb +++ b/ui/flutter/lib/l10n/app_de.arb @@ -432,5 +432,14 @@ "booleanValueHint": "true / false", "androidForegroundServiceChannel": "Gopeed-Hintergrunddienst", "androidForegroundNotificationTitle": "Gopeed wird ausgeführt", - "androidForegroundNotificationText": "Downloads können im Hintergrund fortgesetzt werden" + "androidForegroundNotificationText": "Downloads können im Hintergrund fortgesetzt werden", + "mcpDescription": "Mit KI-Agenten zusammenarbeiten und grenzenlose Möglichkeiten entdecken", + "connectAgent": "Einrichtungsleitfaden", + "connectMcpAgent": "MCP-Agent verbinden", + "mcpAgentDialogDescription": "Wähle deinen Agenten aus und kopiere die zugehörige Verbindungskonfiguration.", + "mcpNotRunningHint": "Der MCP-Dienst ist nicht aktiviert. Bitte aktiviere ihn", + "webApiConfigReadOnlyHelp": "In der Web-Version können API-Einstellungen nicht online geändert werden. Ändere die Server-Startoptionen oder die Konfigurationsdatei und starte den Dienst neu, damit die Änderungen wirksam werden.", + "webMcpApiTokenHint": "Wenn du ein API-Token konfiguriert hast, müssen beim Verbinden des Agents Authentifizierungsinformationen übermittelt werden", + "useMcpApiTokenTemplate": "Zur authentifizierten Konfiguration wechseln", + "mcpRequiresTcp": "Anweisungen zur Agent-Verbindung sind verfügbar, wenn das API-Protokoll TCP ist." } diff --git a/ui/flutter/lib/l10n/app_en.arb b/ui/flutter/lib/l10n/app_en.arb index 8b22f865..578e762d 100644 --- a/ui/flutter/lib/l10n/app_en.arb +++ b/ui/flutter/lib/l10n/app_en.arb @@ -616,5 +616,14 @@ }, "androidForegroundServiceChannel": "Gopeed Background Service", "androidForegroundNotificationTitle": "Gopeed is running", - "androidForegroundNotificationText": "Downloads can continue in the background" + "androidForegroundNotificationText": "Downloads can continue in the background", + "mcpDescription": "Collaborate with AI agents and explore limitless possibilities", + "connectAgent": "Setup guide", + "connectMcpAgent": "Connect MCP agent", + "mcpAgentDialogDescription": "Choose the agent you use, then copy its connection configuration.", + "mcpNotRunningHint": "The MCP service is not enabled. Please enable it", + "webMcpApiTokenHint": "If you configured an API token, authentication information is required when connecting the agent", + "useMcpApiTokenTemplate": "Switch to authenticated configuration", + "webApiConfigReadOnlyHelp": "The web version cannot modify API settings online. Update the server startup options or configuration file, then restart the service for changes to take effect.", + "mcpRequiresTcp": "Agent connection instructions are available when the API protocol is TCP." } diff --git a/ui/flutter/lib/l10n/app_es.arb b/ui/flutter/lib/l10n/app_es.arb index 27e539b3..6aef1468 100644 --- a/ui/flutter/lib/l10n/app_es.arb +++ b/ui/flutter/lib/l10n/app_es.arb @@ -432,5 +432,14 @@ "booleanValueHint": "true / false", "androidForegroundServiceChannel": "Servicio en segundo plano de Gopeed", "androidForegroundNotificationTitle": "Gopeed se está ejecutando", - "androidForegroundNotificationText": "Las descargas pueden continuar en segundo plano" + "androidForegroundNotificationText": "Las descargas pueden continuar en segundo plano", + "mcpDescription": "Colabora con agentes de IA y explora posibilidades ilimitadas", + "connectAgent": "Guía de conexión", + "connectMcpAgent": "Conectar agente MCP", + "mcpAgentDialogDescription": "Elige el agente que utilizas y copia su configuración de conexión.", + "mcpNotRunningHint": "El servicio MCP no está habilitado. Habilítalo", + "webApiConfigReadOnlyHelp": "La versión web no permite modificar la configuración de la API en línea. Actualiza las opciones de inicio del servidor o el archivo de configuración y reinicia el servicio para aplicar los cambios.", + "webMcpApiTokenHint": "Si has configurado un token de API, debes proporcionar la información de autenticación al conectar el agente", + "useMcpApiTokenTemplate": "Cambiar a la configuración autenticada", + "mcpRequiresTcp": "Las instrucciones de conexión de agentes están disponibles cuando el protocolo de la API es TCP." } diff --git a/ui/flutter/lib/l10n/app_fa.arb b/ui/flutter/lib/l10n/app_fa.arb index 86eee320..7398b5c3 100644 --- a/ui/flutter/lib/l10n/app_fa.arb +++ b/ui/flutter/lib/l10n/app_fa.arb @@ -432,5 +432,14 @@ "booleanValueHint": "true / false", "androidForegroundServiceChannel": "سرویس پس‌زمینه Gopeed", "androidForegroundNotificationTitle": "Gopeed در حال اجرا است", - "androidForegroundNotificationText": "دانلودها می‌توانند در پس‌زمینه ادامه پیدا کنند" + "androidForegroundNotificationText": "دانلودها می‌توانند در پس‌زمینه ادامه پیدا کنند", + "mcpDescription": "با عامل‌های هوش مصنوعی همکاری کنید و امکان‌های نامحدود را کشف کنید", + "connectAgent": "راهنمای اتصال", + "connectMcpAgent": "اتصال عامل MCP", + "mcpAgentDialogDescription": "عامل مورد استفاده را انتخاب کنید و پیکربندی اتصال آن را کپی کنید.", + "mcpNotRunningHint": "سرویس MCP فعال نیست. لطفاً آن را فعال کنید", + "webApiConfigReadOnlyHelp": "نسخه وب از ویرایش آنلاین تنظیمات API پشتیبانی نمی‌کند. گزینه‌های راه‌اندازی سرور یا فایل پیکربندی را تغییر دهید و برای اعمال تغییرات سرویس را دوباره راه‌اندازی کنید.", + "webMcpApiTokenHint": "اگر توکن API را پیکربندی کرده‌اید، هنگام اتصال عامل باید اطلاعات احراز هویت را ارسال کنید", + "useMcpApiTokenTemplate": "تغییر به پیکربندی احراز هویت", + "mcpRequiresTcp": "راهنمای اتصال عامل زمانی در دسترس است که پروتکل API روی TCP باشد." } diff --git a/ui/flutter/lib/l10n/app_fr.arb b/ui/flutter/lib/l10n/app_fr.arb index 517a929d..43404fdc 100644 --- a/ui/flutter/lib/l10n/app_fr.arb +++ b/ui/flutter/lib/l10n/app_fr.arb @@ -432,5 +432,14 @@ "booleanValueHint": "true / false", "androidForegroundServiceChannel": "Service d’arrière-plan Gopeed", "androidForegroundNotificationTitle": "Gopeed est en cours d’exécution", - "androidForegroundNotificationText": "Les téléchargements peuvent continuer en arrière-plan" + "androidForegroundNotificationText": "Les téléchargements peuvent continuer en arrière-plan", + "mcpDescription": "Collaborez avec des agents IA et explorez des possibilités infinies", + "connectAgent": "Guide de connexion", + "connectMcpAgent": "Connecter un agent MCP", + "mcpAgentDialogDescription": "Choisissez votre agent, puis copiez sa configuration de connexion.", + "mcpNotRunningHint": "Le service MCP n’est pas activé. Activez-le", + "webApiConfigReadOnlyHelp": "La version web ne permet pas de modifier les paramètres de l’API en ligne. Modifiez les options de démarrage du serveur ou le fichier de configuration, puis redémarrez le service pour appliquer les changements.", + "webMcpApiTokenHint": "Si vous avez configuré un jeton API, les informations d’authentification doivent être transmises lors de la connexion de l’agent", + "useMcpApiTokenTemplate": "Passer à la configuration authentifiée", + "mcpRequiresTcp": "Les instructions de connexion des agents sont disponibles lorsque le protocole de l’API est TCP." } diff --git a/ui/flutter/lib/l10n/app_hu.arb b/ui/flutter/lib/l10n/app_hu.arb index 26c060f2..cd5d2b2d 100644 --- a/ui/flutter/lib/l10n/app_hu.arb +++ b/ui/flutter/lib/l10n/app_hu.arb @@ -432,5 +432,14 @@ "booleanValueHint": "true / false", "androidForegroundServiceChannel": "Gopeed háttérszolgáltatás", "androidForegroundNotificationTitle": "A Gopeed fut", - "androidForegroundNotificationText": "A letöltések a háttérben is folytatódhatnak" + "androidForegroundNotificationText": "A letöltések a háttérben is folytatódhatnak", + "mcpDescription": "Működj együtt AI-ügynökökkel, és fedezz fel korlátlan lehetőségeket", + "connectAgent": "Csatlakoztatási útmutató", + "connectMcpAgent": "MCP-ügynök csatlakoztatása", + "mcpAgentDialogDescription": "Válaszd ki a használt ügynököt, majd másold ki a kapcsolódási beállításait.", + "mcpNotRunningHint": "Az MCP szolgáltatás nincs engedélyezve. Engedélyezd", + "webApiConfigReadOnlyHelp": "A webes verzióban az API-beállítások nem módosíthatók online. Módosítsd a kiszolgáló indítási beállításait vagy a konfigurációs fájlt, majd indítsd újra a szolgáltatást.", + "webMcpApiTokenHint": "Ha API-tokent állítottál be, az ügynök csatlakoztatásakor meg kell adni a hitelesítési adatokat", + "useMcpApiTokenTemplate": "Váltás hitelesített konfigurációra", + "mcpRequiresTcp": "Az ügynökcsatlakoztatási útmutató akkor érhető el, ha az API protokollja TCP." } diff --git a/ui/flutter/lib/l10n/app_id.arb b/ui/flutter/lib/l10n/app_id.arb index 81cd69d5..1f87a970 100644 --- a/ui/flutter/lib/l10n/app_id.arb +++ b/ui/flutter/lib/l10n/app_id.arb @@ -432,5 +432,14 @@ "booleanValueHint": "true / false", "androidForegroundServiceChannel": "Layanan latar belakang Gopeed", "androidForegroundNotificationTitle": "Gopeed sedang berjalan", - "androidForegroundNotificationText": "Unduhan dapat dilanjutkan di latar belakang" + "androidForegroundNotificationText": "Unduhan dapat dilanjutkan di latar belakang", + "mcpDescription": "Berkolaborasi dengan agen AI dan jelajahi kemungkinan tanpa batas", + "connectAgent": "Panduan koneksi", + "connectMcpAgent": "Hubungkan agen MCP", + "mcpAgentDialogDescription": "Pilih agen yang Anda gunakan, lalu salin konfigurasi koneksinya.", + "mcpNotRunningHint": "Layanan MCP belum diaktifkan. Silakan aktifkan", + "webApiConfigReadOnlyHelp": "Versi web tidak dapat mengubah pengaturan API secara online. Perbarui opsi startup server atau file konfigurasi, lalu mulai ulang layanan agar perubahan diterapkan.", + "webMcpApiTokenHint": "Jika Anda telah mengatur token API, informasi autentikasi harus disertakan saat menghubungkan agen", + "useMcpApiTokenTemplate": "Beralih ke konfigurasi terautentikasi", + "mcpRequiresTcp": "Petunjuk koneksi agen tersedia saat protokol API menggunakan TCP." } diff --git a/ui/flutter/lib/l10n/app_it.arb b/ui/flutter/lib/l10n/app_it.arb index 435d4fea..7d298adb 100644 --- a/ui/flutter/lib/l10n/app_it.arb +++ b/ui/flutter/lib/l10n/app_it.arb @@ -432,5 +432,14 @@ "booleanValueHint": "true / false", "androidForegroundServiceChannel": "Servizio in background Gopeed", "androidForegroundNotificationTitle": "Gopeed è in esecuzione", - "androidForegroundNotificationText": "I download possono continuare in background" + "androidForegroundNotificationText": "I download possono continuare in background", + "mcpDescription": "Collabora con gli agenti IA ed esplora possibilità infinite", + "connectAgent": "Guida alla connessione", + "connectMcpAgent": "Connetti agente MCP", + "mcpAgentDialogDescription": "Scegli l’agente che utilizzi e copia la relativa configurazione di connessione.", + "mcpNotRunningHint": "Il servizio MCP non è abilitato. Abilitalo", + "webApiConfigReadOnlyHelp": "La versione web non consente di modificare online le impostazioni API. Aggiorna le opzioni di avvio del server o il file di configurazione, quindi riavvia il servizio per applicare le modifiche.", + "webMcpApiTokenHint": "Se hai configurato un token API, devi fornire le informazioni di autenticazione quando colleghi l’agente", + "useMcpApiTokenTemplate": "Passa alla configurazione autenticata", + "mcpRequiresTcp": "Le istruzioni per connettere gli agenti sono disponibili quando il protocollo API è TCP." } diff --git a/ui/flutter/lib/l10n/app_ja.arb b/ui/flutter/lib/l10n/app_ja.arb index d06b8a52..0bdbc743 100644 --- a/ui/flutter/lib/l10n/app_ja.arb +++ b/ui/flutter/lib/l10n/app_ja.arb @@ -432,5 +432,14 @@ "booleanValueHint": "true / false", "androidForegroundServiceChannel": "Gopeed バックグラウンドサービス", "androidForegroundNotificationTitle": "Gopeed は実行中です", - "androidForegroundNotificationText": "バックグラウンドでダウンロードを続行できます" + "androidForegroundNotificationText": "バックグラウンドでダウンロードを続行できます", + "mcpDescription": "AI エージェントと連携し、無限の可能性を探る", + "connectAgent": "接続ガイド", + "connectMcpAgent": "MCP エージェントを接続", + "mcpAgentDialogDescription": "使用するエージェントを選択し、接続設定をコピーしてください。", + "mcpNotRunningHint": "MCP サービスが有効になっていません。有効にしてください", + "webApiConfigReadOnlyHelp": "Web 版では API 設定をオンラインで変更できません。サーバーの起動オプションまたは設定ファイルを変更し、サービスを再起動して反映してください。", + "webMcpApiTokenHint": "API トークンを設定している場合、エージェント接続時に認証情報を渡す必要があります", + "useMcpApiTokenTemplate": "認証付き設定に切り替える", + "mcpRequiresTcp": "API プロトコルが TCP の場合にエージェント接続手順を利用できます。" } diff --git a/ui/flutter/lib/l10n/app_ko.arb b/ui/flutter/lib/l10n/app_ko.arb index 020ba4e1..db8caf2d 100644 --- a/ui/flutter/lib/l10n/app_ko.arb +++ b/ui/flutter/lib/l10n/app_ko.arb @@ -432,5 +432,14 @@ "booleanValueHint": "true / false", "androidForegroundServiceChannel": "Gopeed 백그라운드 서비스", "androidForegroundNotificationTitle": "Gopeed가 실행 중입니다", - "androidForegroundNotificationText": "백그라운드에서 다운로드를 계속할 수 있습니다" + "androidForegroundNotificationText": "백그라운드에서 다운로드를 계속할 수 있습니다", + "mcpDescription": "AI 에이전트와 협업해 무한한 가능성 탐색", + "connectAgent": "연결 가이드", + "connectMcpAgent": "MCP 에이전트 연결", + "mcpAgentDialogDescription": "사용할 에이전트를 선택한 다음 연결 구성을 복사하세요.", + "mcpNotRunningHint": "MCP 서비스가 활성화되지 않았습니다. 활성화하세요", + "webApiConfigReadOnlyHelp": "웹 버전에서는 API 설정을 온라인으로 수정할 수 없습니다. 서버 시작 옵션이나 구성 파일을 변경한 후 서비스를 다시 시작해야 적용됩니다.", + "webMcpApiTokenHint": "API 토큰을 설정한 경우 에이전트 연결 시 인증 정보를 전달해야 합니다", + "useMcpApiTokenTemplate": "인증 구성으로 전환", + "mcpRequiresTcp": "API 프로토콜이 TCP일 때 에이전트 연결 안내를 사용할 수 있습니다." } diff --git a/ui/flutter/lib/l10n/app_pl.arb b/ui/flutter/lib/l10n/app_pl.arb index 21890c60..c255d506 100644 --- a/ui/flutter/lib/l10n/app_pl.arb +++ b/ui/flutter/lib/l10n/app_pl.arb @@ -432,5 +432,14 @@ "booleanValueHint": "true / false", "androidForegroundServiceChannel": "Usługa Gopeed w tle", "androidForegroundNotificationTitle": "Gopeed jest uruchomiony", - "androidForegroundNotificationText": "Pobieranie może być kontynuowane w tle" + "androidForegroundNotificationText": "Pobieranie może być kontynuowane w tle", + "mcpDescription": "Współpracuj z agentami AI i odkrywaj nieograniczone możliwości", + "connectAgent": "Przewodnik konfiguracji", + "connectMcpAgent": "Połącz agenta MCP", + "mcpAgentDialogDescription": "Wybierz używanego agenta, a następnie skopiuj jego konfigurację połączenia.", + "mcpNotRunningHint": "Usługa MCP nie jest włączona. Włącz ją", + "webApiConfigReadOnlyHelp": "Wersja webowa nie obsługuje edycji ustawień API online. Zmień opcje uruchamiania serwera lub plik konfiguracyjny, a następnie uruchom usługę ponownie.", + "webMcpApiTokenHint": "Jeśli skonfigurowano token API, podczas łączenia agenta należy przekazać dane uwierzytelniające", + "useMcpApiTokenTemplate": "Przełącz na konfigurację z uwierzytelnianiem", + "mcpRequiresTcp": "Instrukcje łączenia agentów są dostępne, gdy protokół API to TCP." } diff --git a/ui/flutter/lib/l10n/app_pt.arb b/ui/flutter/lib/l10n/app_pt.arb index 7ef0b81a..8447f830 100644 --- a/ui/flutter/lib/l10n/app_pt.arb +++ b/ui/flutter/lib/l10n/app_pt.arb @@ -432,5 +432,14 @@ "booleanValueHint": "true / false", "androidForegroundServiceChannel": "Serviço em segundo plano do Gopeed", "androidForegroundNotificationTitle": "O Gopeed está em execução", - "androidForegroundNotificationText": "Os downloads podem continuar em segundo plano" + "androidForegroundNotificationText": "Os downloads podem continuar em segundo plano", + "mcpDescription": "Colabore com agentes de IA e explore possibilidades ilimitadas", + "connectAgent": "Guia de conexão", + "connectMcpAgent": "Conectar agente MCP", + "mcpAgentDialogDescription": "Escolha o agente que utiliza e copie a configuração de conexão correspondente.", + "mcpNotRunningHint": "O serviço MCP não está ativado. Ative-o", + "webApiConfigReadOnlyHelp": "A versão web não permite alterar as definições da API online. Atualize as opções de arranque do servidor ou o ficheiro de configuração e reinicie o serviço para aplicar as alterações.", + "webMcpApiTokenHint": "Se configurou um token de API, é necessário fornecer as informações de autenticação ao ligar o agente", + "useMcpApiTokenTemplate": "Mudar para a configuração autenticada", + "mcpRequiresTcp": "As instruções de conexão de agentes estão disponíveis quando o protocolo da API é TCP." } diff --git a/ui/flutter/lib/l10n/app_ru.arb b/ui/flutter/lib/l10n/app_ru.arb index d9cdb3df..1cc5fe24 100644 --- a/ui/flutter/lib/l10n/app_ru.arb +++ b/ui/flutter/lib/l10n/app_ru.arb @@ -432,5 +432,14 @@ "booleanValueHint": "true / false", "androidForegroundServiceChannel": "Фоновая служба Gopeed", "androidForegroundNotificationTitle": "Gopeed работает", - "androidForegroundNotificationText": "Загрузки могут продолжаться в фоновом режиме" + "androidForegroundNotificationText": "Загрузки могут продолжаться в фоновом режиме", + "mcpDescription": "Сотрудничайте с ИИ-агентами и открывайте безграничные возможности", + "connectAgent": "Руководство по подключению", + "connectMcpAgent": "Подключить MCP-агента", + "mcpAgentDialogDescription": "Выберите используемого агента и скопируйте его конфигурацию подключения.", + "mcpNotRunningHint": "Служба MCP не включена. Включите её", + "webApiConfigReadOnlyHelp": "В веб-версии нельзя изменить настройки API онлайн. Измените параметры запуска сервера или файл конфигурации, затем перезапустите службу.", + "webMcpApiTokenHint": "Если настроен API-токен, при подключении агента необходимо передать данные аутентификации", + "useMcpApiTokenTemplate": "Переключиться на конфигурацию с аутентификацией", + "mcpRequiresTcp": "Инструкции по подключению агентов доступны, когда протокол API — TCP." } diff --git a/ui/flutter/lib/l10n/app_ta.arb b/ui/flutter/lib/l10n/app_ta.arb index d6be4092..82870d83 100644 --- a/ui/flutter/lib/l10n/app_ta.arb +++ b/ui/flutter/lib/l10n/app_ta.arb @@ -432,5 +432,14 @@ "booleanValueHint": "true / false", "androidForegroundServiceChannel": "Gopeed பின்னணி சேவை", "androidForegroundNotificationTitle": "Gopeed இயங்குகிறது", - "androidForegroundNotificationText": "பதிவிறக்கங்கள் பின்னணியில் தொடரலாம்" + "androidForegroundNotificationText": "பதிவிறக்கங்கள் பின்னணியில் தொடரலாம்", + "mcpDescription": "AI முகவர்களுடன் இணைந்து எல்லையற்ற வாய்ப்புகளை ஆராயுங்கள்", + "connectAgent": "இணைப்பு வழிகாட்டி", + "connectMcpAgent": "MCP முகவரை இணை", + "mcpAgentDialogDescription": "நீங்கள் பயன்படுத்தும் முகவரைத் தேர்ந்தெடுத்து அதன் இணைப்பு உள்ளமைவை நகலெடுக்கவும்.", + "mcpNotRunningHint": "MCP சேவை இயக்கப்படவில்லை. அதை இயக்கவும்", + "webApiConfigReadOnlyHelp": "இணையப் பதிப்பில் API அமைப்புகளை ஆன்லைனில் மாற்ற முடியாது. சேவையக தொடக்க விருப்பங்கள் அல்லது உள்ளமைவு கோப்பை மாற்றி, மாற்றங்கள் செயல்பட சேவையை மறுதொடக்கம் செய்யவும்.", + "webMcpApiTokenHint": "API டோக்கனை அமைத்திருந்தால், முகவரை இணைக்கும்போது அங்கீகாரத் தகவலை அனுப்ப வேண்டும்", + "useMcpApiTokenTemplate": "அங்கீகார உள்ளமைவுக்கு மாறு", + "mcpRequiresTcp": "API நெறிமுறை TCP ஆக இருக்கும்போது முகவர் இணைப்பு வழிமுறைகள் கிடைக்கும்." } diff --git a/ui/flutter/lib/l10n/app_tr.arb b/ui/flutter/lib/l10n/app_tr.arb index 163a6554..ca979cae 100644 --- a/ui/flutter/lib/l10n/app_tr.arb +++ b/ui/flutter/lib/l10n/app_tr.arb @@ -432,5 +432,14 @@ "booleanValueHint": "true / false", "androidForegroundServiceChannel": "Gopeed arka plan hizmeti", "androidForegroundNotificationTitle": "Gopeed çalışıyor", - "androidForegroundNotificationText": "İndirmeler arka planda devam edebilir" + "androidForegroundNotificationText": "İndirmeler arka planda devam edebilir", + "mcpDescription": "Yapay zekâ ajanlarıyla iş birliği yapın, sınırsız olanakları keşfedin", + "connectAgent": "Bağlantı rehberi", + "connectMcpAgent": "MCP ajanı bağla", + "mcpAgentDialogDescription": "Kullandığınız ajanı seçin ve bağlantı yapılandırmasını kopyalayın.", + "mcpNotRunningHint": "MCP hizmeti etkin değil. Lütfen etkinleştirin", + "webApiConfigReadOnlyHelp": "Web sürümü API ayarlarının çevrimiçi değiştirilmesini desteklemez. Sunucu başlatma seçeneklerini veya yapılandırma dosyasını güncelleyin ve değişikliklerin etkili olması için hizmeti yeniden başlatın.", + "webMcpApiTokenHint": "Bir API anahtarı yapılandırdıysanız, ajan bağlanırken kimlik doğrulama bilgileri iletilmelidir", + "useMcpApiTokenTemplate": "Kimlik doğrulamalı yapılandırmaya geç", + "mcpRequiresTcp": "Ajan bağlantı yönergeleri API protokolü TCP olduğunda kullanılabilir." } diff --git a/ui/flutter/lib/l10n/app_uk.arb b/ui/flutter/lib/l10n/app_uk.arb index 18291b65..ceae4052 100644 --- a/ui/flutter/lib/l10n/app_uk.arb +++ b/ui/flutter/lib/l10n/app_uk.arb @@ -432,5 +432,14 @@ "booleanValueHint": "true / false", "androidForegroundServiceChannel": "Фонова служба Gopeed", "androidForegroundNotificationTitle": "Gopeed працює", - "androidForegroundNotificationText": "Завантаження можуть продовжуватися у фоновому режимі" + "androidForegroundNotificationText": "Завантаження можуть продовжуватися у фоновому режимі", + "mcpDescription": "Співпрацюйте з ШІ-агентами та відкривайте безмежні можливості", + "connectAgent": "Посібник із підключення", + "connectMcpAgent": "Підключити MCP-агента", + "mcpAgentDialogDescription": "Виберіть агента, якого використовуєте, і скопіюйте його конфігурацію підключення.", + "mcpNotRunningHint": "Службу MCP не ввімкнено. Увімкніть її", + "webApiConfigReadOnlyHelp": "У вебверсії не можна змінювати налаштування API онлайн. Змініть параметри запуску сервера або файл конфігурації, а потім перезапустіть службу.", + "webMcpApiTokenHint": "Якщо налаштовано API-токен, під час підключення агента потрібно передати дані автентифікації", + "useMcpApiTokenTemplate": "Перейти до конфігурації з автентифікацією", + "mcpRequiresTcp": "Інструкції з підключення агентів доступні, коли протокол API — TCP." } diff --git a/ui/flutter/lib/l10n/app_vi.arb b/ui/flutter/lib/l10n/app_vi.arb index 57fa9d44..6ed35bd1 100644 --- a/ui/flutter/lib/l10n/app_vi.arb +++ b/ui/flutter/lib/l10n/app_vi.arb @@ -432,5 +432,14 @@ "booleanValueHint": "true / false", "androidForegroundServiceChannel": "Dịch vụ nền Gopeed", "androidForegroundNotificationTitle": "Gopeed đang chạy", - "androidForegroundNotificationText": "Các lượt tải xuống có thể tiếp tục trong nền" + "androidForegroundNotificationText": "Các lượt tải xuống có thể tiếp tục trong nền", + "mcpDescription": "Cộng tác cùng AI Agent và khám phá khả năng vô hạn", + "connectAgent": "Hướng dẫn kết nối", + "connectMcpAgent": "Kết nối tác nhân MCP", + "mcpAgentDialogDescription": "Chọn tác nhân bạn sử dụng rồi sao chép cấu hình kết nối tương ứng.", + "mcpNotRunningHint": "Dịch vụ MCP chưa được bật. Hãy bật dịch vụ", + "webApiConfigReadOnlyHelp": "Phiên bản web không hỗ trợ sửa cấu hình API trực tuyến. Hãy cập nhật tùy chọn khởi động máy chủ hoặc tệp cấu hình rồi khởi động lại dịch vụ để áp dụng thay đổi.", + "webMcpApiTokenHint": "Nếu đã cấu hình API Token, bạn cần truyền thông tin xác thực khi kết nối tác nhân", + "useMcpApiTokenTemplate": "Chuyển sang cấu hình có xác thực", + "mcpRequiresTcp": "Hướng dẫn kết nối tác nhân khả dụng khi giao thức API là TCP." } diff --git a/ui/flutter/lib/l10n/app_zh.arb b/ui/flutter/lib/l10n/app_zh.arb index 164ae6d5..96e8a184 100644 --- a/ui/flutter/lib/l10n/app_zh.arb +++ b/ui/flutter/lib/l10n/app_zh.arb @@ -432,5 +432,14 @@ "booleanValueHint": "true / false", "androidForegroundServiceChannel": "Gopeed 后台服务", "androidForegroundNotificationTitle": "Gopeed 正在运行", - "androidForegroundNotificationText": "下载可在后台继续" + "androidForegroundNotificationText": "下载可在后台继续", + "mcpDescription": "与 AI Agent 的协同,探索无限可能", + "connectAgent": "接入指南", + "connectMcpAgent": "接入 MCP Agent", + "mcpAgentDialogDescription": "选择你使用的 Agent,然后复制对应的接入配置。", + "mcpNotRunningHint": "尚未启用 MCP 服务,请先开启", + "webMcpApiTokenHint": "若已配置 API Token,接入 Agent 时需要传递认证信息", + "useMcpApiTokenTemplate": "切换为认证配置", + "webApiConfigReadOnlyHelp": "Web 版本不支持在线修改 API 配置。请修改服务端启动参数或配置文件,并重启服务后使配置生效。", + "mcpRequiresTcp": "API 通讯协议为 TCP 时才能使用 Agent 接入指引。" } diff --git a/ui/flutter/lib/l10n/app_zh_TW.arb b/ui/flutter/lib/l10n/app_zh_TW.arb index f271c659..c75a84c7 100644 --- a/ui/flutter/lib/l10n/app_zh_TW.arb +++ b/ui/flutter/lib/l10n/app_zh_TW.arb @@ -432,5 +432,14 @@ "booleanValueHint": "true / false", "androidForegroundServiceChannel": "Gopeed 背景服務", "androidForegroundNotificationTitle": "Gopeed 正在執行", - "androidForegroundNotificationText": "下載可在背景繼續" + "androidForegroundNotificationText": "下載可在背景繼續", + "mcpDescription": "與 AI Agent 的協同,探索無限可能", + "connectAgent": "連接指南", + "connectMcpAgent": "接入 MCP Agent", + "mcpAgentDialogDescription": "選擇你使用的 Agent,然後複製對應的接入設定。", + "mcpNotRunningHint": "尚未啟用 MCP 服務,請先開啟", + "webApiConfigReadOnlyHelp": "Web 版本不支援線上修改 API 設定。請修改伺服器啟動參數或設定檔,並重新啟動服務後使設定生效。", + "webMcpApiTokenHint": "若已設定 API Token,接入 Agent 時需要傳遞認證資訊", + "useMcpApiTokenTemplate": "切換為認證設定", + "mcpRequiresTcp": "API 通訊協定為 TCP 時才能使用 Agent 接入指引。" } diff --git a/ui/flutter/lib/shared/widgets/app_copy_icon_button.dart b/ui/flutter/lib/shared/widgets/app_copy_icon_button.dart new file mode 100644 index 00000000..40fbfa63 --- /dev/null +++ b/ui/flutter/lib/shared/widgets/app_copy_icon_button.dart @@ -0,0 +1,59 @@ +import 'package:flutter/material.dart' show Icons; +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart' as shad; + +import '../../l10n/l10n.dart'; +import 'app_tooltip.dart'; + +class AppCopyIconButton extends StatefulWidget { + const AppCopyIconButton({ + super.key, + required this.text, + this.enabled = true, + this.dimension = 28, + this.iconSize = 16, + }); + + final String text; + final bool enabled; + final double dimension; + final double iconSize; + + @override + State createState() => _AppCopyIconButtonState(); +} + +class _AppCopyIconButtonState extends State { + bool _copied = false; + + @override + void didUpdateWidget(covariant AppCopyIconButton oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.text != oldWidget.text || (!widget.enabled && oldWidget.enabled)) { + _copied = false; + } + } + + @override + Widget build(BuildContext context) { + return AppTooltip( + message: _copied ? context.l10n.copied : context.l10n.copy, + child: SizedBox.square( + dimension: widget.dimension, + child: shad.IconButton.ghost( + size: shad.ButtonSize.xSmall, + onPressed: widget.enabled ? _copy : null, + icon: Icon(_copied ? Icons.check : Icons.copy_outlined, size: widget.iconSize), + ), + ), + ); + } + + Future _copy() async { + await Clipboard.setData(ClipboardData(text: widget.text)); + if (mounted) { + setState(() => _copied = true); + } + } +} diff --git a/ui/flutter/lib/shared/widgets/app_loading_button.dart b/ui/flutter/lib/shared/widgets/app_loading_button.dart index 81521d6b..dd5dde4a 100644 --- a/ui/flutter/lib/shared/widgets/app_loading_button.dart +++ b/ui/flutter/lib/shared/widgets/app_loading_button.dart @@ -31,20 +31,28 @@ class AppLoadingButton extends StatelessWidget { final replaceChild = loading && icon == null; final leading = loading && icon != null ? _LoadingIcon(variant: variant) : icon; final effectiveChild = replaceChild ? _LoadingIcon(variant: variant) : child; + final content = leading == null + ? effectiveChild + : Row( + mainAxisSize: MainAxisSize.min, + children: [ + leading, + const SizedBox(width: AppDesignTokens.space8), + effectiveChild, + ], + ); final effectiveOnPressed = loading ? null : onPressed; return switch (variant) { AppLoadingButtonVariant.secondary => shad.SecondaryButton( onPressed: effectiveOnPressed, - leading: leading, alignment: alignment, - child: effectiveChild, + child: content, ), AppLoadingButtonVariant.primary => AppPrimaryButton( onPressed: effectiveOnPressed, - leading: leading, alignment: alignment, - child: effectiveChild, + child: content, ), AppLoadingButtonVariant.brand => shad.ButtonStyleOverride( decoration: (context, states, value) { @@ -70,12 +78,7 @@ class AppLoadingButton extends StatelessWidget { ? palette.brandForeground.withValues(alpha: 0.55) : palette.brandForeground, ), - child: shad.SecondaryButton( - onPressed: effectiveOnPressed, - leading: leading, - alignment: alignment, - child: effectiveChild, - ), + child: shad.SecondaryButton(onPressed: effectiveOnPressed, alignment: alignment, child: content), ), }; } diff --git a/ui/flutter/lib/shared/widgets/app_primary_button.dart b/ui/flutter/lib/shared/widgets/app_primary_button.dart index 95f0127b..844c3cfe 100644 --- a/ui/flutter/lib/shared/widgets/app_primary_button.dart +++ b/ui/flutter/lib/shared/widgets/app_primary_button.dart @@ -31,6 +31,16 @@ class AppPrimaryButton extends StatelessWidget { @override Widget build(BuildContext context) { final palette = AppPalette.of(context); + final content = leading == null && trailing == null + ? child + : Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (leading != null) ...[leading!, const SizedBox(width: AppDesignTokens.space8)], + child, + if (trailing != null) ...[const SizedBox(width: AppDesignTokens.space8), trailing!], + ], + ); return shad.ButtonStyleOverride( decoration: (context, states, value) { @@ -48,14 +58,12 @@ class AppPrimaryButton extends StatelessWidget { }, child: shad.PrimaryButton( onPressed: onPressed, - leading: leading, - trailing: trailing, alignment: alignment, size: size, density: density, shape: shape, disableTransition: disableTransition, - child: child, + child: content, ), ); } diff --git a/ui/flutter/pubspec.lock b/ui/flutter/pubspec.lock index 8404335b..6427d81b 100644 --- a/ui/flutter/pubspec.lock +++ b/ui/flutter/pubspec.lock @@ -547,6 +547,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.3.2" + flutter_svg: + dependency: "direct main" + description: + name: flutter_svg + sha256: "35882981abcbfb8c15b286f0cd690ff25bac12d95eff3e25ee207f37d4c42e7f" + url: "https://pub.dev" + source: hosted + version: "2.3.0" flutter_test: dependency: "direct dev" description: flutter @@ -934,6 +942,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.9.1" + path_parsing: + dependency: transitive + description: + name: path_parsing + sha256: "883402936929eac138ee0a45da5b0f2c80f89913e6dc3bf77eb65b84b409c6ca" + url: "https://pub.dev" + source: hosted + version: "1.1.0" path_provider: dependency: "direct main" description: @@ -1549,6 +1565,30 @@ packages: url: "https://pub.dev" source: hosted version: "4.6.0" + vector_graphics: + dependency: transitive + description: + name: vector_graphics + sha256: "9d0e3b9cb16542ad660daee871e726a10d13a93b7b5391677c3160e8f5e83935" + url: "https://pub.dev" + source: hosted + version: "1.2.3" + vector_graphics_codec: + dependency: transitive + description: + name: vector_graphics_codec + sha256: "99fd9fbd34d9f9a32efd7b6a6aae14125d8237b10403b422a6a6dfeac2806146" + url: "https://pub.dev" + source: hosted + version: "1.1.13" + vector_graphics_compiler: + dependency: transitive + description: + name: vector_graphics_compiler + sha256: "4dca4feb77dc3ec7f6e27e49c53241eb8217f55e4f9b12599a27f8903bca5682" + url: "https://pub.dev" + source: hosted + version: "1.3.0" vector_math: dependency: transitive description: diff --git a/ui/flutter/pubspec.yaml b/ui/flutter/pubspec.yaml index 6542081b..e74d7129 100644 --- a/ui/flutter/pubspec.yaml +++ b/ui/flutter/pubspec.yaml @@ -64,6 +64,7 @@ dependencies: permission_handler: ^11.3.1 flutter_foreground_task: ^8.2.0 flutter_markdown_plus: ^1.0.7 + flutter_svg: ^2.3.0 path: ^1.9.1 path_provider: ^2.1.5 url_launcher: ^6.3.1 @@ -122,6 +123,7 @@ flutter: - assets/extension/ - assets/tray_icon/ - assets/icon/ + - assets/agent_icons/ - assets/exec/ fonts: diff --git a/ui/flutter/test/api/downloader_config_test.dart b/ui/flutter/test/api/downloader_config_test.dart index d0bea3c0..aa9825d1 100644 --- a/ui/flutter/test/api/downloader_config_test.dart +++ b/ui/flutter/test/api/downloader_config_test.dart @@ -20,10 +20,17 @@ void main() { test('REST server settings round-trip through the Go downloader config', () { final config = DownloaderConfig() - ..api = ApiServerConfig(enable: true, network: 'tcp', address: '127.0.0.1:4321', token: 'secret'); + ..api = ApiServerConfig( + enable: true, + mcpEnable: true, + network: 'tcp', + address: '127.0.0.1:4321', + token: 'secret', + ); final decoded = DownloaderConfig.fromJson(config.toJson()).api; expect(decoded.enable, isTrue); + expect(decoded.mcpEnable, isTrue); expect(decoded.network, 'tcp'); expect(decoded.address, '127.0.0.1:4321'); expect(decoded.token, 'secret'); diff --git a/ui/flutter/test/features/settings/application/settings_controller_test.dart b/ui/flutter/test/features/settings/application/settings_controller_test.dart index 6b477da8..d1473f30 100644 --- a/ui/flutter/test/features/settings/application/settings_controller_test.dart +++ b/ui/flutter/test/features/settings/application/settings_controller_test.dart @@ -75,6 +75,7 @@ class _TestRuntimeController extends AppRuntimeController { startConfig: StartConfig(), apiServerState: const ApiServerState( enabled: false, + mcpEnabled: false, running: false, network: '', address: '', diff --git a/ui/flutter/test/shared/widgets/app_loading_button_test.dart b/ui/flutter/test/shared/widgets/app_loading_button_test.dart index a29fb401..1726cfd6 100644 --- a/ui/flutter/test/shared/widgets/app_loading_button_test.dart +++ b/ui/flutter/test/shared/widgets/app_loading_button_test.dart @@ -51,6 +51,20 @@ void main() { expect(find.descendant(of: secondary, matching: find.text('Check for Updates')), findsOneWidget); expect(find.descendant(of: brand, matching: find.text('Update')), findsOneWidget); expect(find.descendant(of: primary, matching: find.text('Save')), findsOneWidget); + expect( + tester + .widget(find.descendant(of: secondary, matching: find.byType(shad.SecondaryButton))) + .leading, + isNull, + ); + expect( + tester + .widget(find.descendant(of: primary, matching: find.byType(shad.PrimaryButton))) + .leading, + isNull, + ); + expect(find.descendant(of: secondary, matching: find.byType(Row)), findsWidgets); + expect(find.descendant(of: primary, matching: find.byType(Row)), findsWidgets); expect(find.descendant(of: secondary, matching: find.byIcon(shad.LucideIcons.refreshCw)), findsNothing); expect(find.descendant(of: brand, matching: find.byIcon(shad.LucideIcons.download)), findsNothing); expect(find.descendant(of: primary, matching: find.byIcon(shad.LucideIcons.save)), findsNothing); diff --git a/ui/flutter/test/widget_test.dart b/ui/flutter/test/widget_test.dart index badbdfa9..860f9138 100644 --- a/ui/flutter/test/widget_test.dart +++ b/ui/flutter/test/widget_test.dart @@ -4,6 +4,7 @@ import 'dart:io'; import 'package:flutter/foundation.dart' show TargetPlatform, debugDefaultTargetPlatformOverride; import 'package:flutter/material.dart' show Icons, Scrollbar; import 'package:flutter/gestures.dart' show PointerDeviceKind, kDoubleTapMinTime, kSecondaryMouseButton; +import 'package:flutter/rendering.dart' show RenderParagraph; import 'package:flutter/services.dart' show MethodChannel, SystemChannels; import 'package:flutter/widgets.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -29,6 +30,7 @@ import 'package:gopeed/api/model/task.dart' as api_task; import 'package:gopeed/core/common/start_config.dart'; import 'package:gopeed/core/common/api_server_state.dart'; import 'package:gopeed/core/icons/gopeed_icons.dart'; +import 'package:gopeed/core/network/gopeed/gopeed_transport.dart'; import 'package:gopeed/core/window/app_window_chrome.dart'; import 'package:gopeed/core/window/app_window_frame.dart'; import 'package:gopeed/features/home/presentation/widgets/tasks_top_bar.dart'; @@ -44,6 +46,7 @@ import 'package:gopeed/features/tasks/application/tasks_controller.dart'; import 'package:gopeed/features/settings/application/settings_controller.dart'; import 'package:gopeed/features/settings/presentation/pages/settings_page.dart'; import 'package:gopeed/features/settings/presentation/widgets/download_categories_setting.dart'; +import 'package:gopeed/features/settings/presentation/widgets/mcp_agent_setup.dart'; import 'package:gopeed/features/settings/presentation/widgets/settings_item.dart'; import 'package:gopeed/features/settings/presentation/widgets/settings_language_select.dart'; import 'package:gopeed/features/settings/presentation/widgets/settings_list_editor.dart'; @@ -74,6 +77,7 @@ import 'package:gopeed/shared/theme/app_theme_color.dart'; import 'package:gopeed/shared/services/download_directory_picker.dart'; import 'package:gopeed/shared/widgets/app_loading_button.dart'; import 'package:gopeed/shared/widgets/app_choice_segmented_control.dart'; +import 'package:gopeed/shared/widgets/app_copy_icon_button.dart'; import 'package:gopeed/shared/widgets/app_http_headers_editor.dart'; import 'package:gopeed/shared/widgets/app_path_picker_field.dart'; import 'package:gopeed/shared/widgets/app_primary_button.dart'; @@ -88,6 +92,29 @@ import 'package:gopeed/util/updater.dart'; void main() { TestWidgetsFlutterBinding.ensureInitialized().platformDispatcher.localeTestValue = const Locale('en'); + test('Web MCP endpoint excludes page path, query, and hash route', () { + expect( + webMcpEndpoint(Uri.parse('https://gopeed.example:8443/app?source=desktop#/settings/advanced')), + 'https://gopeed.example:8443/mcp', + ); + expect(webMcpEndpoint(Uri.parse('http://127.0.0.1:9999/#/settings?tab=advanced')), 'http://127.0.0.1:9999/mcp'); + }); + + test('Web API display address normalizes localhost', () { + expect(webApiDisplayAddress(Uri.parse('http://localhost:9999/#/settings/advanced')), '127.0.0.1:9999'); + expect(webApiDisplayAddress(Uri.parse('https://example.com/settings')), 'example.com'); + expect(webApiDisplayAddress(Uri.parse('http://[::1]:9999/settings')), '[::1]:9999'); + }); + + test('Flutter Web debug API defaults to port 9999', () { + final pageUri = Uri.parse('http://localhost:52143/app?build=debug#/settings/advanced'); + + expect(webApiBaseUrl(pageUri, debugMode: true), 'http://127.0.0.1:9999/'); + expect(webApiBaseUrl(pageUri, debugMode: false), 'http://localhost:52143/'); + expect(webTransportBaseUrl('http://127.0.0.1:9999/', pageUri, debugMode: true), 'http://127.0.0.1:9999/'); + expect(webTransportBaseUrl('http://127.0.0.1:9999/', pageUri, debugMode: false), ''); + }); + test('macOS reserves its native overlay header without custom chrome', () { debugDefaultTargetPlatformOverride = TargetPlatform.macOS; try { @@ -100,6 +127,14 @@ void main() { } }); + test('Web reserves the same top content inset as desktop platforms', () { + expect(AppWindowChrome.reservesHeaderInsetFor(web: true, platform: TargetPlatform.android), isTrue); + expect(AppWindowChrome.reservesHeaderInsetFor(web: false, platform: TargetPlatform.windows), isTrue); + expect(AppWindowChrome.reservesHeaderInsetFor(web: false, platform: TargetPlatform.macOS), isTrue); + expect(AppWindowChrome.reservesHeaderInsetFor(web: false, platform: TargetPlatform.linux), isTrue); + expect(AppWindowChrome.reservesHeaderInsetFor(web: false, platform: TargetPlatform.android), isFalse); + }); + testWidgets('desktop frame overlays global caption controls without insetting content', (WidgetTester tester) async { const windowChannel = MethodChannel('window_manager'); debugDefaultTargetPlatformOverride = TargetPlatform.windows; @@ -400,7 +435,7 @@ void main() { final appMarkRect = tester.getRect(find.byKey(const ValueKey('primary-rail-app-mark'))); expect(searchRect.width, 240); expect(searchRect.width, lessThan(cardRect.width)); - expect(sortRect.left - searchRect.right, greaterThanOrEqualTo(10)); + expect(sortRect.left - searchRect.right, closeTo(10, 0.01)); expect(sortRect.center.dy, closeTo(searchRect.center.dy, 0.01)); expect(sortRect.height, closeTo(refreshRect.height, 0.01)); expect(searchRect.center.dy, closeTo(appMarkRect.center.dy, 0.01)); @@ -439,6 +474,11 @@ void main() { tester.view.physicalSize = const Size(1400, 900); await tester.pumpAndSettle(); expect(gridColumns(), 4); + expect( + tester.getRect(find.byKey(const ValueKey('extension-sort-control'))).left - + tester.getRect(find.byKey(const ValueKey('extension-search-input'))).right, + closeTo(10, 0.01), + ); expect(tester.takeException(), isNull); }); @@ -976,6 +1016,7 @@ void main() { expect(find.text('Network'), findsOneWidget); expect(find.text('API'), findsOneWidget); expect(find.text('Developer'), findsOneWidget); + expect(tester.getTopLeft(find.text('API')).dy, lessThan(tester.getTopLeft(find.text('Network')).dy)); expect(tester.takeException(), isNull); }); @@ -1033,11 +1074,12 @@ void main() { await tester.tap(find.text('Advanced')); await tester.pumpAndSettle(); - expect(find.text('Protocol'), findsOneWidget); + expect(find.text('Protocol'), findsNothing); + expect(find.text('Unix Socket'), findsNothing); expect(find.text('Username'), findsNothing); await tester.tap(find.text('Custom Proxy')); await tester.pumpAndSettle(); - expect(find.text('Protocol'), findsNWidgets(2)); + expect(find.text('Protocol'), findsOneWidget); expect(find.text('Username'), findsOneWidget); expect(find.text('Password'), findsOneWidget); }); @@ -1317,6 +1359,16 @@ void main() { testWidgets('API settings keep a validated draft until the save button is pressed', (WidgetTester tester) async { await _setTestSize(tester, const Size(1024, 900)); final runtimeController = RecordingStartConfigRuntimeController(); + String? clipboardText; + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(SystemChannels.platform, (call) async { + if (call.method == 'Clipboard.setData') { + clipboardText = (call.arguments as Map)['text'] as String?; + } + return null; + }); + addTearDown(() { + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(SystemChannels.platform, null); + }); await tester.pumpWidget( ProviderScope( overrides: [ @@ -1331,7 +1383,7 @@ void main() { await tester.tap(find.text('Advanced')); await tester.pumpAndSettle(); expect(find.text('Runtime status'), findsOneWidget); - expect(find.text('tcp://192.168.1.20:4321'), findsOneWidget); + expect(find.text('http://192.168.1.20:4321'), findsOneWidget); expect(find.byKey(const ValueKey('api-server-status-dot')), findsOneWidget); final host = find.byKey(const ValueKey('api-host-input')); @@ -1341,6 +1393,68 @@ void main() { expect(tester.widget(host).controller!.text, '192.168.1.20'); expect(tester.widget(port).controller!.text, '4321'); expect(tester.widget(saveButton).onPressed, isNull); + expect(find.byKey(const ValueKey('mcp-endpoint-switch')), findsOneWidget); + expect(find.byType(McpSettingsItem), findsOneWidget); + expect(find.byKey(const ValueKey('mcp-ai-icon')), findsOneWidget); + expect(find.text('Collaborate with AI agents and explore limitless possibilities'), findsOneWidget); + expect(find.text('Setup guide'), findsOneWidget); + + final agentSetupLink = find.byKey(const ValueKey('open-mcp-agent-setup')); + expect( + tester.getRect(agentSetupLink).left, + lessThan(tester.getRect(find.byKey(const ValueKey('mcp-endpoint-switch'))).left), + ); + await tester.ensureVisible(agentSetupLink); + await tester.tap(agentSetupLink); + await tester.pumpAndSettle(); + expect(find.byKey(const ValueKey('mcp-agent-setup-dialog')), findsOneWidget); + expect(find.byTooltip('Cursor'), findsOneWidget); + expect(find.text('Codex · MCP'), findsOneWidget); + expect(find.textContaining('http://192.168.1.20:4321/mcp'), findsOneWidget); + expect(find.textContaining("GOPEED_API_TOKEN='••••••••'"), findsOneWidget); + expect(find.textContaining('bearer-token-env-var GOPEED_API_TOKEN'), findsOneWidget); + expect(find.textContaining("GOPEED_API_TOKEN='token'"), findsNothing); + expect(find.textContaining('token placeholder'), findsNothing); + final copySnippet = find.byKey(const ValueKey('copy-mcp-agent-snippet')); + expect(tester.widget(copySnippet), isA()); + expect(find.descendant(of: copySnippet, matching: find.byIcon(Icons.copy_outlined)), findsOneWidget); + expect( + tester.widget(find.descendant(of: copySnippet, matching: find.byType(AppTooltip))).message, + 'Copy', + ); + await tester.tap(copySnippet); + await tester.pump(); + expect(clipboardText, contains("export GOPEED_API_TOKEN='token'")); + expect(clipboardText, contains('--bearer-token-env-var GOPEED_API_TOKEN')); + expect(clipboardText, isNot(contains('••••••••'))); + expect(find.descendant(of: copySnippet, matching: find.byIcon(Icons.check)), findsOneWidget); + expect( + tester.widget(find.descendant(of: copySnippet, matching: find.byType(AppTooltip))).message, + 'Copied', + ); + await tester.tap(find.byKey(const ValueKey('mcp-agent-cursor'))); + await tester.pumpAndSettle(); + expect(find.textContaining('Authorization'), findsOneWidget); + expect(find.textContaining('Bearer ••••••••'), findsOneWidget); + expect(find.textContaining('Bearer token'), findsNothing); + expect(find.descendant(of: copySnippet, matching: find.byIcon(Icons.copy_outlined)), findsOneWidget); + expect( + tester.widget(find.descendant(of: copySnippet, matching: find.byType(AppTooltip))).message, + 'Copy', + ); + await tester.tap(copySnippet); + await tester.pump(); + expect(clipboardText, contains('Bearer token')); + await tester.tap(find.byKey(const ValueKey('mcp-agent-claude-code'))); + await tester.pumpAndSettle(); + expect(find.textContaining('claude mcp add --transport http'), findsOneWidget); + await tester.tap(find.byKey(const ValueKey('close-mcp-agent-setup'))); + await tester.pumpAndSettle(); + + await tester.ensureVisible(find.byKey(const ValueKey('mcp-endpoint-switch'))); + await tester.tap(find.byKey(const ValueKey('mcp-endpoint-switch'))); + await tester.pump(); + expect(tester.widget(saveButton).onPressed, isNotNull); await tester.enterText(host, ''); await tester.enterText(port, '0'); @@ -1364,6 +1478,7 @@ void main() { await tester.pumpAndSettle(); expect(runtimeController.savedStartConfig?.network, 'tcp'); expect(runtimeController.savedStartConfig?.address, '127.0.0.1:0'); + expect(runtimeController.savedStartConfig?.mcpEnable, isTrue); expect(tester.widget(saveButton).onPressed, isNull); expect(find.text('Restart required'), findsNothing); expect(find.text('Auto saved'), findsNothing); @@ -1372,15 +1487,202 @@ void main() { await tester.pumpAndSettle(); await tester.tap(find.text('Advanced')); await tester.pumpAndSettle(); - final protocolItem = find.ancestor(of: find.text('Protocol'), matching: find.byType(SettingsItem)); - final tcpChoice = find.byKey(const ValueKey('settings-choice-tcp')); - expect(tester.getRect(tcpChoice).left, closeTo(tester.getRect(protocolItem).left, 0.01)); + expect(find.text('Unix Socket'), findsNothing); await tester.pump(const Duration(seconds: 6)); await tester.pumpAndSettle(); expect(tester.takeException(), isNull); }); + testWidgets('read-only MCP settings keep the Agent setup link available', (WidgetTester tester) async { + var changeCount = 0; + var openCount = 0; + await tester.pumpWidget( + shad.ShadcnApp( + theme: AppTheme.light(), + materialTheme: AppTheme.materialLight(), + home: Center( + child: McpSettingsItem( + enabled: true, + readOnly: true, + onChanged: (_) => changeCount += 1, + onOpenAgentSetup: () => openCount += 1, + ), + ), + ), + ); + await tester.pump(); + + final switchFinder = find.byKey(const ValueKey('mcp-endpoint-switch')); + expect(tester.widget(switchFinder).enabled, isFalse); + await tester.tap(switchFinder); + await tester.pump(); + expect(changeCount, 0); + + await tester.tap(find.byKey(const ValueKey('open-mcp-agent-setup'))); + await tester.pump(); + expect(openCount, 1); + }); + + testWidgets('Web MCP Agent setup switches once to an API token template and resets on reopen', ( + WidgetTester tester, + ) async { + await _setTestSize(tester, const Size(800, 700)); + await tester.pumpWidget( + shad.ShadcnApp( + theme: AppTheme.light(), + materialTheme: AppTheme.materialLight(), + home: Builder( + builder: (context) => Center( + child: shad.PrimaryButton( + onPressed: () => unawaited( + showMcpAgentSetupDialog( + context, + endpoint: 'https://example.com/mcp', + apiToken: 'stale-web-token', + mcpRunning: true, + allowManualApiTokenTemplate: true, + ), + ), + child: const Text('Open'), + ), + ), + ), + ), + ); + + await tester.tap(find.text('Open')); + await tester.pumpAndSettle(); + final tokenHelp = find.byKey(const ValueKey('mcp-api-token-template-help')); + expect(tokenHelp, findsOneWidget); + expect(find.descendant(of: tokenHelp, matching: find.byIcon(Icons.help_outline)), findsOneWidget); + expect(find.byKey(const ValueKey('mcp-api-token-template-enabled')), findsNothing); + expect(find.textContaining(''), findsNothing); + expect(find.textContaining('stale-web-token'), findsNothing); + + final mouse = await tester.createGesture(kind: PointerDeviceKind.mouse); + await mouse.addPointer(location: Offset.zero); + addTearDown(mouse.removePointer); + await mouse.moveTo(tester.getCenter(tokenHelp)); + await tester.pump(const Duration(milliseconds: 200)); + await tester.pumpAndSettle(); + expect(find.byKey(const ValueKey('mcp-api-token-template-hint')), findsOneWidget); + expect( + find.text('If you configured an API token, authentication information is required when connecting the agent'), + findsOneWidget, + ); + final useTokenTemplate = find.byKey(const ValueKey('use-mcp-api-token-template')); + expect(useTokenTemplate, findsOneWidget); + expect(find.text('Switch to authenticated configuration'), findsOneWidget); + + await tester.tap(useTokenTemplate); + await tester.pumpAndSettle(); + expect(find.byKey(const ValueKey('mcp-api-token-template-help')), findsOneWidget); + expect(find.descendant(of: tokenHelp, matching: find.byIcon(Icons.help_outline)), findsOneWidget); + expect(find.textContaining("GOPEED_API_TOKEN=''"), findsOneWidget); + expect(find.textContaining('--bearer-token-env-var GOPEED_API_TOKEN'), findsOneWidget); + + await tester.tap(find.byKey(const ValueKey('close-mcp-agent-setup'))); + await tester.pumpAndSettle(); + await tester.tap(find.text('Open')); + await tester.pumpAndSettle(); + expect(find.byKey(const ValueKey('mcp-api-token-template-help')), findsOneWidget); + expect(find.textContaining(''), findsNothing); + }); + + testWidgets('MCP Agent setup dialog fits a compact mobile viewport', (WidgetTester tester) async { + await _setTestSize(tester, const Size(320, 568)); + var mcpRunning = false; + await tester.pumpWidget( + shad.ShadcnApp( + theme: AppTheme.light(), + materialTheme: AppTheme.materialLight(), + home: Builder( + builder: (context) => Center( + child: shad.PrimaryButton( + onPressed: () => unawaited( + showMcpAgentSetupDialog( + context, + endpoint: 'http://127.0.0.1:9999/mcp', + apiToken: mcpRunning ? '' : 'mobile-secret-token', + mcpRunning: mcpRunning, + ), + ), + child: const Text('Open'), + ), + ), + ), + ), + ); + + await tester.tap(find.text('Open')); + await tester.pumpAndSettle(); + expect(find.byKey(const ValueKey('mcp-agent-setup-dialog')), findsOneWidget); + expect(find.byKey(const ValueKey('mcp-agent-codex')), findsOneWidget); + expect(find.byKey(const ValueKey('mcp-agent-claude-code')), findsOneWidget); + expect(find.byKey(const ValueKey('mcp-agent-cursor')), findsOneWidget); + expect(find.byKey(const ValueKey('mcp-agent-github-copilot')), findsOneWidget); + expect(find.byKey(const ValueKey('mcp-agent-windsurf')), findsOneWidget); + expect(find.byKey(const ValueKey('mcp-agent-gemini-cli')), findsOneWidget); + expect(find.byKey(const ValueKey('mcp-agent-cline')), findsOneWidget); + expect(find.byKey(const ValueKey('mcp-agent-opencode')), findsOneWidget); + expect(find.byKey(const ValueKey('mcp-agent-trae')), findsOneWidget); + expect(find.byKey(const ValueKey('mcp-agent-qoder')), findsOneWidget); + expect(find.byKey(const ValueKey('mcp-agent-codebuddy')), findsNothing); + expect(find.byKey(const ValueKey('mcp-agent-workbuddy')), findsOneWidget); + expect(find.byKey(const ValueKey('mcp-agent-zcode')), findsOneWidget); + expect(find.byKey(const ValueKey('mcp-agent-deepseek-harness')), findsOneWidget); + expect(find.byKey(const ValueKey('mcp-agent-pi')), findsOneWidget); + expect(find.byTooltip('DeepSeek Harness'), findsOneWidget); + expect(find.text('Codex · MCP'), findsOneWidget); + expect(find.text('Cursor'), findsNothing); + expect(find.textContaining('mobi••••••••oken'), findsOneWidget); + expect(find.textContaining('mobile-secret-token'), findsNothing); + final codexPosition = tester.getTopLeft(find.byKey(const ValueKey('mcp-agent-codex'))); + final claudePosition = tester.getTopLeft(find.byKey(const ValueKey('mcp-agent-claude-code'))); + final cursorPosition = tester.getTopLeft(find.byKey(const ValueKey('mcp-agent-cursor'))); + expect(codexPosition.dy, claudePosition.dy); + expect(claudePosition.dy, cursorPosition.dy); + expect(codexPosition.dx, lessThan(claudePosition.dx)); + expect(claudePosition.dx, lessThan(cursorPosition.dx)); + expect( + tester.getTopLeft(find.byKey(const ValueKey('mcp-agent-cursor'))).dy, + lessThan(tester.getTopLeft(find.byKey(const ValueKey('mcp-agent-pi'))).dy), + ); + expect(find.byKey(const ValueKey('mcp-not-running-hint')), findsOneWidget); + expect(find.text('The MCP service is not enabled. Please enable it'), findsOneWidget); + expect(find.text('Generic'), findsNothing); + expect(tester.getSize(find.byKey(const ValueKey('mcp-agent-snippet'))).height, 176); + expect(find.byKey(const ValueKey('mcp-snippet-horizontal-scrollbar')), findsOneWidget); + expect(find.byKey(const ValueKey('mcp-snippet-vertical-scrollbar')), findsOneWidget); + expect(tester.getRect(find.byKey(const ValueKey('mcp-agent-snippet'))).right, lessThanOrEqualTo(320)); + expect(tester.getRect(find.byKey(const ValueKey('close-mcp-agent-setup'))).right, lessThanOrEqualTo(320)); + expect(find.byKey(const ValueKey('finish-mcp-agent-setup')), findsNothing); + expect(tester.takeException(), isNull); + + final deepSeek = find.byKey(const ValueKey('mcp-agent-deepseek-harness')); + await tester.ensureVisible(deepSeek); + await tester.tap(deepSeek); + await tester.pumpAndSettle(); + expect(find.text('DeepSeek Harness · MCP'), findsOneWidget); + expect(find.textContaining('@deepseek-ai/dsh-mcp-client'), findsOneWidget); + expect(tester.getSize(find.byKey(const ValueKey('mcp-agent-snippet'))).height, 176); + + final pi = find.byKey(const ValueKey('mcp-agent-pi')); + await tester.ensureVisible(pi); + await tester.tap(pi); + await tester.pumpAndSettle(); + expect(find.textContaining('pi install npm:pi-codemcp'), findsOneWidget); + + await tester.tap(find.byKey(const ValueKey('close-mcp-agent-setup'))); + await tester.pumpAndSettle(); + mcpRunning = true; + await tester.tap(find.text('Open')); + await tester.pumpAndSettle(); + expect(find.byKey(const ValueKey('mcp-not-running-hint')), findsNothing); + expect(find.textContaining('codex mcp add gopeed --url'), findsOneWidget); + }); + testWidgets('disabling the API server requires confirmation and keeps listener fields visible', ( WidgetTester tester, ) async { @@ -1423,7 +1725,7 @@ void main() { expect(tester.widget(hostInput).controller!.text, 'unsaved.example.com'); final statusDot = tester.widget(find.byKey(const ValueKey('api-server-status-dot'))); expect((statusDot.decoration! as BoxDecoration).color, AppPalette.light.textMuted.withValues(alpha: 0.72)); - expect(find.text('tcp://192.168.1.20:4321'), findsNothing); + expect(find.text('http://192.168.1.20:4321'), findsNothing); await tester.pump(const Duration(seconds: 6)); await tester.pumpAndSettle(); expect(tester.takeException(), isNull); @@ -2209,6 +2511,56 @@ void main() { ); }); + testWidgets('create task button fits Chinese and caps longer translations', (WidgetTester tester) async { + await _setTestSize(tester, const Size(1024, 120)); + final searchController = TextEditingController(); + addTearDown(searchController.dispose); + + Widget appFor(Locale locale) { + return shad.ShadcnApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + theme: AppTheme.light(), + materialTheme: AppTheme.materialLight(), + home: Builder( + builder: (context) => Localizations.override( + context: context, + locale: locale, + delegates: const [AppLocalizations.delegate], + child: TasksTopBar( + searchController: searchController, + onAddTask: () {}, + batchMode: false, + selectedBatchCount: 0, + canPauseSelected: false, + canResumeSelected: false, + onToggleBatchMode: () {}, + onPauseSelected: () {}, + onResumeSelected: () {}, + onDeleteSelected: () {}, + ), + ), + ), + ); + } + + await tester.pumpWidget(appFor(const Locale('zh'))); + await tester.pumpAndSettle(); + final chineseText = find.text('创建任务'); + expect(chineseText, findsOneWidget); + expect(tester.renderObject(chineseText).didExceedMaxLines, isFalse); + final chineseWidth = tester.getSize(find.byKey(const ValueKey('tasks-create-button-container'))).width; + expect(chineseWidth, greaterThanOrEqualTo(120)); + + await tester.pumpWidget(appFor(const Locale('hu'))); + await tester.pumpAndSettle(); + final hungarianText = find.text('Feladat létrehozása'); + expect(hungarianText, findsOneWidget); + final hungarianWidth = tester.getSize(find.byKey(const ValueKey('tasks-create-button-container'))).width; + expect(hungarianWidth, greaterThan(chineseWidth)); + expect(hungarianWidth, lessThanOrEqualTo(190)); + }); + testWidgets('resolved file selector uses aligned tree header and sortable columns', (WidgetTester tester) async { await _setTestSize(tester, const Size(760, 620)); var selected = []; @@ -3178,6 +3530,16 @@ void main() { addTearDown(() => debugDefaultTargetPlatformOverride = null); await _setTestSize(tester, const Size(900, 700)); final task = _taskRecord(id: 'editable-details', name: 'editable.zip', status: TaskStatus.paused); + String? clipboardText; + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(SystemChannels.platform, (call) async { + if (call.method == 'Clipboard.setData') { + clipboardText = (call.arguments as Map)['text'] as String?; + } + return null; + }); + addTearDown(() { + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(SystemChannels.platform, null); + }); await tester.pumpWidget( ProviderScope( @@ -3208,6 +3570,22 @@ void main() { expect(find.text('Edit'), findsNothing); expect(find.text('Open'), findsNothing); + final copyAction = find.byKey(const ValueKey('task-details-copy-url')); + expect(tester.widget(copyAction), isA()); + expect(find.descendant(of: copyAction, matching: find.byIcon(Icons.copy_outlined)), findsOneWidget); + expect( + tester.widget(find.descendant(of: copyAction, matching: find.byType(AppTooltip))).message, + 'Copy', + ); + await tester.tap(copyAction); + await tester.pump(); + expect(clipboardText, task.url); + expect(find.descendant(of: copyAction, matching: find.byIcon(Icons.check)), findsOneWidget); + expect( + tester.widget(find.descendant(of: copyAction, matching: find.byType(AppTooltip))).message, + 'Copied', + ); + final editAction = find.byKey(const ValueKey('task-details-edit-url')); final editTooltipFinder = find.descendant(of: editAction, matching: find.byType(AppTooltip)); expect(tester.widget(editTooltipFinder).message, 'Edit'); @@ -4656,6 +5034,7 @@ class RecordingStartConfigRuntimeController extends AppRuntimeController { ..network = config.network ..address = config.address ..apiEnable = config.apiEnable + ..mcpEnable = config.mcpEnable ..apiToken = config.apiToken ..storage = config.storage ..storageDir = config.storageDir @@ -4686,6 +5065,7 @@ ApiServerState _testApiServerState({ }) { return ApiServerState( enabled: enabled ?? running, + mcpEnabled: false, running: running, network: running ? 'tcp' : '', address: running ? address : '', diff --git a/ui/flutter_old/pubspec.lock b/ui/flutter_old/pubspec.lock index bc1d1d28..5a9f98b0 100644 --- a/ui/flutter_old/pubspec.lock +++ b/ui/flutter_old/pubspec.lock @@ -810,10 +810,10 @@ packages: dependency: transitive description: name: matcher - sha256: "12956d0ad8390bbcc63ca2e1469c0619946ccb52809807067a7020d57e647aa6" + sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 url: "https://pub.dev" source: hosted - version: "0.12.18" + version: "0.12.19" material_color_utilities: dependency: transitive description: @@ -1328,10 +1328,10 @@ packages: dependency: transitive description: name: test_api - sha256: "93167629bfc610f71560ab9312acdda4959de4df6fac7492c89ff0d3886f6636" + sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" url: "https://pub.dev" source: hosted - version: "0.7.9" + version: "0.7.10" timezone: dependency: transitive description: