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

+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 : '',