feat: add MCP support (#1471)

This commit is contained in:
Levi authored and GitHub committed 2026-09-05 20:21:39 +08:00
1 parent 39cd9034cd
commit f2145de571
83 files changed
+3119 -363

No files matched your search

+20 -1
View File
@@ -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)
+20 -1
View File
@@ -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)
+20 -1
View File
@@ -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)
+20 -1
View File
@@ -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)
+20 -1
View File
@@ -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)
+24 -9
View File
@@ -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)
}
}
}
}
+85 -18
View File
@@ -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"),
},
},
+1
View File
@@ -50,6 +50,7 @@ func main() {
StorageDir: storageDir,
WhiteDownloadDirs: args.WhiteDownloadDirs,
ApiToken: *args.ApiToken,
MCPEnable: args.MCPEnable,
DownloadConfig: args.DownloadConfig,
ProductionMode: true,
WebEnable: true,
+15 -9
View File
@@ -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
+32 -22
View File
@@ -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=
+5 -4
View File
@@ -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 {
+525
View File
@@ -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
}
+215
View File
@@ -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)
}
}
+1
View File
@@ -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
}
+30 -5
View File
@@ -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)
}
+2
View File
@@ -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"`
+51 -10
View File
@@ -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
+107
View File
@@ -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) {
+21
View File
@@ -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.
+13
View File
@@ -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.
@@ -0,0 +1 @@
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Claude Code</title><path clip-rule="evenodd" d="M20.998 10.949H24v3.102h-3v3.028h-1.487V20H18v-2.921h-1.487V20H15v-2.921H9V20H7.488v-2.921H6V20H4.487v-2.921H3V14.05H0V10.95h3V5h17.998v5.949zM6 10.949h1.488V8.102H6v2.847zm10.51 0H18V8.102h-1.49v2.847z" fill="#D97757" fill-rule="evenodd"></path></svg>

After

Width:  |  Height:  |  Size: 425 B

+1
View File
@@ -0,0 +1 @@
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Cline</title><path d="M17.035 3.991c2.75 0 4.98 2.24 4.98 5.003v1.667l1.45 2.896a1.01 1.01 0 01-.002.909l-1.448 2.864v1.668c0 2.762-2.23 5.002-4.98 5.002H7.074c-2.751 0-4.98-2.24-4.98-5.002V17.33l-1.48-2.855a1.01 1.01 0 01-.003-.927l1.482-2.887V8.994c0-2.763 2.23-5.003 4.98-5.003h9.962zM8.265 9.6a2.274 2.274 0 00-2.274 2.274v4.042a2.274 2.274 0 004.547 0v-4.042A2.274 2.274 0 008.265 9.6zm7.326 0a2.274 2.274 0 00-2.274 2.274v4.042a2.274 2.274 0 104.548 0v-4.042A2.274 2.274 0 0015.59 9.6z"></path><path d="M12.054 5.558a2.779 2.779 0 100-5.558 2.779 2.779 0 000 5.558z"></path></svg>

After

Width:  |  Height:  |  Size: 751 B

@@ -0,0 +1 @@
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>CodeBuddy</title><path d="M18.821 0H5.18A5.179 5.179 0 000 5.179V18.82A5.179 5.179 0 005.179 24H18.82A5.179 5.179 0 0024 18.821V5.18A5.179 5.179 0 0018.821 0z" fill="url(#lobe-icons-code-buddy-_R_0_)"></path><path d="M18.777 1.647c.28-.02.536.114.972.51 1.018.926 2.437 2.828 3.318 4.452l.34.631.482.24.11.06v3.638a5.206 5.206 0 00-5.32-1.23c-.491.166-1.021.471-2.08 1.082l-6.09 3.516c-1.057.61-1.586.916-1.975 1.259a5.208 5.208 0 00-1.493 5.572c.165.49.471 1.02 1.082 2.08l.315.543h-3.26c-.685 0-1.34-.135-1.939-.377-.169-.956-.009-1.789.469-2.335.158-.18.164-.189.13-.493a11.846 11.846 0 01-.057-1.711l.02-.444-.667-1.18C2.1 15.622 1.445 14.078 1.192 12.9c-.133-.647-.125-.934.04-1.146.1-.128.427-.261.822-.334.994-.175 3.162-.017 5.575.41l.25.043.551-.487c.915-.81 1.522-1.264 2.641-1.962 1.167-.73 2.484-1.331 3.967-1.807l.476-.152.261-.688c.937-2.471 1.896-4.293 2.58-4.9.235-.21.25-.22.422-.23z" fill="#fff"></path><path d="M12.139 18.2a1.203 1.203 0 011.642.44l1.296 2.243a1.204 1.204 0 01-2.083 1.203l-1.296-2.243a1.203 1.203 0 01.44-1.644zM18.629 14.452a1.203 1.203 0 011.642.44l1.295 2.244a1.203 1.203 0 11-2.083 1.203l-1.295-2.243a1.203 1.203 0 01.44-1.644z" fill="#fff"></path><defs><radialGradient cx="0" cy="0" gradientTransform="matrix(-9.00009 -16 16 -9.00009 21 24.5)" gradientUnits="userSpaceOnUse" id="lobe-icons-code-buddy-_R_0_" r="1"><stop stop-color="#2EA99D"></stop><stop offset="1" stop-color="#6C4DFF"></stop></radialGradient></defs></svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

+1
View File
@@ -0,0 +1 @@
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Codex</title><path d="M19.503 0H4.496A4.496 4.496 0 000 4.496v15.007A4.496 4.496 0 004.496 24h15.007A4.496 4.496 0 0024 19.503V4.496A4.496 4.496 0 0019.503 0z" fill="#fff"></path><path d="M9.064 3.344a4.578 4.578 0 012.285-.312c1 .115 1.891.54 2.673 1.275.01.01.024.017.037.021a.09.09 0 00.043 0 4.55 4.55 0 013.046.275l.047.022.116.057a4.581 4.581 0 012.188 2.399c.209.51.313 1.041.315 1.595a4.24 4.24 0 01-.134 1.223.123.123 0 00.03.115c.594.607.988 1.33 1.183 2.17.289 1.425-.007 2.71-.887 3.854l-.136.166a4.548 4.548 0 01-2.201 1.388.123.123 0 00-.081.076c-.191.551-.383 1.023-.74 1.494-.9 1.187-2.222 1.846-3.711 1.838-1.187-.006-2.239-.44-3.157-1.302a.107.107 0 00-.105-.024c-.388.125-.78.143-1.204.138a4.441 4.441 0 01-1.945-.466 4.544 4.544 0 01-1.61-1.335c-.152-.202-.303-.392-.414-.617a5.81 5.81 0 01-.37-.961 4.582 4.582 0 01-.014-2.298.124.124 0 00.006-.056.085.085 0 00-.027-.048 4.467 4.467 0 01-1.034-1.651 3.896 3.896 0 01-.251-1.192 5.189 5.189 0 01.141-1.6c.337-1.112.982-1.985 1.933-2.618.212-.141.413-.251.601-.33.215-.089.43-.164.646-.227a.098.098 0 00.065-.066 4.51 4.51 0 01.829-1.615 4.535 4.535 0 011.837-1.388zm3.482 10.565a.637.637 0 000 1.272h3.636a.637.637 0 100-1.272h-3.636zM8.462 9.23a.637.637 0 00-1.106.631l1.272 2.224-1.266 2.136a.636.636 0 101.095.649l1.454-2.455a.636.636 0 00.005-.64L8.462 9.23z" fill="url(#lobe-icons-codex-_R_0_)"></path><defs><linearGradient gradientUnits="userSpaceOnUse" id="lobe-icons-codex-_R_0_" x1="12" x2="12" y1="3" y2="21"><stop stop-color="#B1A7FF"></stop><stop offset=".5" stop-color="#7A9DFF"></stop><stop offset="1" stop-color="#3941FF"></stop></linearGradient></defs></svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

+1
View File
@@ -0,0 +1 @@
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Cursor</title><path d="M22.106 5.68L12.5.135a.998.998 0 00-.998 0L1.893 5.68a.84.84 0 00-.419.726v11.186c0 .3.16.577.42.727l9.607 5.547a.999.999 0 00.998 0l9.608-5.547a.84.84 0 00.42-.727V6.407a.84.84 0 00-.42-.726zm-.603 1.176L12.228 22.92c-.063.108-.228.064-.228-.061V12.34a.59.59 0 00-.295-.51l-9.11-5.26c-.107-.062-.063-.228.062-.228h18.55c.264 0 .428.286.296.514z"></path></svg>

After

Width:  |  Height:  |  Size: 548 B

@@ -0,0 +1 @@
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>DeepSeek</title><path d="M23.748 4.482c-.254-.124-.364.113-.512.234-.051.039-.094.09-.137.136-.372.397-.806.657-1.373.626-.829-.046-1.537.214-2.163.848-.133-.782-.575-1.248-1.247-1.548-.352-.156-.708-.311-.955-.65-.172-.241-.219-.51-.305-.774-.055-.16-.11-.323-.293-.35-.2-.031-.278.136-.356.276-.313.572-.434 1.202-.422 1.84.027 1.436.633 2.58 1.838 3.393.137.093.172.187.129.323-.082.28-.18.552-.266.833-.055.179-.137.217-.329.14a5.526 5.526 0 01-1.736-1.18c-.857-.828-1.631-1.742-2.597-2.458a11.365 11.365 0 00-.689-.471c-.985-.957.13-1.743.388-1.836.27-.098.093-.432-.779-.428-.872.004-1.67.295-2.687.684a3.055 3.055 0 01-.465.137 9.597 9.597 0 00-2.883-.102c-1.885.21-3.39 1.102-4.497 2.623C.082 8.606-.231 10.684.152 12.85c.403 2.284 1.569 4.175 3.36 5.653 1.858 1.533 3.997 2.284 6.438 2.14 1.482-.085 3.133-.284 4.994-1.86.47.234.962.327 1.78.397.63.059 1.236-.03 1.705-.128.735-.156.684-.837.419-.961-2.155-1.004-1.682-.595-2.113-.926 1.096-1.296 2.746-2.642 3.392-7.003.05-.347.007-.565 0-.845-.004-.17.035-.237.23-.256a4.173 4.173 0 001.545-.475c1.396-.763 1.96-2.015 2.093-3.517.02-.23-.004-.467-.247-.588zM11.581 18c-2.089-1.642-3.102-2.183-3.52-2.16-.392.024-.321.471-.235.763.09.288.207.486.371.739.114.167.192.416-.113.603-.673.416-1.842-.14-1.897-.167-1.361-.802-2.5-1.86-3.301-3.307-.774-1.393-1.224-2.887-1.298-4.482-.02-.386.093-.522.477-.592a4.696 4.696 0 011.529-.039c2.132.312 3.946 1.265 5.468 2.774.868.86 1.525 1.887 2.202 2.891.72 1.066 1.494 2.082 2.48 2.914.348.292.625.514.891.677-.802.09-2.14.11-3.054-.614zm1-6.44a.306.306 0 01.415-.287.302.302 0 01.2.288.306.306 0 01-.31.307.303.303 0 01-.304-.308zm3.11 1.596c-.2.081-.399.151-.59.16a1.245 1.245 0 01-.798-.254c-.274-.23-.47-.358-.552-.758a1.73 1.73 0 01.016-.588c.07-.327-.008-.537-.239-.727-.187-.156-.426-.199-.688-.199a.559.559 0 01-.254-.078c-.11-.054-.2-.19-.114-.358.028-.054.16-.186.192-.21.356-.202.767-.136 1.146.016.352.144.618.408 1.001.782.391.451.462.576.685.914.176.265.336.537.445.848.067.195-.019.354-.25.452z" fill="#4D6BFE"></path></svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

@@ -0,0 +1 @@
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Gemini CLI</title><path d="M0 4.391A4.391 4.391 0 014.391 0h15.217A4.391 4.391 0 0124 4.391v15.217A4.391 4.391 0 0119.608 24H4.391A4.391 4.391 0 010 19.608V4.391z" fill="url(#lobe-icons-gemini-cli-_R_0_)"></path><path clip-rule="evenodd" d="M19.74 1.444a2.816 2.816 0 012.816 2.816v15.48a2.816 2.816 0 01-2.816 2.816H4.26a2.816 2.816 0 01-2.816-2.816V4.26A2.816 2.816 0 014.26 1.444h15.48zM7.236 8.564l7.752 3.728-7.752 3.727v2.802l9.557-4.596v-3.866L7.236 5.763v2.801z" fill="#1E1E2E" fill-rule="evenodd"></path><defs><linearGradient gradientUnits="userSpaceOnUse" id="lobe-icons-gemini-cli-_R_0_" x1="24" x2="0" y1="6.587" y2="16.494"><stop stop-color="#EE4D5D"></stop><stop offset=".328" stop-color="#B381DD"></stop><stop offset=".476" stop-color="#207CFE"></stop></linearGradient></defs></svg>

After

Width:  |  Height:  |  Size: 922 B

@@ -0,0 +1 @@
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>GithubCopilot</title><path d="M19.245 5.364c1.322 1.36 1.877 3.216 2.11 5.817.622 0 1.2.135 1.592.654l.73.964c.21.278.323.61.323.955v2.62c0 .339-.173.669-.453.868C20.239 19.602 16.157 21.5 12 21.5c-4.6 0-9.205-2.583-11.547-4.258-.28-.2-.452-.53-.453-.868v-2.62c0-.345.113-.679.321-.956l.73-.963c.392-.517.974-.654 1.593-.654l.029-.297c.25-2.446.81-4.213 2.082-5.52 2.461-2.54 5.71-2.851 7.146-2.864h.198c1.436.013 4.685.323 7.146 2.864zm-7.244 4.328c-.284 0-.613.016-.962.05-.123.447-.305.85-.57 1.108-1.05 1.023-2.316 1.18-2.994 1.18-.638 0-1.306-.13-1.851-.464-.516.165-1.012.403-1.044.996a65.882 65.882 0 00-.063 2.884l-.002.48c-.002.563-.005 1.126-.013 1.69.002.326.204.63.51.765 2.482 1.102 4.83 1.657 6.99 1.657 2.156 0 4.504-.555 6.985-1.657a.854.854 0 00.51-.766c.03-1.682.006-3.372-.076-5.053-.031-.596-.528-.83-1.046-.996-.546.333-1.212.464-1.85.464-.677 0-1.942-.157-2.993-1.18-.266-.258-.447-.661-.57-1.108-.32-.032-.64-.049-.96-.05zm-2.525 4.013c.539 0 .976.426.976.95v1.753c0 .525-.437.95-.976.95a.964.964 0 01-.976-.95v-1.752c0-.525.437-.951.976-.951zm5 0c.539 0 .976.426.976.95v1.753c0 .525-.437.95-.976.95a.964.964 0 01-.976-.95v-1.752c0-.525.437-.951.976-.951zM7.635 5.087c-1.05.102-1.935.438-2.385.906-.975 1.037-.765 3.668-.21 4.224.405.394 1.17.657 1.995.657h.09c.649-.013 1.785-.176 2.73-1.11.435-.41.705-1.433.675-2.47-.03-.834-.27-1.52-.63-1.813-.39-.336-1.275-.482-2.265-.394zm6.465.394c-.36.292-.6.98-.63 1.813-.03 1.037.24 2.06.675 2.47.968.957 2.136 1.104 2.776 1.11h.044c.825 0 1.59-.263 1.995-.657.555-.556.765-3.187-.21-4.224-.45-.468-1.335-.804-2.385-.906-.99-.088-1.875.058-2.265.394zM12 7.615c-.24 0-.525.015-.84.044.03.16.045.336.06.526l-.001.159a2.94 2.94 0 01-.014.25c.225-.022.425-.027.612-.028h.366c.187 0 .387.006.612.028-.015-.146-.015-.277-.015-.409.015-.19.03-.365.06-.526a9.29 9.29 0 00-.84-.044z"></path></svg>

After

Width:  |  Height:  |  Size: 2.0 KiB

@@ -0,0 +1 @@
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>opencode</title><path d="M16 6H8v12h8V6zm4 16H4V2h16v20z"></path></svg>

After

Width:  |  Height:  |  Size: 236 B

+1
View File
@@ -0,0 +1 @@
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Pi</title><path clip-rule="evenodd" d="M1 1h16.5v11H12v5.5H6.5V23H1V1zm5.5 5.5V12H12V6.5H6.5z"></path><path d="M17.5 12H23v11h-5.5V12z"></path></svg>

After

Width:  |  Height:  |  Size: 314 B

+1
View File
@@ -0,0 +1 @@
<svg fill="currentColor" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Qoder</title><path d="M23.376 14.458v-4.056c0-2.304-1.003-4.154-2.748-5.075L11.612.574l-.046.086-.045.086c1.68.886 2.644 2.673 2.644 4.902v4.056a7.928 7.928 0 01-.014.454l-.005.061c-.005.081-.01.164-.018.245a4.897 4.897 0 01-.011.1l-.01.076c-.008.068-.015.135-.025.203l-.018.113-.01.058a9.99 9.99 0 01-.098.513l-.007.03a7.209 7.209 0 01-.074.294l-.024.086c-.027.099-.056.197-.087.296l-.027.085a9.592 9.592 0 01-.111.323l-.033.085-.018.046c-.032.082-.064.166-.098.248-.019.048-.04.096-.061.145l-.007.017a6 6 0 01-.084.187c-.024.056-.05.11-.077.165-.03.061-.058.122-.089.182a9.423 9.423 0 01-.176.332c-.03.056-.062.111-.094.167-.031.053-.062.108-.095.16-.033.055-.066.11-.101.164-.033.053-.065.104-.1.155-.034.055-.07.107-.111.169l-.099.144a15.193 15.193 0 01-.34.457c-.04.05-.08.102-.121.151l-.107.128-.007.008a6.987 6.987 0 01-.262.298l-.149.16-.116.12a9.562 9.562 0 01-.204.198l-.03.03-.072.069a9.05 9.05 0 01-.263.235l-.025.022-.029.026-.042.035a11.7 11.7 0 01-.22.18l-.07.055-.018.013a8.904 8.904 0 01-.194.146c-.029.02-.057.042-.086.063a7.7 7.7 0 01-.22.152l-.057.04a8.865 8.865 0 01-.293.185l-.062.037a10.424 10.424 0 01-.307.173l-.037.02-.196.103-.108.052-.012.006a6.196 6.196 0 01-.315.143c-.065.028-.13.054-.196.08l-.035.014-.086.034c-.07.026-.143.05-.215.075l-.039.014-.064.023a8.056 8.056 0 01-.323.097l-.63.173a7.285 7.285 0 01-.33.08l-.07.015c-.053.012-.104.023-.157.032l-.065.011-.085.015a2.332 2.332 0 01-.194.027l-.085.01a4.715 4.715 0 01-.16.018l-.034.003a4.861 4.861 0 01-.246.016h-.033a2.714 2.714 0 01-.155.005h-.106a3.384 3.384 0 01-.225-.007H4.86l-.15-.012-.066-.006a5.586 5.586 0 01-.187-.02l-.04-.005a5.14 5.14 0 01-.219-.035l-.054-.01a6.943 6.943 0 01-.347-.082l-.03-.008-.038-.01a5.034 5.034 0 01-.269-.086l-.063-.023a4.216 4.216 0 01-.188-.073l-.071-.031-.016-.007a4.959 4.959 0 01-.16-.074l-.026-.013a.164.164 0 00-.014-.007l-.671-.351.486.486h.016l8.995 4.742.093.048.03.014.02.01c.056.026.111.052.169.076l.016.008.073.032.195.076.022.008a.718.718 0 01.03.012l.014.004a4.693 4.693 0 00.323.1l.027.007c.073.02.147.038.22.055l.018.004.027.006.066.013.088.016c.075.014.15.026.226.038l.042.004c.064.009.128.016.193.022l.126.012.06.003.05.002.098.005c.046.002.094.002.14.003h.12c.05 0 .1-.002.161-.005h.033l.07-.004a6.17 6.17 0 00.184-.014l.033-.003a.753.753 0 00.058-.005l.108-.012.081-.01.08-.01.129-.021.082-.014.071-.012c.054-.01.107-.021.16-.033l.066-.013.059-.013c.096-.022.192-.046.287-.073l.63-.172a7.354 7.354 0 00.397-.122l.04-.015c.075-.025.149-.051.222-.078.032-.011.062-.024.093-.037l.03-.012c.067-.027.135-.054.2-.082l.128-.056.195-.09.021-.01.102-.05c.068-.034.135-.069.202-.105l.037-.02.073-.038c.08-.044.16-.092.24-.139l.026-.015a8.086 8.086 0 00.322-.2l.065-.045 1.98.902a1.748 1.748 0 002.472-1.59v-7.33l.004.004z" fill="#2ADB5C"></path><path d="M11.617.576a3.904 3.904 0 00-.093-.047c-.016-.009-.033-.016-.05-.024a5.854 5.854 0 00-.166-.077l-.09-.04a4.094 4.094 0 00-.194-.074c-.017-.006-.035-.015-.053-.02l-.013-.005a5.18 5.18 0 00-.277-.088l-.07-.019a4.034 4.034 0 00-.219-.053c-.015-.003-.03-.008-.044-.012L10.253.1l-.057-.011a5.177 5.177 0 00-.225-.036L9.928.047a5.972 5.972 0 00-.191-.022L9.669.02a1.33 1.33 0 00-.058-.005L9.515.009a4.058 4.058 0 00-.111-.005C9.354 0 9.304 0 9.254 0h-.109c-.052 0-.106.004-.16.004L8.884.01A5.32 5.32 0 008.7.022l-.083.006h-.008c-.05.005-.1.013-.15.019l-.12.014c-.056.008-.112.018-.169.028-.037.006-.074.011-.11.018a7.054 7.054 0 00-.572.133l-.63.172c-.111.031-.22.064-.33.1-.037.011-.072.025-.108.037a12.91 12.91 0 00-.345.126c-.067.027-.133.054-.2.083l-.128.055a11.916 11.916 0 00-.318.15 7.376 7.376 0 00-.311.163c-.082.045-.16.092-.24.14a9.424 9.424 0 00-.35.218l-.016.01-.06.04a9.242 9.242 0 00-.51.37 12.54 12.54 0 00-.315.254l-.043.034-.01.007-.045.041c-.09.078-.18.158-.268.24l-.106.101c-.07.067-.139.135-.207.204l-.056.054-.063.067-.153.164-.12.133-.148.17c-.024.03-.049.057-.073.086l-.043.053a5.96 5.96 0 00-.123.155l-.118.151c-.04.053-.08.106-.118.16l-.075.101-.038.056-.107.155-.11.164a9.91 9.91 0 00-.168.265c-.012.02-.023.043-.037.063a12.43 12.43 0 00-.192.335l-.09.168c-.019.034-.04.07-.057.105-.011.022-.02.043-.032.065l-.092.188-.08.167a19.15 19.15 0 00-.083.192c-.017.038-.035.076-.05.115-.008.016-.014.034-.021.051-.035.083-.067.167-.1.253l-.051.134c-.04.11-.077.22-.113.33l-.02.057c0 .002 0 .005-.002.007l-.008.026c-.032.1-.06.2-.09.301l-.023.089c-.027.1-.052.2-.075.301l-.007.03a7.63 7.63 0 00-.057.267l-.008.048c-.015.074-.026.148-.038.223L.082 8.4c-.011.078-.02.156-.029.234-.006.051-.013.103-.017.154a6.57 6.57 0 00-.02.26c-.003.044-.007.086-.009.13-.004.128-.007.257-.007.386v4.056c0 1.478.42 2.741 1.138 3.692A4.75 4.75 0 002.73 18.67l9.015 4.753c-1.656-.874-2.728-2.685-2.73-5.051v-4.056c0-.13.004-.26.01-.39.002-.043.006-.085.01-.128.005-.088.01-.174.019-.261l.017-.155c.01-.077.018-.155.029-.234l.026-.164c.013-.074.025-.15.039-.223.02-.105.04-.Line truncated

After

Width:  |  Height:  |  Size: 6.1 KiB

+1
View File
@@ -0,0 +1 @@
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>TRAE</title><path d="M24 20.541H3.428v-3.426H0V3.4h24V20.54zM3.428 17.115h17.144V6.827H3.428v10.288zm8.573-5.196l-2.425 2.424-2.424-2.424 2.424-2.424 2.425 2.424zm6.857-.001l-2.424 2.423-2.425-2.423 2.425-2.425 2.424 2.425z" fill="#32F08C"></path></svg>

After

Width:  |  Height:  |  Size: 378 B

@@ -0,0 +1 @@
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Windsurf</title><path clip-rule="evenodd" d="M23.78 5.004h-.228a2.187 2.187 0 00-2.18 2.196v4.912c0 .98-.804 1.775-1.76 1.775a1.818 1.818 0 01-1.472-.773L13.168 5.95a2.197 2.197 0 00-1.81-.95c-1.134 0-2.154.972-2.154 2.173v4.94c0 .98-.797 1.775-1.76 1.775-.57 0-1.136-.289-1.472-.773L.408 5.098C.282 4.918 0 5.007 0 5.228v4.284c0 .216.066.426.188.604l5.475 7.889c.324.466.8.812 1.351.938 1.377.316 2.645-.754 2.645-2.117V11.89c0-.98.787-1.775 1.76-1.775h.002c.586 0 1.135.288 1.472.773l4.972 7.163a2.15 2.15 0 001.81.95c1.158 0 2.151-.973 2.151-2.173v-4.939c0-.98.787-1.775 1.76-1.775h.194c.122 0 .22-.1.22-.222V5.225a.221.221 0 00-.22-.222z"></path></svg>

After

Width:  |  Height:  |  Size: 821 B

+1
View File
@@ -0,0 +1 @@
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Z.ai</title><path d="M12.105 2L9.927 4.953H.653L2.83 2h9.276zM23.254 19.048L21.078 22h-9.242l2.174-2.952h9.244zM24 2L9.264 22H0L14.736 2H24z"></path></svg>

After

Width:  |  Height:  |  Size: 320 B

@@ -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<String, dynamic>? json) =>
json == null ? ApiServerConfig() : _$ApiServerConfigFromJson(json);
@@ -39,6 +39,7 @@ Map<String, dynamic> _$DownloaderConfigToJson(DownloaderConfig instance) => <Str
ApiServerConfig _$ApiServerConfigFromJson(Map<String, dynamic> 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<String, dynamic> json) => ApiServe
Map<String, dynamic> _$ApiServerConfigToJson(ApiServerConfig instance) => <String, dynamic>{
'enable': instance.enable,
'mcpEnable': instance.mcpEnable,
'network': instance.network,
'address': instance.address,
'token': instance.token,
@@ -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<AppRuntimeState> {
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<AppRuntimeState> {
var runningPort = 0;
var apiServerState = const ApiServerState(
enabled: false,
mcpEnabled: false,
running: false,
network: '',
address: '',
@@ -141,7 +154,7 @@ class AppRuntimeController extends AsyncNotifier<AppRuntimeState> {
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<AppRuntimeState> {
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<AppRuntimeState> {
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<AppRuntimeState> {
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<AppRuntimeState> {
..network = config.network
..address = config.address
..apiEnable = config.apiEnable
..mcpEnable = config.mcpEnable
..storage = config.storage
..storageDir = config.storageDir
..refreshInterval = config.refreshInterval
@@ -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<String, dynamic> 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: '',
@@ -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;
@@ -23,6 +23,7 @@ StartConfig _$StartConfigFromJson(Map<String, dynamic> 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<String, dynamic> _$StartConfigToJson(StartConfig instance) =>
'network': instance.network,
'address': instance.address,
'apiEnable': instance.apiEnable,
'mcpEnable': instance.mcpEnable,
'storage': instance.storage,
'storageDir': instance.storageDir,
'refreshInterval': instance.refreshInterval,
@@ -43,6 +43,7 @@ class LibgopeedBootBrowser implements LibgopeedBoot {
const _unsupportedApiServerResult = ApiServerOperationResult(
state: ApiServerState(
enabled: false,
mcpEnabled: false,
running: false,
network: '',
address: '',
@@ -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),
@@ -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<dynamic> request(String path, {String method = 'GET', dynamic data, Map<String, dynamic>? queryParameters});
@@ -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;
}
@@ -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),
@@ -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)),
],
),
);
}
}
@@ -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<SettingsPage> {
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<SettingsPage> {
bool _syncingControllers = false;
bool _syncingStartControllers = false;
bool _savingStartConfig = false;
String? _apiNetworkDraft;
List<TextEditingController> get _textControllers => [
_downloadDirController,
@@ -105,7 +111,6 @@ class _SettingsPageState extends ConsumerState<SettingsPage> {
];
List<TextEditingController> get _startTextControllers => [
_apiAddressController,
_apiHostController,
_apiPortController,
_apiTokenController,
@@ -149,7 +154,9 @@ class _SettingsPageState extends ConsumerState<SettingsPage> {
_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<SettingsPage> {
) {
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<SettingsPage> {
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<SettingsPage> {
],
),
),
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<String>(
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<SettingsPage> {
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<SettingsPage> {
_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<SettingsPage> {
_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<SettingsPage> {
}
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<SettingsPage> {
Future<String?> _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<SettingsPage> {
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<void> _runAction(Future<void> Function() action) async {
try {
await action();
@@ -1422,15 +1415,12 @@ class _SettingsPageState extends ConsumerState<SettingsPage> {
}
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<SettingsPage> {
..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<SettingsPage> {
..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<SettingsPage> {
}
}
Future<void> _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<SettingsPage> {
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 = <String>[];
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<SettingsPage> {
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),
],
),
),
],
);
@@ -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),
],
),
),
),
],
@@ -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<bool> 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<void> 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<void>(
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 ? '<API_TOKEN>' : '';
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 <String, Object>{'disabled': false, 'autoApprove': <Object>[]},
),
_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 <String, Object>{'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<String, Object> extra = const <String, Object>{},
}) {
final server = <String, Object>{};
if (type != null) {
server['type'] = type;
}
server[urlKey] = endpoint;
if (apiToken.isNotEmpty) {
server['headers'] = <String, String>{'Authorization': 'Bearer $apiToken'};
}
server.addAll(extra);
return const JsonEncoder.withIndent(' ').convert(<String, Object>{
rootKey: <String, Object>{'gopeed': server},
});
}
String _openCodeSnippet({required String endpoint, required String apiToken}) {
final server = <String, Object>{'type': 'remote', 'url': endpoint, 'oauth': false};
if (apiToken.isNotEmpty) {
server['headers'] = <String, String>{'Authorization': 'Bearer $apiToken'};
}
return const JsonEncoder.withIndent(' ').convert(<String, Object>{
r'$schema': 'https://opencode.ai/config.json',
'mcp': <String, Object>{
'servers': <String, Object>{'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("'", "'\"'\"'")}'";
@@ -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,
],
),
],
],
);
@@ -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<GithubMirrorDraft?> 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),
],
),
),
],
),
@@ -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<TaskDetailsView> {
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<TaskDetailsView> {
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<TaskDetailsView> {
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<TaskDetailsView> {
_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,
],
),
),
+10 -1
View File
@@ -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."
}
+10 -1
View File
@@ -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."
}
+10 -1
View File
@@ -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."
}
+10 -1
View File
@@ -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."
}
+10 -1
View File
@@ -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."
}
+10 -1
View File
@@ -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 باشد."
}
+10 -1
View File
@@ -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."
}
+10 -1
View File
@@ -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."
}
+10 -1
View File
@@ -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."
}
+10 -1
View File
@@ -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."
}
+10 -1
View File
@@ -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 の場合にエージェント接続手順を利用できます。"
}
+10 -1
View File
@@ -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일 때 에이전트 연결 안내를 사용할 수 있습니다."
}
+10 -1
View File
@@ -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."
}
+10 -1
View File
@@ -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."
}
+10 -1
View File
@@ -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."
}
+10 -1
View File
@@ -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 ஆக இருக்கும்போது முகவர் இணைப்பு வழிமுறைகள் கிடைக்கும்."
}
+10 -1
View File
@@ -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."
}
+10 -1
View File
@@ -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."
}
+10 -1
View File
@@ -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."
}
+10 -1
View File
@@ -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 接入指引。"
}
+10 -1
View File
@@ -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 接入指引。"
}
@@ -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<AppCopyIconButton> createState() => _AppCopyIconButtonState();
}
class _AppCopyIconButtonState extends State<AppCopyIconButton> {
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<void> _copy() async {
await Clipboard.setData(ClipboardData(text: widget.text));
if (mounted) {
setState(() => _copied = true);
}
}
}
@@ -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),
),
};
}
@@ -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,
),
);
}
+40
View File
@@ -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:
+2
View File
@@ -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:
@@ -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');
@@ -75,6 +75,7 @@ class _TestRuntimeController extends AppRuntimeController {
startConfig: StartConfig(),
apiServerState: const ApiServerState(
enabled: false,
mcpEnabled: false,
running: false,
network: '',
address: '',
@@ -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<shad.SecondaryButton>(find.descendant(of: secondary, matching: find.byType(shad.SecondaryButton)))
.leading,
isNull,
);
expect(
tester
.widget<shad.PrimaryButton>(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);
+388 -8
View File
@@ -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<Object?, Object?>)['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<shad.TextField>(host).controller!.text, '192.168.1.20');
expect(tester.widget<shad.TextField>(port).controller!.text, '4321');
expect(tester.widget<AppLoadingButton>(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<AppCopyIconButton>());
expect(find.descendant(of: copySnippet, matching: find.byIcon(Icons.copy_outlined)), findsOneWidget);
expect(
tester.widget<AppTooltip>(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<AppTooltip>(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<AppTooltip>(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<AppLoadingButton>(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<AppLoadingButton>(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<shad.Switch>(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('<API_TOKEN>'), 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='<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('<API_TOKEN>'), 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<shad.TextField>(hostInput).controller!.text, 'unsaved.example.com');
final statusDot = tester.widget<AnimatedContainer>(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<RenderParagraph>(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 = <int>[];
@@ -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<Object?, Object?>)['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<AppCopyIconButton>());
expect(find.descendant(of: copyAction, matching: find.byIcon(Icons.copy_outlined)), findsOneWidget);
expect(
tester.widget<AppTooltip>(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<AppTooltip>(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<AppTooltip>(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 : '',
+4 -4
View File
@@ -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: