feat(desktop): unify skills and connectors with managed installation and mentions

This commit is contained in:
程序员阿江(Relakkes)
2026-09-14 10:14:15 +08:00
parent 57feabde05
commit c4a4178f18
154 changed files with 8705 additions and 721 deletions
+55
View File
@@ -0,0 +1,55 @@
Service brand marks identify the corresponding services; no endorsement implied.
Source: WorkBuddy public connector catalog, https://acc-1258344699.cos.ap-guangzhou.myqcloud.com/connectors-config-v2/connectors-config.zip (icons/feishu.svg, icons/dingtalk.svg, icons/wecom.svg), retrieved 2026-09-13.
Additional service marks, retrieved 2026-09-13:
- github: https://acc-1258344699.cos.ap-guangzhou.myqcloud.com/connectors-config-v2/connectors-config.zip (icons/github.svg)
- notion: https://acc-1258344699.cos.ap-guangzhou.myqcloud.com/connectors-config-v2/connectors-config.zip (icons/notion.svg)
- canva: https://acc-1258344699.cos.ap-guangzhou.myqcloud.com/connectors-config-v2/connectors-config.zip (icons/canva.png)
- tencent-maps: https://acc-1258344699.cos.ap-guangzhou.myqcloud.com/connectors-config-v2/connectors-config.zip (icons/tencent-map.png)
- tencent-docs: https://acc-1258344699.cos.ap-guangzhou.myqcloud.com/connectors-config-v2/connectors-config.zip (icons/tencent-docs.png)
- datayes: https://acc-1258344699.cos.ap-guangzhou.myqcloud.com/connectors-config-v2/connectors-config.zip (icons/datayes-data.png)
- pkulaw: https://acc-1258344699.cos.ap-guangzhou.myqcloud.com/connectors-config-v2/connectors-config.zip (icons/pkulaw.svg)
- jinshuju: https://acc-1258344699.cos.ap-guangzhou.myqcloud.com/connectors-config-v2/connectors-config.zip (icons/jinshuju.svg)
- linear: https://registry.npmjs.org/simple-icons/-/simple-icons-16.31.0.tgz (icons/linear.svg)
- supabase: https://registry.npmjs.org/simple-icons/-/simple-icons-16.31.0.tgz (icons/supabase.svg)
- sentry: https://registry.npmjs.org/simple-icons/-/simple-icons-16.31.0.tgz (icons/sentry.svg)
- stripe: https://registry.npmjs.org/simple-icons/-/simple-icons-16.31.0.tgz (icons/stripe.svg)
- figma: https://registry.npmjs.org/simple-icons/-/simple-icons-16.31.0.tgz (icons/figma.svg)
- huggingface: https://registry.npmjs.org/simple-icons/-/simple-icons-16.31.0.tgz (icons/huggingface.svg)
- baidu-maps: https://registry.npmjs.org/simple-icons/-/simple-icons-16.31.0.tgz (icons/baidu.svg)
- amap: https://a.amap.com/pc/static/favicon.ico
- tavily: https://www.tavily.com/icon.svg?icon.0v6b1ltlljvdd.svg
- exa: https://exa.ai/images/favicon-32x32.png
- context7: https://context7.com/favicon.ico
- zhipu-search: https://bigmodel.cn/static/images/favicon.png
Simple Icons assets use CC0; service trademarks remain their owners' marks.
- tencent-search: https://cloudcache.tencent-cloud.com/qcloud/favicon.ico?t=201902181234 (Tencent Cloud product family icon)
Dark/monochrome marks have a light backplate so the original mark remains legible in dark themes.
2026-09-14 global service marks: neon, cloudflare, firecrawl, clickup, miro, postman, render, airtable, atlassian, webflow, todoist, intercom. Source: https://registry.npmjs.org/simple-icons/-/simple-icons-16.31.0.tgz (icons/; CC0, trademarks belong to service owners).
Firecrawl exception: official favicon https://www.firecrawl.dev/favicon.png (not in Simple Icons).
### 国内目录补充(2026-09-14)
下列图片均取自对应供应商官网标识。ICO/PNG 通过系统 sips 转为最大 128px PNG,再以本地 SVG 的 data:image/png 包装,避免远程加载。ListingGood 保留官网内联 favicon SVG;飞常准保留官方 SVG 路径并去除 XML/外部 DTD 声明。仅为产品识别,不表示供应商合作或背书。
| 文件 | 官方图片来源 / 发现页 |
| --- | --- |
| qingflow.svg | https://qingflow.com/favicon.ico |
| qcc.svg | https://agent.qcc.com/favicon-qcc.png (官网 /guide 的 icon 链接) |
| xmind.svg | https://framerusercontent.com/images/PvPQXjvou7vNNpBBUrFlOgF4MCI.png (https://xmind.cn 的 favicon 链接) |
| kuaicha.svg | https://s.thsi.cn/cd/kc-menhu-pc/public/img/logo.png (快查开放平台官网品牌图) |
| qveris.svg | https://qveris.cn/apple-touch-icon.png |
| listinggood.svg | https://listinggood.cn/ 的内联 data:image/svg+xml favicon |
| bazhuayu.svg | https://www.bazhuayu.com/favicon.ico?v1 |
| variflight.svg | https://static.variflight.com/assets/mcp/assets/variflight-logo.svg (官方 MCP 页 favicon) |
| jufa.svg | https://www.jufaai.com/favicon.ico |
| sorftime.svg | https://www.sorftime.com/content/home/img/favicon.ico?v=2.9.9 |
2026-09-14 independent skill tool icons:
- hyperframes: official HeyGen source, https://raw.githubusercontent.com/heygen-com/hyperframes/60780774bb1896373947fb3e3197117501f53a22/docs/logo/symbol-light.svg
- drawio: official draw.io source, https://raw.githubusercontent.com/jgraph/drawio-mcp/14b318b19cc37b159f841227b9d11fbd18ce18ea/plugins/codex/drawio/assets/drawio-logo.svg
- obsidian: https://registry.npmjs.org/simple-icons/-/simple-icons-16.31.0.tgz (icons/obsidian.svg; CC0 silhouette, purple presentation fill).
- frontend-design, canvas-design, algorithmic-art, webapp-testing, mcp-builder: original neutral tool glyphs created for this application. These are category illustrations, not Anthropic brand marks.
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48"><rect width="48" height="48" rx="10" fill="white"/><svg x="6" y="6" width="36" height="36" viewBox="0 0 24 24" fill="#18BFFF"><path d="M11.992 1.966c-.434 0-.87.086-1.28.257L1.779 5.917c-.503.208-.49.908.012 1.116l8.982 3.558a3.266 3.266 0 0 0 2.454 0l8.982-3.558c.503-.196.503-.908.012-1.116l-8.957-3.694a3.255 3.255 0 0 0-1.272-.257zM23.4 8.056a.589.589 0 0 0-.222.045l-10.012 3.877a.612.612 0 0 0-.38.564v8.896a.6.6 0 0 0 .821.552L23.62 18.1a.583.583 0 0 0 .38-.551V8.653a.6.6 0 0 0-.6-.596zM.676 8.095a.644.644 0 0 0-.48.19C.086 8.396 0 8.53 0 8.69v8.355c0 .442.515.737.908.54l6.27-3.006.307-.147 2.969-1.436c.466-.22.43-.908-.061-1.092L.883 8.138a.57.57 0 0 0-.207-.044z"/></svg></svg>

After

Width:  |  Height:  |  Size: 750 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><rect width="64" height="64" rx="14" fill="#f5f3ff"/><g fill="none" stroke="#7c3aed" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M14 32c0-15 36-15 36 0s-36 15-36 0Z"/><path d="M23 16c13-8 31 23 18 31S10 24 23 16Z"/><path d="M41 16C28 8 10 39 23 47s31-23 18-31Z"/><circle cx="32" cy="32" r="3"/></g></svg>

After

Width:  |  Height:  |  Size: 396 B

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.7 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48"><rect width="48" height="48" rx="10" fill="white"/><svg x="6" y="6" width="36" height="36" viewBox="0 0 24 24" fill="#0052CC"><path d="M7.12 11.084a.683.683 0 00-1.16.126L.075 22.974a.703.703 0 00.63 1.018h8.19a.678.678 0 00.63-.39c1.767-3.65.696-9.203-2.406-12.52zM11.434.386a15.515 15.515 0 00-.906 15.317l3.95 7.9a.703.703 0 00.628.388h8.19a.703.703 0 00.63-1.017L12.63.38a.664.664 0 00-1.196.006z"/></svg></svg>

After

Width:  |  Height:  |  Size: 475 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns:ns0="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48"><rect width="48" height="48" rx="8" fill="#fff" /><ns0:svg fill="#2932E1" role="img" viewBox="0 0 24 24" x="4" y="4" width="40" height="40"><ns0:title>Baidu</ns0:title><ns0:path d="M9.154 0C7.71 0 6.54 1.658 6.54 3.707c0 2.051 1.171 3.71 2.615 3.71 1.446 0 2.614-1.659 2.614-3.71C11.768 1.658 10.6 0 9.154 0zm7.025.594C14.86.58 13.347 2.589 13.2 3.927c-.187 1.745.25 3.487 2.179 3.735 1.933.25 3.175-1.806 3.422-3.364.252-1.555-.995-3.364-2.362-3.674a1.218 1.218 0 0 0-.261-.03zM3.582 5.535a2.811 2.811 0 0 0-.156.008c-2.118.19-2.428 3.24-2.428 3.24-.287 1.41.686 4.425 3.297 3.864 2.617-.561 2.262-3.68 2.183-4.362-.125-1.018-1.292-2.773-2.896-2.75zm16.534 1.753c-2.308 0-2.617 2.119-2.617 3.616 0 1.43.121 3.425 2.988 3.362 2.867-.063 2.553-3.238 2.553-3.988 0-.745-.62-2.99-2.924-2.99zm-8.264 2.478c-1.424.014-2.708.925-3.323 1.947-1.118 1.868-2.863 3.05-3.112 3.363-.25.309-3.61 2.116-2.864 5.42.746 3.301 3.365 3.237 3.365 3.237s1.93.19 4.171-.31c2.24-.495 4.17.123 4.17.123s5.233 1.748 6.665-1.616c1.43-3.364-.808-5.109-.808-5.109s-2.99-2.306-4.736-4.798c-1.072-1.665-2.348-2.268-3.528-2.257zm-2.234 3.84l1.542.024v8.197H7.758c-1.47-.291-2.055-1.292-2.13-1.462-.072-.173-.488-.976-.268-2.343.635-2.049 2.447-2.196 2.447-2.196h1.81zm3.964 2.39v3.881c.096.413.612.488.612.488h1.614v-4.343h1.689v5.782h-3.915c-1.517-.39-1.59-1.465-1.59-1.465v-4.317zm-5.458 1.147c-.66.197-.978.708-1.05.928-.076.22-.247.78-.1 1.269.294 1.095 1.248 1.144 1.248 1.144h1.37v-3.34z" /></ns0:svg></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 10 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 25 KiB

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><rect width="64" height="64" rx="14" fill="#fff1f2"/><g fill="none" stroke="#e11d48" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><rect x="16" y="12" width="32" height="40" rx="4"/><circle cx="27" cy="24" r="4"/><path d="m20 43 9-10 6 6 5-5 4 9M23 48h18"/></g></svg>

After

Width:  |  Height:  |  Size: 348 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48"><rect width="48" height="48" rx="10" fill="white"/><svg x="6" y="6" width="36" height="36" viewBox="0 0 24 24" fill="#7B68EE"><path d="M2 18.439l3.69-2.828c1.961 2.56 4.044 3.739 6.363 3.739 2.307 0 4.33-1.166 6.203-3.704L22 18.405C19.298 22.065 15.941 24 12.053 24 8.178 24 4.788 22.078 2 18.439zM12.04 6.15l-6.568 5.66-3.036-3.52L12.055 0l9.543 8.296-3.05 3.509z"/></svg></svg>

After

Width:  |  Height:  |  Size: 439 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48"><rect width="48" height="48" rx="10" fill="white"/><svg x="6" y="6" width="36" height="36" viewBox="0 0 24 24" fill="#F38020"><path d="M16.5088 16.8447c.1475-.5068.0908-.9707-.1553-1.3154-.2246-.3164-.6045-.499-1.0615-.5205l-8.6592-.1123a.1559.1559 0 0 1-.1333-.0713c-.0283-.042-.0351-.0986-.021-.1553.0278-.084.1123-.1484.2036-.1562l8.7359-.1123c1.0351-.0489 2.1601-.8868 2.5537-1.9136l.499-1.3013c.0215-.0561.0293-.1128.0147-.168-.5625-2.5463-2.835-4.4453-5.5499-4.4453-2.5039 0-4.6284 1.6177-5.3876 3.8614-.4927-.3658-1.1187-.5625-1.794-.499-1.2026.119-2.1665 1.083-2.2861 2.2856-.0283.31-.0069.6128.0635.894C1.5683 13.171 0 14.7754 0 16.752c0 .1748.0142.3515.0352.5273.0141.083.0844.1475.1689.1475h15.9814c.0909 0 .1758-.0645.2032-.1553l.12-.4268zm2.7568-5.5634c-.0771 0-.1611 0-.2383.0112-.0566 0-.1054.0415-.127.0976l-.3378 1.1744c-.1475.5068-.0918.9707.1543 1.3164.2256.3164.6055.498 1.0625.5195l1.8437.1133c.0557 0 .1055.0263.1329.0703.0283.043.0351.1074.0214.1562-.0283.084-.1132.1485-.204.1553l-1.921.1123c-1.041.0488-2.1582.8867-2.5527 1.914l-.1406.3585c-.0283.0713.0215.1416.0986.1416h6.5977c.0771 0 .1474-.0489.169-.126.1122-.4082.1757-.837.1757-1.2803 0-2.6025-2.125-4.727-4.7344-4.727"/></svg></svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns:ns0="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48"><rect width="48" height="48" rx="8" fill="#fff" /><ns0:svg viewBox="0 0 48 48" x="4" y="4" width="40" height="40"><ns0:image width="48" height="48" href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEoAAABLCAYAAADXjBHUAAABY2lDQ1BrQ0dDb2xvclNwYWNlRGlzcGxheVAzAAAokX2QsUvDUBDGv1aloHUQHRwcMolDlJIKuji0FURxCFXB6pS+pqmQxkeSIgU3/4GC/4EKzm4Whzo6OAiik+jm5KTgouV5L4mkInqP435877vjOCA5bnBu9wOoO75bXMorm6UtJfWMBL0gDObxnK6vSv6uP+P9PvTeTstZv///jcGK6TGqn5QZxl0fSKjE+p7PJe8Tj7m0FHFLshXyieRyyOeBZ71YIL4mVljNqBC/EKvlHt3q4brdYNEOcvu06WysyTmUE1jEDjxw2DDQhAId2T/8s4G/gF1yN+FSn4UafOrJkSInmMTLcMAwA5VYQ4ZSk3eO7ncX3U+NtYMnYKEjhLiItZUOcDZHJ2vH2tQ8MDIEXLW54RqB1EeZrFaB11NguASM3lDPtlfNauH26Tww8CjE2ySQOgS6LSE+joToHlPzA3DpfAEDp2ITpJYOWwAAAARjSUNQDA0AAW4D4+8AAABsZVhJZk1NACoAAAAIAAQBGgAFAAAAAQAAAD4BGwAFAAAAAQAAAEYBKAADAAAAAQACAACHaQAEAAAAAQAAAE4AAAAAAAAASAAAAAEAAABIAAAAAQACoAIABAAAAAEAAABKoAMABAAAAAEAAABLAAAAAHCeeAcAAAAJcEhZcwAACxMAAAsTAQCanBgAAATESURBVHgB7Zs9TBRBFMcXQwFYcBg1gYbTEhMwiI0mgJFGI7EAY0POAjWhoYGCBgqszwIKCrEA7DwKCdHmjEKkExMKKZVrINFEaSB0On/MnbtvB+7t7hsM8l7DztzbeTO/ffP1ZqioHLn/zlMpS6DSaHSW1VIF75Qy4BFQUDxO6lFMTgpKQXEJMPV0jFJQTAJMNfUoBcUkwFRTj1JQTAJMNfUoJihsihNJx4Umb/reQOQylr+se/25qUjv5fqGvZaGxkjvQLl3LuutbW3gMbYkBgXL6bpzkStQiPFOqromli28l1S06zEJKigmqMRdb22r4G38/B7oEgWTnlldOrQK0Ikqs5+WvCUzth0mmSsdobrEsUVtJAa1vbfjPTSDcv7RWKnsRjP+bO/tepMrr0t5Eg+zZeCP3uwNQILNnhfZ/Q+Z1L5I18NXHlqcDdTl6Z2M13GxKZDnMtHSkPbGunoDJp68zXlrmxuBvLgJEVAwPmG8h3aL570DXqrqdNy6sd+DjVzfUED/1fpHbzyfC+QlSYiBQiWwLkKXKwq64GhXTzHp7C9s0CXK8OKMqD1RUBg0Jz4Ex6XB67e9lvq0aKX9heFjwIZf0OUwwUiKKChUbHLlTcCrkJc145UrGTMDOJVygz7V56TFQWEWnF19H7CNQd2FV8GbsBzwCyBJexPKFweFQhfMQEol09pOsxKnrd5k1louxAmotc1CqPtl2jrF699uWX7QmVfKqBNQ6H6FH98CdUxV1YiuqxC1oDOdK0hoiBNQKBhbGyrNgrNfs1lgUpHYqtAyi2lnoGwD6uX66LGkYkXpX3gUFZtNqhM37QxUYTu8jokTdDuoYbYY07H0KFsDJbczWBocpTjzKFsjaqvl9n0pwbJsdaV5FebG3S+aqekwgSP1qLD545OjoJjfSkEpKCYBppp6FBNU4sOFg+xgnWNbPeMkRUIyrcHwCspc/rruJMSCsp2BQgwKMXO/YOUsBWrUHCTQTXH/yykDSuZD+OuNZ+16lMgBaWegas3JCBXJTStiXlQkV/60bGeg0nVnqS2xMzYUXNgOxruQl06FbSJfQpyBakydD9UPg62U2OJdjWfCNqXsOQNlC6nYGhe3IQufw3F5yXgXrZcTUFga0BkJYVrJMQrhZhr63bebchN+cQLKdufAxVkbjs2pdF+6SrNE0k5ADV67Faic5PrJX/CcOcPzH+Hjt7tNbX4VsWdxUFiN42aJXyQvS/jLRfejR/jwZptH+9+L8ywOih6fu/KmYmNxhE/HPtyTkhZRUDjePipvKoKAV+Eim1/gUba9oF8n6rMYKMw49Evixp3U3u6whmH2o7f7st0PQjPvYWWU+00M1Ly5yOVfEqDLjefny9kX+x22/F0QJ9PTZFOexJgIKHiSv8sBUtezcTMj7SSpW6R3YeuPzd3Se+iCdMws/RjxIfEpDGa5/OO/F1259tFd0LAoggu1cWa0tomRxP+5IOJRURr7L3Rtp8pR63EiQEWFYtNXUDYqlrzEoeANcxkjzsobA35U4fzngq3MOLZoOYkHc1rg/5rWrsf8sgpKQTEJMNXUoxQUkwBTTT1KQTEJMNXUoxQUkwBTTT1KQTEJMNXUo5igTPSg4gZT90Sr/QY8wFukC54i3gAAAABJRU5ErkJggg==" /></ns0:svg></svg>

After

Width:  |  Height:  |  Size: 2.6 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 16 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024" width="2500" height="2500"><path fill="#4285F4" d="M1024 155.733C1024 70.4 953.6 0 868.267 0H155.733C70.4 0 0 70.4 0 155.733v712.534C0 953.6 70.4 1024 155.733 1024h712.534C953.6 1024 1024 953.6 1024 868.267V155.733z"/><path fill="#FFF" d="M810.667 422.4c-2.134 6.4-4.267 14.933-8.534 23.467C774.4 505.6 701.867 620.8 701.867 620.8l-21.334 36.267h102.4L588.8 915.2l44.8-174.933h-78.933l27.733-115.2c-23.467 6.4-49.067 12.8-81.067 23.466 0 0-42.666 25.6-121.6-46.933 0 0-53.333-46.933-23.466-59.733 12.8-4.267 64-10.667 104.533-17.067 55.467-6.4 87.467-10.667 87.467-10.667s-168.534 2.134-206.934-4.266c-40.533-6.4-89.6-72.534-100.266-132.267 0 0-17.067-32 36.266-17.067s268.8 59.734 268.8 59.734-281.6-87.467-300.8-108.8c-19.2-21.334-55.466-115.2-51.2-172.8 0 0 2.134-14.934 17.067-10.667 0 0 209.067 96 352 147.2 140.8 53.333 264.533 81.067 247.467 147.2z"/></svg>

After

Width:  |  Height:  |  Size: 928 B

+21
View File
@@ -0,0 +1,21 @@
<!-- Generator: Adobe Illustrator 21.1.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Ebene_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 161.6 161.6" style="enable-background:new 0 0 161.6 161.6;" xml:space="preserve">
<style type="text/css">
.st0{fill:#F08705;}
.st1{fill:#DF6C0C;}
.st2{fill:#FFFFFF;}
.st3{fill:#333333;}
</style>
<g>
<path class="st0" d="M161.6,154.7c0,3.9-3.2,6.9-6.9,6.9H6.9c-3.9,0-6.9-3.2-6.9-6.9V6.9C0,3,3.2,0,6.9,0h147.8
c3.9,0,6.9,3.2,6.9,6.9L161.6,154.7L161.6,154.7z"/>
<g>
<path class="st1" d="M161.6,154.7c0,3.9-3.2,6.9-6.9,6.9H55.3l-32.2-32.7l20-32.7l59.4-73.8l58.9,60.7L161.6,154.7z"/>
</g>
<path class="st2" d="M132.7,90.3h-17l-18-30.6c4-0.8,7-4.4,7-8.6V28c0-4.9-3.9-8.8-8.8-8.8h-30c-4.9,0-8.8,3.9-8.8,8.8v23.1
c0,4.3,3,7.8,6.9,8.6L46,90.4H29c-4.9,0-8.8,3.9-8.8,8.8v23.1c0,4.9,3.9,8.8,8.8,8.8h30c4.9,0,8.8-3.9,8.8-8.8V99.2
c0-4.9-3.9-8.8-8.8-8.8h-2.9L73.9,60h13.9l17.9,30.4h-3c-4.9,0-8.8,3.9-8.8,8.8v23.1c0,4.9,3.9,8.8,8.8,8.8h30
c4.9,0,8.8-3.9,8.8-8.8V99.2C141.5,94.3,137.6,90.3,132.7,90.3z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns:ns0="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48"><rect width="48" height="48" rx="8" fill="#fff" /><ns0:svg viewBox="0 0 48 48" x="4" y="4" width="40" height="40"><ns0:image width="48" height="48" href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAACgUlEQVR42u2XXUiTYRTHf8/euem7V6sbQcFMsgyJQoigizDqIuhDiW6iksBISrGS7MsP3JhWlBUqWnThjX2RBGlSRCREF0UEkURkGWYSUhCpe7e5ue3pQmaY6Eq3hNq5ex8O5/lxzvn/eR+Ruu6bZA7DwBxHFCAKYAyVUF+hkb06ZkbFS07pdD4bnR2AGieYFy8YdEhudHhCXmo2wZ5tsSgG0F1y9h0Ixvx4QW+/n1v3p4ewH7agGOB6h4fnXb7w7ICU0NPnx3ZIZUmqMmXexrUm8nLNvOnxY2twhW8JpYQim44AGq0acWYxKScp0cCZUgtOl6TYruPxyvCqoLvXT80lF0sXKVQUqhPnqEBDpcaCBMHRs04+fPJHRoYtbR7udnrZlWMmZ4Np/PxIvsqq5Uaab49w77E3sj5QftFJ/0CAmhILKUkG1mTFULAjlldvfZy+7Aq/D/waw7rkYLVOa10CjVUayYkGHE5JoVVn1EdkAISAq7XxE850t2RFhhEpYW+Zg89fAv+oFQdluLvUMf6dlWmktS6Brm4fyYkGLpRpbN43NKMu/HEHEjRBfYWG2yMpsukU253EWwRNVo0Y418YQXD7g2p4+nKUKzdHWLnMyMn9amQB8nLNbF1v4lq7h/ZHP/V+vtnFi9c+8rfHsinbFBmAjDSF8gMq7z76qW6aqHefH4rtOt+HJeeOWVi8UAkvgBDQWKUhgSKrjtsz2ecHvgY4UevEogoaKjXMJhFegPRUhao6F+/7pvb5B0+8tLR5yExXqCpWw+uEgw5JWorC8YLpC4/6JP4A7Nxi5s7D0P8EIQFcbsmQQyIYK/o7oTvHRqSpoccgoi+jKMB/D/ADRpbjdBgK8S0AAAAASUVORK5CYII=" /></ns0:svg></svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

+11
View File
@@ -0,0 +1,11 @@
<svg viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
preserveAspectRatio="xMidYMid meet">
<path d="M12.9238 12.8029C12.9427 12.784 12.9616 12.7682 12.9806 12.7493C13.0184 12.7146 13.0563 12.6767 13.091 12.6389L13.1667 12.5631L13.397 12.336L14.7315 11.0173L15.0659 10.686C15.129 10.6229 15.1952 10.563 15.2615 10.5031C15.3845 10.3926 15.5076 10.2854 15.6369 10.1813C15.7536 10.0866 15.8767 9.99514 15.9997 9.9068C16.1732 9.78376 16.3499 9.67019 16.5329 9.55977C16.7127 9.45251 16.8957 9.35471 17.085 9.26322C17.2616 9.17804 17.4415 9.09917 17.6276 9.02661C17.7317 8.9856 17.8326 8.94774 17.9399 8.91304C17.9935 8.89411 18.044 8.87834 18.0977 8.86256C17.6276 7.00439 16.7632 5.3008 15.5991 3.84959C15.3719 3.56566 15.0249 3.40161 14.6589 3.40161H5.0084C4.83489 3.40161 4.76233 3.6256 4.90114 3.72656C8.18528 6.13997 10.9236 9.24114 12.9017 12.825C12.908 12.8187 12.9175 12.8124 12.9238 12.8029Z"
fill="#00D6B9" />
<path d="M9.09696 21.2986C14.0815 21.2986 18.4225 18.5476 20.6877 14.4843C20.7666 14.3423 20.8454 14.1972 20.918 14.052C20.8044 14.2729 20.6751 14.4811 20.5394 14.6767C20.4889 14.7461 20.4385 14.8155 20.388 14.8818C20.3217 14.9669 20.2555 15.049 20.1861 15.1278C20.1324 15.1909 20.0757 15.2509 20.0189 15.3108C19.9021 15.4307 19.7823 15.5474 19.6561 15.6547C19.5867 15.7146 19.5141 15.7714 19.4415 15.8282C19.3564 15.8944 19.268 15.9575 19.1797 16.0143C19.1229 16.0522 19.0661 16.09 19.0093 16.1247C18.9494 16.1626 18.8895 16.1973 18.8264 16.232C18.7002 16.3014 18.574 16.3645 18.4446 16.4245C18.3311 16.4749 18.2175 16.5223 18.1008 16.5633C17.9746 16.6106 17.8452 16.6516 17.7159 16.6863C17.5234 16.7399 17.3247 16.7809 17.1259 16.8125C16.9808 16.8346 16.8357 16.8504 16.6874 16.863C16.5328 16.8724 16.3751 16.8787 16.2173 16.8756C16.0438 16.8724 15.8703 16.863 15.6936 16.844C15.5643 16.8314 15.435 16.8125 15.3056 16.7873C15.192 16.7683 15.0785 16.7431 14.9649 16.7178C14.9049 16.7021 14.845 16.6895 14.7851 16.6737C14.6179 16.6295 14.4538 16.5822 14.2898 16.5349C14.2077 16.5096 14.1257 16.4875 14.0437 16.4623C13.9206 16.4245 13.7976 16.3897 13.6777 16.3519C13.5768 16.3203 13.479 16.2888 13.378 16.2572C13.2834 16.2257 13.1887 16.1942 13.0941 16.1626C13.031 16.1405 12.9647 16.1184 12.9016 16.0964C12.8228 16.0711 12.7471 16.0427 12.6682 16.0143C12.6114 15.9954 12.5578 15.9765 12.501 15.9544C12.3906 15.9134 12.2802 15.8755 12.1729 15.8345C12.1098 15.8093 12.0467 15.7872 11.9836 15.7619C11.8984 15.7304 11.8132 15.6957 11.7312 15.6641C11.6429 15.6294 11.5514 15.5947 11.4631 15.5569C11.4063 15.5348 11.3463 15.5096 11.2895 15.4875C11.217 15.4591 11.1476 15.4275 11.075 15.3991C11.0214 15.3771 10.9646 15.3518 10.911 15.3297C10.8542 15.3045 10.7974 15.2793 10.7406 15.254C10.6901 15.2319 10.6428 15.2099 10.5923 15.1878C10.5482 15.1688 10.5008 15.1468 10.4567 15.1278C10.4094 15.1057 10.3652 15.0868 10.3179 15.0647C10.2705 15.0427 10.2232 15.0206 10.1759 14.9985C10.116 14.9701 10.056 14.9417 9.99608 14.9165C9.93299 14.8881 9.87304 14.8565 9.80995 14.8281C9.7437 14.7966 9.67745 14.765 9.6112 14.7303C9.55441 14.7019 9.49762 14.6735 9.44084 14.6483C6.45324 13.1592 3.80321 11.1717 1.54438 8.76145C1.43081 8.64157 1.23206 8.72044 1.23206 8.88449L1.23836 18.0933C1.23836 18.494 1.43712 18.8726 1.77153 19.0934C3.86631 20.4878 6.38699 21.2986 9.09696 21.2986Z"
fill="#3370FF" />
<path d="M23.7322 9.29488C22.7226 8.79642 21.5838 8.5188 20.3818 8.5188C19.6688 8.5188 18.9747 8.6166 18.3217 8.80273C18.246 8.82481 18.1703 8.8469 18.0977 8.86898C18.0441 8.88476 17.9905 8.90368 17.94 8.91946C17.8359 8.95416 17.7318 8.99202 17.6276 9.03303C17.4447 9.10559 17.2617 9.18446 17.085 9.26964C16.8957 9.36113 16.7128 9.45893 16.5329 9.56619C16.35 9.67345 16.1701 9.79018 15.9998 9.91322C15.8767 10.0016 15.7569 10.093 15.637 10.1877C15.5076 10.2918 15.3846 10.3991 15.2616 10.5095C15.1953 10.5694 15.1322 10.6325 15.066 10.6925L14.7315 11.0206L13.3939 12.3424L13.1636 12.5696L13.0879 12.6453C13.05 12.6831 13.0122 12.7178 12.9775 12.7557C12.9586 12.7746 12.9396 12.7904 12.9207 12.8093C12.8923 12.8377 12.8639 12.863 12.8355 12.8882C12.804 12.9166 12.7724 12.9481 12.7409 12.9765C11.9143 13.7368 10.9931 14.3899 9.99304 14.923C10.053 14.9514 10.1129 14.9798 10.1729 15.0051C10.2202 15.0271 10.2675 15.0492 10.3148 15.0713C10.359 15.0934 10.4063 15.1123 10.4536 15.1344C10.4978 15.1533 10.5451 15.1754 10.5893 15.1943C10.6398 15.2164 10.6871 15.2385 10.7376 15.2606C10.7944 15.2858 10.8511 15.3111 10.9079 15.3363C10.9616 15.3584 11.0184 15.3836 11.072 15.4057C11.1445 15.4373 11.2139 15.4657 11.2865 15.4941C11.3433 15.5193 11.4032 15.5414 11.46 15.5635C11.5484 15.5982 11.6367 15.636 11.7282 15.6707C11.8134 15.7023 11.8954 15.737 11.9806 15.7685C12.0437 15.7938 12.1068 15.8158 12.1699 15.8411C12.2803 15.8821 12.3875 15.9231 12.498 15.961C12.5547 15.9799 12.6084 16.002 12.6652 16.0209C12.744 16.0493 12.8197 16.0745 12.8986 16.1029C12.9617 16.125 13.028 16.1471 13.0911 16.1692C13.1857 16.2007 13.2803 16.2323 13.375 16.2638C13.4728 16.2954 13.5737 16.3269 13.6747 16.3585C13.7977 16.3963 13.9176 16.4342 14.0406 16.4689C14.1227 16.4941 14.2047 16.5162 14.2867 16.5414C14.4508 16.5888 14.618 16.6361 14.782 16.6803C14.842 16.696 14.9019 16.7118 14.9618 16.7244C15.0754 16.7528 15.189 16.7749 15.3026 16.7938C15.4319 16.8159 15.5613 16.8348 15.6906 16.8506C15.8673 16.8695 16.0408 16.8822 16.2143 16.8822C16.372 16.8853 16.5298 16.879 16.6844 16.8695C16.8326 16.8601 16.9778 16.8412 17.1229 16.8191C17.3248 16.7875 17.5204 16.7465 17.7128 16.6929C17.8422 16.6582 17.9715 16.6172 18.0977 16.5698C18.2144 16.5257 18.328 16.4815 18.4416 16.4279C18.5709 16.3679 18.7003 16.3048 18.8233 16.2354C18.8833 16.2007 18.9464 16.166 19.0063 16.1282C19.0631 16.0935 19.1199 16.0556 19.1767 16.0178C19.265 15.9578 19.3533 15.8947 19.4385 15.8316C19.5111 15.7748 19.5836 15.718 19.653 15.6581C19.7792 15.5508 19.8991 15.4341 20.0158 15.3142C20.0726 15.2543 20.1294 15.1943 20.183 15.1313C20.2524 15.0524 20.3187 14.9704 20.3849 14.8852C20.4354 14.8189 20.4859 14.7495 20.5364 14.6801C20.672 14.4845 20.7982 14.2763 20.9118 14.0586L21.0411 13.7999L22.2084 11.4748L22.2053 11.4812C22.5807 10.6578 23.1012 9.91953 23.7322 9.29488Z"
fill="#133C9A" />
</svg>

After

Width:  |  Height:  |  Size: 6.1 KiB

+1
View File
@@ -0,0 +1 @@
<svg fill="#F24E1E" role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Figma</title><path d="M15.852 8.981h-4.588V0h4.588c2.476 0 4.49 2.014 4.49 4.49s-2.014 4.491-4.49 4.491zM12.735 7.51h3.117c1.665 0 3.019-1.355 3.019-3.019s-1.355-3.019-3.019-3.019h-3.117V7.51zm0 1.471H8.148c-2.476 0-4.49-2.014-4.49-4.49S5.672 0 8.148 0h4.588v8.981zm-4.587-7.51c-1.665 0-3.019 1.355-3.019 3.019s1.354 3.02 3.019 3.02h3.117V1.471H8.148zm4.587 15.019H8.148c-2.476 0-4.49-2.014-4.49-4.49s2.014-4.49 4.49-4.49h4.588v8.98zM8.148 8.981c-1.665 0-3.019 1.355-3.019 3.019s1.355 3.019 3.019 3.019h3.117V8.981H8.148zM8.172 24c-2.489 0-4.515-2.014-4.515-4.49s2.014-4.49 4.49-4.49h4.588v4.441c0 2.503-2.047 4.539-4.563 4.539zm-.024-7.51a3.023 3.023 0 0 0-3.019 3.019c0 1.665 1.365 3.019 3.044 3.019 1.705 0 3.093-1.376 3.093-3.068v-2.97H8.148zm7.704 0h-.098c-2.476 0-4.49-2.014-4.49-4.49s2.014-4.49 4.49-4.49h.098c2.476 0 4.49 2.014 4.49 4.49s-2.014 4.49-4.49 4.49zm-.097-7.509c-1.665 0-3.019 1.355-3.019 3.019s1.355 3.019 3.019 3.019h.098c1.665 0 3.019-1.355 3.019-3.019s-1.355-3.019-3.019-3.019h-.098z"/></svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48"><image width="48" height="48" href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADAAAAAwCAYAAABXAvmHAAAACXBIWXMAAAsTAAALEwEAmpwYAAABaWlDQ1BEaXNwbGF5IFAzAAB4nHWQvUvDUBTFT6tS0DqIDh0cMolD1NIKdnFoKxRFMFQFq1OafgltfCQpUnETVyn4H1jBWXCwiFRwcXAQRAcR3Zw6KbhoeN6XVNoi3sfl/Ticc7lcwBtQGSv2AijplpFMxKS11Lrke4OHnlOqZrKooiwK/v276/PR9d5PiFlNu3YQ2U9cl84ul3aeAlN//V3Vn8maGv3f1EGNGRbgkYmVbYsJ3iUeMWgp4qrgvMvHgtMunzuelWSc+JZY0gpqhrhJLKc79HwHl4plrbWD2N6f1VeXxRzqUcxhEyYYilBRgQQF4X/8044/ji1yV2BQLo8CLMpESRETssTz0KFhEjJxCEHqkLhz634PrfvJbW3vFZhtcM4v2tpCAzidoZPV29p4BBgaAG7qTDVUR+qh9uZywPsJMJgChu8os2HmwiF3e38M6Hvh/GMM8B0CdpXzryPO7RqFn4Er/QcXKWq8MSlPPgAABWVJREFUeAHtWVtoHFUY/s7ZyTabTXbT3EiyMTFopLR9kMYbFUUfKl6CYqmU1Ad9ENSUgi9e0Ao1BaVaBQUrFrW0WuqtSoVaxUtFRV8UCoqotWnqtnWTbpprdzc7s+f4n5ltbrvZnZnMtgT6QchhZvbM//238///MEnAIgbHIsclAhcb3hMQIv+6RPCeAOcQB9+AsWOjuTZRQiKlcaGmdogPdiB9C4P4do9FxDBQCrCSpNHhGNLrmsBaWyBPx8FXdEJ75UcgQyR8GrxEaSxQEbIEzWTAGmsgor/DeHC5dU14a4nSEGBsek0GZoEKiNEojFcfojcuBgskRnM0zcJhiA/fhoyfJFLeBbX3BEjjou8I4C+ffZ0yEbu8AZnXHyE23r3WewLkPuKjbWC14dx7mgbx8zdWMHsEbwmofB+PQvz0He3sy72vEl56EnKgH17BOwLK5ynfG4+vAWtrsoTNh4AGGTsGr+ANARWUlF3EricgVJDOzEJz4SPLjJ+FV/CAgDSDUv5yEMbu7eT71fNrX0G52ZKK7E/F9DWXWDgBISH//QP6pi6w9pbiwkySq9W1mEs5dApGTyfFRQJusTACSlg9Cb17Bdiyy+xll4wAb10OcehN6F2tdEpTPJRXwi3cExAZM2j1DVeBXdlsPzVGSOgjX8PY1kOkyRJSt/ZyCfcEKE2K3U9DZsYL+/wcMKbD2LqW3K3ZEjyRgFRB7bKmdEdAuU5qAsZbL4BVh+AUbGm9WeiZ65oQ5Fe7aAFXcEeAXCezbyvQGFp4sxKsgvEO7aUYuNjLtQuJQ3vAKqqwYFAqZX6qnz5+cbqDcwB3BMbilAJj3lWV1dUwXnuK3DLh2AquCIhYHxVmvsIPUfMih4fy10Q5G5IVIrUwtm9wbAV3FqADCGWFGxMZH4D25HuQ/52x10b6l0B8eQA4N+IoI9knoNzFSMMsHQy9eNYw6PlwPfzv/gX5T7S4ZlUs1FdRbO0sXEvNgX0CVO9Mdi4xy2EEqor7apr64aWNQEMbfD1bKG5GURSVlRDfvw8nsE9gZBCsjuRaH7EEyxQgoPw+EAJrWWYeVr71myEn1EldXLOi/6ijQHZAYIB8mYOFyqFv7ARrjsz7qDwzDO3R57Nv8Jl/fM29RDpd/D2pJGkpBbuwTUCkzlmPqylDQ6MVB/OAVTWA39UzS5P85m7IkTEUhTqh7RDNwvaMg5lNepHsoFLn0Sj8h5OW8DMCl0c6SLM6ioKs7GT4ZdsCrKZxqn7J/2IS/u8oyvb+Rmrx52YdFfgUAvJsHAVjoTxIDU8QdmE/BkJUgJVX5L+nOrITp6C9tJ/64ZX50yC5nOyX0Ho/Iz9PzTtaYS1tJUqjKhBXXoN8biRHx8DXPQy+em3W73MFULOisn2fg6+6Hfy2B4BkbhcmExPgN90HJ3Aw3KWC69cvYDxzDwVx3YwdSAdpHwl3IjuZ0PL+doqUIpgcR/rOarJWM6YUQlqXx0/D/ykNBWqabVvBQSkhwTvvIHkD1mTh/NXYILnF/mzQzhd8M4RRsREMg9946+xikH7PV10L1EZK5EJmChXwbdlLTXxs+iX+AFgHuRZ30pGQMu7vpfNiKLu1D/JYDNrmTxxXuA6LOQZ+XRf41TfQdCFlngm8/Yqpe4726aBpREZarjM4BG1TrzWtYKWsRpXWqXnXXv6B/JjcRae6KOi8pTRBlkMoCDk2Th9ArgfvftZVc++8nDY/XBgoO0ClhQhD9h2HKyhh+0ZJ+NWUfg/Tnrq93mEupFtkDPOf/tzdUk4mpWOc/FMaOx+z1kZausXCvpGdT5vmjMih9qTl/3NLDqcozUe+C4hLX+ovNhY9gf8BBdOq4LVBF+wAAAAASUVORK5CYII="/></svg>

After

Width:  |  Height:  |  Size: 2.5 KiB

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><rect width="64" height="64" rx="14" fill="#eef2ff"/><g fill="none" stroke="#4f46e5" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><rect x="13" y="16" width="38" height="32" rx="4"/><path d="M13 25h38M21 21h.01M26 21h.01M31 21h.01M25 32l-5 5 5 5m14-10 5 5-5 5m-5-11-4 13"/></g></svg>

After

Width:  |  Height:  |  Size: 364 B

+8
View File
@@ -0,0 +1,8 @@
<svg xmlns:ns0="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48"><rect width="48" height="48" rx="8" fill="#fff" /><ns0:svg width="40" height="40" x="4" y="4">
<ns0:clipPath id="clip_0"><ns0:circle cx="14" cy="14" r="14" />
</ns0:clipPath>
<ns0:g clip-path="url(#clip_0)">
<ns0:path fill="#F2F2F2" d="M14 0C6.265 0 0 6.265 0 14C0 20.195 4.0075 25.4275 9.5725 27.2825C10.2725 27.405 10.535 26.985 10.535 26.6175C10.535 26.285 10.5175 25.1825 10.5175 24.01C7 24.6575 6.09 23.1525 5.81 22.365C5.6525 21.9625 4.97 20.72 4.375 20.3875C3.885 20.125 3.185 19.4775 4.3575 19.46C5.46 19.4425 6.2475 20.475 6.51 20.895C7.77 23.0125 9.7825 22.4175 10.5875 22.05C10.71 21.14 11.0775 20.5275 11.48 20.1775C8.365 19.8275 5.11 18.62 5.11 13.265C5.11 11.7425 5.6525 10.4825 6.545 9.5025C6.405 9.1525 5.915 7.7175 6.685 5.7925C6.685 5.7925 7.8575 5.425 10.535 7.2275C11.655 6.9125 12.845 6.755 14.035 6.755C15.225 6.755 16.415 6.9125 17.535 7.2275C20.2125 5.4075 21.385 5.7925 21.385 5.7925C22.155 7.7175 21.665 9.1525 21.525 9.5025C22.4175 10.4825 22.96 11.725 22.96 13.265C22.96 18.6375 19.6875 19.8275 16.5725 20.1775C17.08 20.615 17.5175 21.455 17.5175 22.7675C17.5175 24.64 17.5 26.145 17.5 26.6175C17.5 26.985 17.7625 27.4225 18.4625 27.2825C23.9925 25.4275 28 20.1775 28 14C28 6.265 21.735 0 14 0L14 0Z" />
<ns0:path fill="#24292E" transform="matrix(1 0 0 1 3.7 5.75)" d="M14.667 21.5378C14.0265 21.6302 13.784 21.2123 13.784 20.859C13.784 20.7011 13.786 20.4278 13.7886 20.064C13.7938 19.3394 13.8015 18.2556 13.8015 17.009C13.8015 15.6965 13.364 14.8565 12.8565 14.419C15.9715 14.069 19.244 12.879 19.244 7.5065C19.244 5.9665 18.7015 4.724 17.809 3.744C17.949 3.394 18.439 1.959 17.669 0.034C17.669 0.034 16.4965 -0.351 13.819 1.469C12.699 1.154 11.509 0.9965 10.319 0.9965C9.129 0.9965 7.939 1.154 6.819 1.469C4.1415 -0.3335 2.969 0.034 2.969 0.034C2.199 1.959 2.689 3.394 2.829 3.744C1.9365 4.724 1.394 5.984 1.394 7.5065C1.394 12.8615 4.649 14.069 7.764 14.419C7.3615 14.769 6.994 15.3815 6.8715 16.2915C6.0665 16.659 4.054 17.254 2.794 15.1365C2.5315 14.7165 1.744 13.684 0.6415 13.7015C-0.531 13.719 0.169 14.3665 0.659 14.629C1.254 14.9615 1.9365 16.204 2.094 16.6065C2.374 17.394 3.284 18.899 6.8015 18.2515C6.8015 19.424 6.819 20.5265 6.819 20.859C6.819 21.2254 6.5581 21.6439 5.863 21.5251Q6.9323 21.881 8.0447 22.0613Q9.1571 22.2415 10.284 22.2415Q11.4009 22.2415 12.5036 22.0645Q13.6063 21.8874 14.667 21.5378Z" fill-rule="evenodd" />
</ns0:g>
</ns0:svg></svg>

After

Width:  |  Height:  |  Size: 2.5 KiB

@@ -0,0 +1 @@
<svg fill="#FFD21E" role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Hugging Face</title><path d="M12.025 1.13c-5.77 0-10.449 4.647-10.449 10.378 0 1.112.178 2.181.503 3.185.064-.222.203-.444.416-.577a.96.96 0 0 1 .524-.15c.293 0 .584.124.84.284.278.173.48.408.71.694.226.282.458.611.684.951v-.014c.017-.324.106-.622.264-.874s.403-.487.762-.543c.3-.047.596.06.787.203s.31.313.4.467c.15.257.212.468.233.542.01.026.653 1.552 1.657 2.54.616.605 1.01 1.223 1.082 1.912.055.537-.096 1.059-.38 1.572.637.121 1.294.187 1.967.187.657 0 1.298-.063 1.921-.178-.287-.517-.44-1.041-.384-1.581.07-.69.465-1.307 1.081-1.913 1.004-.987 1.647-2.513 1.657-2.539.021-.074.083-.285.233-.542.09-.154.208-.323.4-.467a1.08 1.08 0 0 1 .787-.203c.359.056.604.29.762.543s.247.55.265.874v.015c.225-.34.457-.67.683-.952.23-.286.432-.52.71-.694.257-.16.547-.284.84-.285a.97.97 0 0 1 .524.151c.228.143.373.388.43.625l.006.04a10.3 10.3 0 0 0 .534-3.273c0-5.731-4.678-10.378-10.449-10.378M8.327 6.583a1.5 1.5 0 0 1 .713.174 1.487 1.487 0 0 1 .617 2.013c-.183.343-.762-.214-1.102-.094-.38.134-.532.914-.917.71a1.487 1.487 0 0 1 .69-2.803m7.486 0a1.487 1.487 0 0 1 .689 2.803c-.385.204-.536-.576-.916-.71-.34-.12-.92.437-1.103.094a1.487 1.487 0 0 1 .617-2.013 1.5 1.5 0 0 1 .713-.174m-10.68 1.55a.96.96 0 1 1 0 1.921.96.96 0 0 1 0-1.92m13.838 0a.96.96 0 1 1 0 1.92.96.96 0 0 1 0-1.92M8.489 11.458c.588.01 1.965 1.157 3.572 1.164 1.607-.007 2.984-1.155 3.572-1.164.196-.003.305.12.305.454 0 .886-.424 2.328-1.563 3.202-.22-.756-1.396-1.366-1.63-1.32q-.011.001-.02.006l-.044.026-.01.008-.03.024q-.018.017-.035.036l-.032.04a1 1 0 0 0-.058.09l-.014.025q-.049.088-.11.19a1 1 0 0 1-.083.116 1.2 1.2 0 0 1-.173.18q-.035.029-.075.058a1.3 1.3 0 0 1-.251-.243 1 1 0 0 1-.076-.107c-.124-.193-.177-.363-.337-.444-.034-.016-.104-.008-.2.022q-.094.03-.216.087-.06.028-.125.063l-.13.074q-.067.04-.136.086a3 3 0 0 0-.135.096 3 3 0 0 0-.26.219 2 2 0 0 0-.12.121 2 2 0 0 0-.106.128l-.002.002a2 2 0 0 0-.09.132l-.001.001a1.2 1.2 0 0 0-.105.212q-.013.036-.024.073c-1.139-.875-1.563-2.317-1.563-3.203 0-.334.109-.457.305-.454m.836 10.354c.824-1.19.766-2.082-.365-3.194-1.13-1.112-1.789-2.738-1.789-2.738s-.246-.945-.806-.858-.97 1.499.202 2.362c1.173.864-.233 1.45-.685.64-.45-.812-1.683-2.896-2.322-3.295s-1.089-.175-.938.647 2.822 2.813 2.562 3.244-1.176-.506-1.176-.506-2.866-2.567-3.49-1.898.473 1.23 2.037 2.16c1.564.932 1.686 1.178 1.464 1.53s-3.675-2.511-4-1.297c-.323 1.214 3.524 1.567 3.287 2.405-.238.839-2.71-1.587-3.216-.642-.506.946 3.49 2.056 3.522 2.064 1.29.33 4.568 1.028 5.713-.624m5.349 0c-.824-1.19-.766-2.082.365-3.194 1.13-1.112 1.789-2.738 1.789-2.738s.246-.945.806-.858.97 1.499-.202 2.362c-1.173.864.233 1.45.685.64.451-.812 1.683-2.896 2.322-3.295s1.089-.175.938.647-2.822 2.813-2.562 3.244 1.176-.506 1.176-.506 2.866-2.567 3.49-1.898-.473 1.23-2.037 2.16c-1.564.932-1.686 1.178-1.464 1.53s3.675-2.511 4-1.297c.323 1.214-3.524 1.567-3.287 2.405.238.839 2.71-1.587 3.216-.642.506.946-3.49 2.056-3.522 2.064-1.29.33-4.568 1.028-5.713-.624"/></svg>

After

Width:  |  Height:  |  Size: 3.0 KiB

+15
View File
@@ -0,0 +1,15 @@
<svg width="100" height="100" viewBox="0 0 100 100" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="100" height="100" fill="white"/>
<path d="M10.1851 57.8021L33.1145 73.8313C36.2202 75.9978 41.5173 73.5433 42.4816 69.4984L51.7611 30.4271C52.7253 26.3822 48.5802 23.9277 44.4602 26.0942L13.917 42.1235C6.96677 45.7676 4.97564 54.1579 10.1851 57.8021Z" fill="url(#paint0_linear_2204_3074)"/>
<path d="M87.5129 57.5141L56.9696 73.5433C52.8371 75.7098 48.7046 73.2553 49.6688 69.2104L58.9483 30.1391C59.9125 26.0942 65.2097 23.6397 68.3154 25.8062L91.2447 41.8354C96.4668 45.4796 94.4631 53.8699 87.5129 57.5141Z" fill="url(#paint1_linear_2204_3074)"/>
<defs>
<linearGradient id="paint0_linear_2204_3074" x1="48.5676" y1="25" x2="44.7804" y2="71.9384" gradientUnits="userSpaceOnUse">
<stop stop-color="#06E3FA"/>
<stop offset="1" stop-color="#4FDB5E"/>
</linearGradient>
<linearGradient id="paint1_linear_2204_3074" x1="54.8282" y1="73.8392" x2="72.0989" y2="32.8932" gradientUnits="userSpaceOnUse">
<stop stop-color="#06E3FA"/>
<stop offset="1" stop-color="#4FDB5E"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48"><rect width="48" height="48" rx="10" fill="white"/><svg x="6" y="6" width="36" height="36" viewBox="0 0 24 24" fill="#286EFA"><path d="M21 0H3C1.343 0 0 1.343 0 3v18c0 1.658 1.343 3 3 3h18c1.658 0 3-1.342 3-3V3c0-1.657-1.342-3-3-3zm-5.801 4.399c0-.44.36-.8.802-.8.44 0 .8.36.8.8v10.688c0 .442-.36.801-.8.801-.443 0-.802-.359-.802-.801V4.399zM11.2 3.994c0-.44.357-.799.8-.799s.8.359.8.799v11.602c0 .44-.357.8-.8.8s-.8-.36-.8-.8V3.994zm-4 .405c0-.44.359-.8.799-.8.443 0 .802.36.802.8v10.688c0 .442-.36.801-.802.801-.44 0-.799-.359-.799-.801V4.399zM3.199 6c0-.442.36-.8.802-.8.44 0 .799.358.799.8v7.195c0 .441-.359.8-.799.8-.443 0-.802-.36-.802-.8V6zM20.52 18.202c-.123.105-3.086 2.593-8.52 2.593-5.433 0-8.397-2.486-8.521-2.593-.335-.288-.375-.792-.086-1.128.285-.334.79-.375 1.125-.09.047.041 2.693 2.211 7.481 2.211 4.848 0 7.456-2.186 7.479-2.207.334-.289.839-.25 1.128.086.289.336.25.84-.086 1.128zm.281-5.007c0 .441-.36.8-.801.8-.441 0-.801-.36-.801-.8V6c0-.442.361-.8.801-.8.441 0 .801.357.801.8v7.195z"/></svg></svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

+23
View File
@@ -0,0 +1,23 @@
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="18" height="18" rx="6.075" fill="#FF8533"/>
<rect width="18" height="18" rx="6.075" fill="black" fill-opacity="0.01"/>
<g filter="url(#filter1_d_400_2887)">
<path fill-rule="evenodd" clip-rule="evenodd" d="M8.6902 8.73227C8.68608 8.7279 8.68008 8.72154 8.66625 8.72761L7.9091 9.11698C7.90512 9.11903 7.8997 9.12902 7.90445 9.14024C7.97183 9.29938 8.47929 10.5295 8.35807 11.1993C8.35807 11.1993 8.15401 12.1873 7.71066 12.8827L7.71037 12.8833C7.69715 12.9075 7.221 13.779 6.24546 13.6848C6.2401 13.6843 6.22764 13.6848 6.22764 13.6974L6.14389 14.3961C6.14389 14.3961 9.27551 14.3909 11.6501 14.3909C11.5989 14.0627 11.5403 13.6058 11.5295 13.5204C11.5283 13.5112 11.5247 13.5068 11.5124 13.5098C11.3224 13.5524 9.4224 13.9494 9.4224 12.7859C9.4224 12.7191 9.43093 12.2022 9.54191 11.8046C9.78285 10.9423 10.5212 10.1979 10.596 10.1242C10.5997 10.1202 10.6018 10.1105 10.5997 10.1013L10.4551 9.07605C10.4535 9.07044 10.444 9.05905 10.4297 9.0716C10.3065 9.22448 9.67184 9.97081 9.35542 9.53702L8.69091 8.73302L8.6902 8.73227Z" fill="white"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M6.37821 9.6672C6.21351 9.49699 5.35104 9.05819 3.94141 9.85658C3.94141 9.85658 4.71346 10.9715 4.9815 11.2331C5.24954 11.4947 5.61608 11.7474 6.27002 11.5477L6.27382 11.5468C6.30859 11.5387 6.57353 11.4719 6.6971 11.3013C6.74959 11.235 6.78352 11.1329 6.79886 11.0083C6.82066 10.6769 6.80053 10.0263 6.37821 9.6672Z" fill="white"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M13.9699 8.56628C13.4879 8.48574 12.9753 8.47065 12.4934 8.58352C12.361 8.61518 12.2421 8.65984 12.1185 8.7378C11.8546 8.90276 11.702 9.17868 11.5799 9.47993C11.5788 9.48324 11.5766 9.4894 11.5734 9.4981C11.5354 9.60228 11.3643 10.0716 11.457 10.3983C11.5408 10.7085 12.1575 10.8611 12.2621 10.8839C12.4759 10.9297 12.5579 10.9375 12.655 10.9467C12.663 10.9475 12.6711 10.9482 12.6793 10.949C12.7465 10.9531 12.7738 10.9515 12.8035 10.9499C12.8084 10.9496 12.8133 10.9493 12.8185 10.949C13.2438 10.927 14.1737 10.5428 14.7336 8.70888C14.7353 8.70124 14.0277 8.57612 13.9699 8.56628Z" fill="white"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M5.62376 4.2538C5.62376 4.2538 4.22529 3.80884 3.94141 3.60393C3.94141 3.60393 4.25731 8.93305 7.16761 8.71873C7.38985 8.70645 7.60575 8.66875 7.79285 8.63233C8.0189 8.57726 8.11373 8.55185 8.3728 8.4341C8.50318 8.36718 8.61409 8.29094 8.7106 8.17489C9.03232 7.83775 9.03487 7.02284 8.98068 6.60941C8.95274 6.3646 8.9138 6.2045 8.84607 6.02153C8.40186 4.99742 7.56518 4.77742 6.46915 4.48922C6.20211 4.419 5.91967 4.34473 5.62376 4.2538Z" fill="white"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M11.5011 8.03461C11.7434 7.93232 13.8354 7.04938 12.0113 3.60393C12.0113 3.60393 8.67394 5.1731 9.96461 7.77459C10.0985 8.017 10.1406 8.05862 10.2227 8.1396C10.2309 8.14772 10.2396 8.15624 10.2487 8.1654C10.5181 8.44606 11.3067 8.13332 11.4902 8.03921C11.4933 8.0379 11.4969 8.03637 11.5011 8.03461Z" fill="white"/>
</g>
<defs>
<filter id="filter1_d_400_2887" x="2.81641" y="3.04143" width="13.0421" height="13.0422" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dy="0.5625"/>
<feGaussianBlur stdDeviation="0.5625"/>
<feComposite in2="hardAlpha" operator="out"/>
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.08 0"/>
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_400_2887"/>
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_400_2887" result="shape"/>
</filter>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 3.7 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 24 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 13 KiB

+1
View File
@@ -0,0 +1 @@
<svg fill="#5E6AD2" role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Linear</title><path d="M2.886 4.18A11.982 11.982 0 0 1 11.99 0C18.624 0 24 5.376 24 12.009c0 3.64-1.62 6.903-4.18 9.105L2.887 4.18ZM1.817 5.626l16.556 16.556c-.524.33-1.075.62-1.65.866L.951 7.277c.247-.575.537-1.126.866-1.65ZM.322 9.163l14.515 14.515c-.71.172-1.443.282-2.195.322L0 11.358a12 12 0 0 1 .322-2.195Zm-.17 4.862 9.823 9.824a12.02 12.02 0 0 1-9.824-9.824Z"/></svg>

After

Width:  |  Height:  |  Size: 468 B

@@ -0,0 +1 @@
<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'><rect width='32' height='32' rx='6' fill='#3b5a82'/><polyline points='7 17 13 24 25 7' fill='none' stroke='white' stroke-width='3.5' stroke-linecap='round' stroke-linejoin='round'/></svg>

After

Width:  |  Height:  |  Size: 248 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><rect width="64" height="64" rx="14" fill="#eff6ff"/><g fill="none" stroke="#2563eb" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="m32 12 18 10v20L32 52 14 42V22L32 12Zm0 20v20M14 22l18 10 18-10M23 17l18 10v9"/></g></svg>

After

Width:  |  Height:  |  Size: 311 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48"><rect width="48" height="48" rx="10" fill="white"/><svg x="6" y="6" width="36" height="36" viewBox="0 0 24 24" fill="#050038"><path d="M17.392 0H13.9L17 4.808 10.444 0H6.949l3.102 6.3L3.494 0H0l3.05 8.131L0 24h3.494L10.05 6.985 6.949 24h3.494L17 5.494 13.899 24h3.493L24 3.672 17.392 0z"/></svg></svg>

After

Width:  |  Height:  |  Size: 361 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48"><rect width="48" height="48" rx="10" fill="white"/><svg x="6" y="6" width="36" height="36" viewBox="0 0 24 24" fill="#00E599"><path d="M24 0V24l-9.365-8.045V24H0V0ZM2.942 21.087h8.751V9.563l9.365 8.204V2.919L2.942 2.914Z"/></svg></svg>

After

Width:  |  Height:  |  Size: 295 B

+9
View File
@@ -0,0 +1,9 @@
<svg xmlns:ns0="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48"><rect width="48" height="48" rx="8" fill="#fff" /><ns0:svg width="40" height="40" x="4" y="4">
<ns0:circle fill="#2A2A2A" cx="14" cy="14" r="14" />
<ns0:clipPath id="clip_0">
<ns0:path d="M3.5 4.7399Q3.7902 4.4108 4.1005 4.1005Q4.4107 3.7903 4.7398 3.5001L23.2602 3.5001Q23.5893 3.7903 23.8995 4.1005Q24.2098 4.4108 24.5 4.7399L24.5 23.2601Q24.2097 23.5892 23.8995 23.8995Q23.5892 24.2098 23.2601 24.5001L4.7399 24.5001Q4.4108 24.2098 4.1005 23.8995Q3.7902 23.5892 3.5 23.2601L3.5 4.7399Z" clip-rule="evenodd" />
</ns0:clipPath>
<ns0:g clip-path="url(#clip_0)">
<ns0:path fill="#FFF" transform="matrix(1 0 0 1 3.92798 3.50024)" d="M3.4738 3.6818C4.1265 4.212 4.3715 4.1718 5.5982 4.0895L17.1614 3.3957C17.4064 3.3957 17.2025 3.1507 17.1211 3.1104L15.1996 1.7218C14.8321 1.4365 14.3412 1.1093 13.4015 1.1906L2.2059 2.0079C1.7981 2.0482 1.7159 2.2529 1.8786 2.4156L3.4738 3.6818ZM4.1676 6.3768L4.1676 18.5428C4.1676 19.1964 4.494 19.4414 5.2299 19.4003L17.9375 18.6653C18.6734 18.625 18.7556 18.1753 18.7556 17.6442L18.7556 5.5595C18.7556 5.0293 18.5517 4.7432 18.1011 4.7834L4.8213 5.5595C4.3312 5.6007 4.1676 5.8457 4.1676 6.3759L4.1676 6.3768ZM16.7125 7.0287C16.7939 7.3962 16.7125 7.7637 16.345 7.8057L15.7325 7.9282L15.7325 16.9091C15.2005 17.1953 14.7105 17.3589 14.3019 17.3589C13.6474 17.3589 13.4837 17.1542 12.9937 16.5425L8.9889 10.2548L8.9889 16.3378L10.2559 16.6248C10.2559 16.6248 10.2559 17.3598 9.2339 17.3598L6.4146 17.5225C6.3332 17.3598 6.4146 16.9512 6.7007 16.8698L7.4358 16.6659L7.4358 8.622L6.4164 8.5398C6.3341 8.1723 6.5389 7.642 7.1103 7.6009L10.1342 7.397L14.3027 13.7661L14.3027 8.1312L13.2396 8.0095C13.1582 7.5598 13.4846 7.2334 13.8932 7.1931L16.7125 7.0287ZM1.2661 0.9054L12.9124 0.0479C14.3421 -0.0746 14.7105 0.0068 15.6091 0.6604L19.327 3.2732C19.9395 3.722 20.1442 3.8445 20.1443 4.3345L20.1443 18.6653C20.1442 19.563 19.8179 20.095 18.6743 20.1755L5.1485 20.9928C4.291 21.0339 3.8815 20.9114 3.4318 20.3392L0.6939 16.7867C0.2039 16.133 0 15.6439 0 15.0717L0 2.3334C0 1.5993 0.3273 0.9859 1.2661 0.9054L1.2661 0.9054Z" />
</ns0:g>
</ns0:svg></svg>

After

Width:  |  Height:  |  Size: 2.2 KiB

+1
View File
@@ -0,0 +1 @@
<svg fill="#7c3aed" role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Obsidian</title><path d="M19.355 18.538a68.967 68.959 0 0 0 1.858-2.954.81.81 0 0 0-.062-.9c-.516-.685-1.504-2.075-2.042-3.362-.553-1.321-.636-3.375-.64-4.377a1.707 1.707 0 0 0-.358-1.05l-3.198-4.064a3.744 3.744 0 0 1-.076.543c-.106.503-.307 1.004-.536 1.5-.134.29-.29.6-.446.914l-.31.626c-.516 1.068-.997 2.227-1.132 3.59-.124 1.26.046 2.73.815 4.481.128.011.257.025.386.044a6.363 6.363 0 0 1 3.326 1.505c.916.79 1.744 1.922 2.415 3.5zM8.199 22.569c.073.012.146.02.22.02.78.024 2.095.092 3.16.29.87.16 2.593.64 4.01 1.055 1.083.316 2.198-.548 2.355-1.664.114-.814.33-1.735.725-2.58l-.01.005c-.67-1.87-1.522-3.078-2.416-3.849a5.295 5.295 0 0 0-2.778-1.257c-1.54-.216-2.952.19-3.84.45.532 2.218.368 4.829-1.425 7.531zM5.533 9.938c-.023.1-.056.197-.098.29L2.82 16.059a1.602 1.602 0 0 0 .313 1.772l4.116 4.24c2.103-3.101 1.796-6.02.836-8.3-.728-1.73-1.832-3.081-2.55-3.831zM9.32 14.01c.615-.183 1.606-.465 2.745-.534-.683-1.725-.848-3.233-.716-4.577.154-1.552.7-2.847 1.235-3.95.113-.235.223-.454.328-.664.149-.297.288-.577.419-.86.217-.47.379-.885.46-1.27.08-.38.08-.72-.014-1.043-.095-.325-.297-.675-.68-1.06a1.6 1.6 0 0 0-1.475.36l-4.95 4.452a1.602 1.602 0 0 0-.513.952l-.427 2.83c.672.59 2.328 2.316 3.335 4.711.09.21.175.43.253.653z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 11 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48"><rect width="48" height="48" rx="10" fill="white"/><svg x="6" y="6" width="36" height="36" viewBox="0 0 24 24" fill="#FF6C37"><path d="M13.527.099C6.955-.744.942 3.9.099 10.473c-.843 6.572 3.8 12.584 10.373 13.428 6.573.843 12.587-3.801 13.428-10.374C24.744 6.955 20.101.943 13.527.099zm2.471 7.485a.855.855 0 0 0-.593.25l-4.453 4.453-.307-.307-.643-.643c4.389-4.376 5.18-4.418 5.996-3.753zm-4.863 4.861l4.44-4.44a.62.62 0 1 1 .847.903l-4.699 4.125-.588-.588zm.33.694l-1.1.238a.06.06 0 0 1-.067-.032.06.06 0 0 1 .01-.073l.645-.645.512.512zm-2.803-.459l1.172-1.172.879.878-1.979.426a.074.074 0 0 1-.085-.039.072.072 0 0 1 .013-.093zm-3.646 6.058a.076.076 0 0 1-.069-.083.077.077 0 0 1 .022-.046h.002l.946-.946 1.222 1.222-2.123-.147zm2.425-1.256a.228.228 0 0 0-.117.256l.203.865a.125.125 0 0 1-.211.117h-.003l-.934-.934-.294-.295 3.762-3.758 1.82-.393.874.874c-1.255 1.102-2.971 2.201-5.1 3.268zm5.279-3.428h-.002l-.839-.839 4.699-4.125a.952.952 0 0 0 .119-.127c-.148 1.345-2.029 3.245-3.977 5.091zm3.657-6.46l-.003-.002a1.822 1.822 0 0 1 2.459-2.684l-1.61 1.613a.119.119 0 0 0 0 .169l1.247 1.247a1.817 1.817 0 0 1-2.093-.343zm2.578 0a1.714 1.714 0 0 1-.271.218h-.001l-1.207-1.207 1.533-1.533c.661.72.637 1.832-.054 2.522zM18.855 6.05a.143.143 0 0 0-.053.157.416.416 0 0 1-.053.45.14.14 0 0 0 .023.197.141.141 0 0 0 .084.03.14.14 0 0 0 .106-.05.691.691 0 0 0 .087-.751.138.138 0 0 0-.194-.033z"/></svg></svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 23 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 13 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 29 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48"><rect width="48" height="48" rx="10" fill="white"/><svg x="6" y="6" width="36" height="36" viewBox="0 0 24 24" fill="#000000"><path d="M18.263.007c-3.121-.147-5.744 2.109-6.192 5.082-.018.138-.045.272-.067.405-.696 3.703-3.936 6.507-7.827 6.507-1.388 0-2.691-.356-3.825-.979a.2024.2024 0 0 0-.302.178V24H12v-8.999c0-1.656 1.338-3 2.987-3h2.988c3.382 0 6.103-2.817 5.97-6.244-.12-3.084-2.61-5.603-5.682-5.75"/></svg></svg>

After

Width:  |  Height:  |  Size: 481 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns:ns0="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48"><rect width="48" height="48" rx="8" fill="#fff" /><ns0:svg fill="#362D59" role="img" viewBox="0 0 24 24" x="4" y="4" width="40" height="40"><ns0:title>Sentry</ns0:title><ns0:path d="M13.91 2.505c-.873-1.448-2.972-1.448-3.844 0L6.904 7.92a15.478 15.478 0 0 1 8.53 12.811h-2.221A13.301 13.301 0 0 0 5.784 9.814l-2.926 5.06a7.65 7.65 0 0 1 4.435 5.848H2.194a.365.365 0 0 1-.298-.534l1.413-2.402a5.16 5.16 0 0 0-1.614-.913L.296 19.275a2.182 2.182 0 0 0 .812 2.999 2.24 2.24 0 0 0 1.086.288h6.983a9.322 9.322 0 0 0-3.845-8.318l1.11-1.922a11.47 11.47 0 0 1 4.95 10.24h5.915a17.242 17.242 0 0 0-7.885-15.28l2.244-3.845a.37.37 0 0 1 .504-.13c.255.14 9.75 16.708 9.928 16.9a.365.365 0 0 1-.327.543h-2.287c.029.612.029 1.223 0 1.831h2.297a2.206 2.206 0 0 0 1.922-3.31z" /></ns0:svg></svg>

After

Width:  |  Height:  |  Size: 877 B

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 36 KiB

+1
View File
@@ -0,0 +1 @@
<svg fill="#635BFF" role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Stripe</title><path d="M13.976 9.15c-2.172-.806-3.356-1.426-3.356-2.409 0-.831.683-1.305 1.901-1.305 2.227 0 4.515.858 6.09 1.631l.89-5.494C18.252.975 15.697 0 12.165 0 9.667 0 7.589.654 6.104 1.872 4.56 3.147 3.757 4.992 3.757 7.218c0 4.039 2.467 5.76 6.476 7.219 2.585.92 3.445 1.574 3.445 2.583 0 .98-.84 1.545-2.354 1.545-1.875 0-4.965-.921-6.99-2.109l-.9 5.555C5.175 22.99 8.385 24 11.714 24c2.641 0 4.843-.624 6.328-1.813 1.664-1.305 2.525-3.236 2.525-5.732 0-4.128-2.524-5.851-6.594-7.305h.003z"/></svg>

After

Width:  |  Height:  |  Size: 603 B

+1
View File
@@ -0,0 +1 @@
<svg fill="#3FCF8E" role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Supabase</title><path d="M11.9 1.036c-.015-.986-1.26-1.41-1.874-.637L.764 12.05C-.33 13.427.65 15.455 2.409 15.455h9.579l.113 7.51c.014.985 1.259 1.408 1.873.636l9.262-11.653c1.093-1.375.113-3.403-1.645-3.403h-9.642z"/></svg>

After

Width:  |  Height:  |  Size: 318 B

+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 55 55" width="55" height="55">
<path d="M38.8088 0C44.4762 0 47.3101 9.1895e-05 49.4748 1.10306C51.3788 2.07326 52.927 3.62135 53.8971 5.52544C55.0001 7.69013 55 10.524 55 16.1914V38.8088C55 44.4761 55.0001 47.3099 53.8971 49.4746C52.927 51.3786 51.3788 52.9267 49.4748 53.8969C47.3101 54.9999 44.4762 55 38.8088 55H16.1914C10.5239 55 7.69014 54.9999 5.52544 53.8969C3.62133 52.9267 2.07325 51.3787 1.10306 49.4746C0.000109958 47.3099 0 44.4762 0 38.8088V16.1912C0 10.5238 0.000107614 7.69012 1.10306 5.52544C2.07325 3.62133 3.62133 2.07326 5.52544 1.10306C7.69014 8.88436e-05 10.5239 0 16.1914 0H38.8088Z" fill="#1F1E1E"/>
<path d="M23.3863 30.405C23.0885 30.405 22.7934 30.4638 22.5183 30.5782C22.2433 30.6926 21.9933 30.8603 21.7827 31.0716L17.4534 35.4188L16.3029 34.2638C15.4573 33.415 14.0099 33.8346 13.7471 35.0064L12.1073 42.3023C12.0498 42.5532 12.0568 42.8148 12.128 43.0621C12.199 43.3085 12.3313 43.5327 12.5123 43.7136L12.5108 43.7159C12.6915 43.8987 12.9155 44.0321 13.1619 44.1037C13.4083 44.1752 13.6688 44.1824 13.9188 44.1246L21.1862 42.4776C22.3527 42.2137 22.7715 40.7614 21.9259 39.9124L20.7754 38.7574L25.1056 34.411C25.5306 33.9842 25.7695 33.4049 25.7695 32.8013C25.7695 32.1977 25.5306 31.6185 25.1056 31.1916L25.0476 31.1334L25.0497 31.1319L24.9896 31.0716C24.7791 30.8603 24.529 30.6926 24.254 30.5782C23.979 30.4638 23.684 30.4051 23.3863 30.405Z M39.0054 27.6663C37.9941 27.0245 36.6768 27.7546 36.6766 28.9556V30.5884H27.3981C27.7081 31.1832 27.8847 31.8596 27.8847 32.5774C27.8846 33.6152 27.5181 34.5672 26.908 35.3099L36.6758 35.3096L36.6752 36.9424C36.6753 38.1434 37.9941 38.8736 39.0054 38.2319L45.3045 34.2387C45.7757 33.9384 46.0114 33.443 46.0115 32.948C46.0109 32.4536 45.7752 31.9592 45.303 31.6601L39.0054 27.6663Z M23.6104 10.2206C23.3539 10.2194 23.101 10.2839 22.8763 10.4081C22.6514 10.5325 22.4614 10.7132 22.3253 10.9318L18.3472 17.2549H18.3458C17.7067 18.2701 18.4346 19.5933 19.6307 19.5936H21.2578V28.9869C21.9327 28.5406 22.7403 28.2801 23.609 28.2801C24.4781 28.2801 25.2864 28.5407 25.9615 28.9875V19.5936H27.5884C28.7849 19.5936 29.5112 18.2702 28.8728 17.2541L24.8939 10.9318C24.5951 10.4584 24.1029 10.2212 23.6104 10.2206Z" fill="#FFFCF6"/>
</svg>

After

Width:  |  Height:  |  Size: 2.2 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 5.3 KiB

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48"><image width="48" height="48" href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAMAAACdt4HsAAAADnRFWHRTb2Z0d2FyZQBGaWdtYZ6xlmMAAAAJcEhZcwAACxMAAAsTAQCanBgAAAABc1JHQgCuzhzpAAAABGdBTUEAALGPC/xhBQAAAGlQTFRF////+fz+6vP98vj+/z4C7vX94O38/v7/5fD99/r+BKX/2uv8/0Ir/kEWBIn/BZn/A5L/9ezvA6D/+vX29eHiyuX+A37/79PV9Fc4N7P9sdv9gWeB/nJchs/+Xr/95J+X9Lmy/ox72HNhQ6PlPQAABHlJREFUWMOFlol6ozoMhY2pF+JsBNqQjaR9/4e8WrwDd5QpX4Zwjn7JxrYQQhiRwrQQJr8jqieEwWhCVD+KBvSq1ota3wSDrqv8jWkVENRJa32UN139eyIw2wbGyzHq54BAtWazA0ZEeG9R2weCrfwitc8T1A4KmtBsGBjh8bsUJSn8TsO4On6GywfRAJ8VA35GbQ8jpR8gerygSZsZsAh7oJYTKdCDahiuv8/fDzpgtIsHicCsjV1HyT+/z2m/fwJE22G5FUCaSEUPAvz4fk4g3++nHh1yg/D8Wgkh+5WSU/yBgVoYmOU8MDx1mX0K8v10HVcJDL8L2AOTzS2Sx+QEMI6rBDjPqAcmFR9Kz+T76U0GCidd1igmoJkY3yYs/lrJuYJeIYFSoqsC7qn4H2DPOkdx2B+m9xUIrKQQrQ9F//Ci0IOi78dMDlIIuEzX6+g0hIU/QYUo1qO0jV9lP75/n6wt4g8MdA9yMsgSF9khPUy6Snr4gs/0gQqcB9ACXgj88AVbwI5U/L6QHr4oAAA6oK0P0fgl0uASA98wOdgMMPApax6HD46BVRI/0ERTBswDJQHi+sScpZSDAHqqEh3COkKziA2kbNV9fnytqQHgjQAydEqmOexZ4J5Vbt7tHuv6iQCkBwCDuAfwlwb0WoJ+w6EEkIEgQTTSWn2/7bYcMgDFMzGouRlNBwDfBHC5XB4rACPOYqb3TfRytmg6q93rxvrT6fFPABnXc15OmhYAon7hcPgbaQylV0s/jL6LuOi3ALCLBudz6TB9CEAiPXlY6SuIAMplAOdz6YAd6PE9TghWGBM3JdiyWhkAvP58fBQd6HsiCHIwSPxooGQJcIR4lABUAIlRb0MJHkDZGuB4/HnkAIMsAGxau6kDKwDHH+9AAEM0gFcZ/+K2VwCcLgxAeu9weBLAEHJTfk/gTy0IMOcVcH6MBy5EBNCG/NIvKH4ZwH0fARYV/LDF40BTYOhan1+GFSk2oMMxnAv9OQBA0Ajgnp6vmxBxRSIDeA0vVQXeYnY96/Folx9y4skFWyjn3aICiuPdOS2HoVuGqACKWcAOx+P8DXor227bIBvDuoTj7c561RL/hgE2hyYRNKEgOKEc+EH/vwTYAQ+wy0bh9CI5pN/QdyJV4LiFWQ+iHNOv6dtoAAcu+bpFAnC4nKD2f6THHVHwsRcKsLOXk8NlBjnrLRKkM4PKFkRc0hreUqW9BwA0ITkbaN5HY1b4rrMQNIfR4BXlt9d3LZcyAqCB9Xu7dlr4Y4399gC32ytk92peexKBzgkcGuBZKczC+Q6Ry70+laAzB5cZuBuzs1yzPK4dWQ90ptYuGcCOyOisZzWl51c/J0gOuYF1GGQQ5V68ScAGdMZCBE13oPwkp2sxjCrIWe/4nEgzw9II401r07odlq6SwPGD0YAd6Fm8KZf6wsB5fp0T+MlpcQCqlZumTlmCiyH4lJss0DzbO4JeJwMuviwhnrnAAMeQdy3rV39dETjtEoLgE25yQD6efDJkp6kXXmCVxPTlP1N/fjxAahXjAAAAAElFTkSuQmCC"/></svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48"><image width="48" height="48" href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAAAXNSR0IArs4c6QAAAHhlWElmTU0AKgAAAAgABAEaAAUAAAABAAAAPgEbAAUAAAABAAAARgEoAAMAAAABAAIAAIdpAAQAAAABAAAATgAAAAAAAABIAAAAAQAAAEgAAAABAAOgAQADAAAAAQABAACgAgAEAAAAAQAAACCgAwAEAAAAAQAAACAAAAAAnwlWxAAAAAlwSFlzAAALEwAACxMBAJqcGAAAA8dJREFUWAntl1uMTVcYx9cxOi5NG8S9xogRPCARGpcgEkLjEhpDKeKWENoeSV9IkIiQCG8TTeMSmmyhoiVI04dq3Zo4JI3hSWtjXELELRrpMDqz/P5772X2mdnOOURyXvyT//4u61vr+9b15KSstaaYaFHM5Mr9voDUG21B2rYh/ks4BQ6DnaHGuA/PwaNwv6lK/YcsCIUXkLbzGHEr7JpnZBWziiL25IkLmvMXkLYfELkdLkoY8Dm+BqiVaYrdOJZRyP9NG+J2IYdwBx3iya9hfwXLGLw1bBvoxnyNvA4dFqOob07kXoG0nUPvfbERdqF/Q9JnMV+jmratMb6HCxudZj7xe2N2lvr6AtK2lMga2C3qsZOBlkb660Xaakwt/8Io6B6ynL7armbItQWziHbJb6GvbNY7yVGV0tO6AtbAv+FlOBQmomWiN3ROjrVVMYPamJ1bVWza9qZP3ne++RZk/EGMPtNc+2gC8kPzrKTOdK391XR4/p0Z3udO7sxRq2e7o+najoTt4RN4ER4081OXkK/QWEDGV6AO0BevWrOVF5ib4QYKSb5aXnBl1xGzGur6JuEAzuUU8liNYQEZX3t9EvaF+XCCgJkU8TAr0LO6jofgxCx/svEP7rEUcTdlMr6KOA1HRbHat33wR3gb9oc6VKOhg4/yGUVcDRye7YT8BX4a2OHnDGIbVLIeUCs7F7pV/xN9jAqYjnIYCvVwBgMfCSz3CYv8FnMLdDenmrjBJkz+B/4BLhypLdjCDLMPoWen4f8ZlkDhcxWgH5CpgWnMJgZdG+nNRcbXAPvhIzjWXKnQg6Qt6QOFBriExD/ISIRnN+JfE7UdUwHayw6Ro5wCbkZ6ssj4Q2h4QnKt1m+wIgrUIZ1L8oORnSw825OGG1HjIxXglqmO5K2SezXxelYzPgk/iVqUvJLkWs388KxexVIFaj+fSgGlFNMuVHN8PTuQ1lPQJdcDNekNkitHkBz5VAVUQ4fZTkmUYXIdOD00goqfQvLjgVXYJ56jWgXoYXDYyCq4A+V8ofTsMBRdrY5Rw7/I8SRXQYUh3DodQocDOgP6Cb0MyyPvA+R6+BNnQr9khqs2iq/u+ccygWImk/x8YOX7eLYLIZVwPXQT0EHsHz4KGX8Exu+wDYyj1tSXXDU1PStMQ4t4Wx1B2vtCoP2O91Uf9R3HBM6GBciV8Yfy1RXqBUPUl1wxN8rKKEKr9K5Qw0C6MX9pwMYCZIXbsQCtkqQdmXm/JjNX1NtAW3YB6sXdTXJdwwCp93/N3FIUS+odKCqKXsBLgeL6DyOweJoAAAAASUVORK5CYII="/></svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48"><rect width="48" height="48" rx="10" fill="white"/><svg x="6" y="6" width="36" height="36" viewBox="0 0 24 24" fill="#E44332"><path d="M21 0H3C1.35 0 0 1.35 0 3v3.858s3.854 2.24 4.098 2.38c.31.18.694.177 1.004 0 .26-.147 8.02-4.608 8.136-4.675.279-.161.58-.107.748-.01.164.097.606.348.84.48.232.134.221.502.013.622l-9.712 5.59c-.346.2-.69.204-1.048.002C3.478 10.907.998 9.463 0 8.882v2.02l4.098 2.38c.31.18.694.177 1.004 0 .26-.147 8.02-4.609 8.136-4.676.279-.16.58-.106.748-.008.164.096.606.347.84.48.232.133.221.5.013.62-.208.121-9.288 5.346-9.712 5.59-.346.2-.69.205-1.048.002C3.478 14.951.998 13.506 0 12.926v2.02l4.098 2.38c.31.18.694.177 1.004 0 .26-.147 8.02-4.609 8.136-4.676.279-.16.58-.106.748-.009.164.097.606.348.84.48.232.133.221.502.013.622l-9.712 5.59c-.346.199-.69.204-1.048.001C3.478 18.994.998 17.55 0 16.97V21c0 1.65 1.35 3 3 3h18c1.65 0 3-1.35 3-3V3c0-1.65-1.35-3-3-3z"/></svg></svg>

After

Width:  |  Height:  |  Size: 963 B

+1
View File
@@ -0,0 +1 @@
<svg t="1781765469788" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="947" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M51.2 921.6v-2.56 2.56zM10.24 837.12c0-2.56-2.56-7.68-2.56-10.24 0 2.56 0 7.68 2.56 10.24zM23.04 878.08c-2.56-5.12-5.12-12.8-7.68-17.92 2.56 7.68 5.12 12.8 7.68 17.92zM43.52 908.8c-2.56-2.56-5.12-7.68-7.68-10.24 2.56 5.12 5.12 7.68 7.68 10.24zM33.28 896l-7.68-15.36 7.68 15.36zM15.36 860.16c-2.56-5.12-2.56-10.24-5.12-15.36 2.56 5.12 2.56 10.24 5.12 15.36zM806.4 0H215.04C97.28 0 2.56 94.72 2.56 207.36V537.6l325.12-12.8c-2.56-7.68-2.56-17.92-2.56-28.16 0-89.6 64-163.84 148.48-179.2l17.92-171.52c0-7.68 5.12-15.36 15.36-15.36 7.68 0 15.36 7.68 15.36 15.36l17.92 171.52c87.04 15.36 151.04 89.6 151.04 179.2 0 7.68 0 17.92-2.56 25.6l327.68 17.92v51.2l-115.2 10.24c5.12 10.24 10.24 23.04 10.24 33.28 0 35.84-30.72 66.56-66.56 66.56-35.84 0-66.56-30.72-66.56-66.56 0-7.68 2.56-15.36 5.12-23.04l-143.36 12.8c-33.28 35.84-79.36 56.32-133.12 56.32s-99.84-23.04-133.12-58.88l-125.44-10.24c2.56 7.68 5.12 15.36 5.12 23.04 0 35.84-30.72 66.56-66.56 66.56-35.84 0-66.56-30.72-66.56-66.56 0-12.8 2.56-23.04 10.24-33.28l-128-10.24v207.36c38.4-15.36 81.92-23.04 125.44-23.04 112.64 0 209.92 48.64 261.12 122.88 25.6-15.36 58.88-25.6 94.72-25.6 61.44 0 112.64 25.6 138.24 64 23.04-12.8 51.2-20.48 87.04-20.48 12.8 0 23.04 0 35.84 2.56 33.28-40.96 81.92-66.56 138.24-66.56 40.96 0 76.8 12.8 107.52 35.84-17.92 33.28-43.52 58.88-71.68 76.8 58.88-35.84 99.84-102.4 99.84-176.64V207.36c0-112.64-94.72-207.36-212.48-207.36z" fill="#5096FA" p-id="948"></path><path d="M604.16 442.88c-2.56-40.96-48.64-71.68-99.84-71.68-48.64 0-94.72 28.16-99.84 69.12 28.16 5.12 61.44 12.8 99.84 12.8s71.68-5.12 99.84-10.24z" fill="#5096FA" p-id="949"></path><path d="M35.84 898.56c0-2.56-2.56-2.56-2.56-5.12 0 2.56 0 5.12 2.56 5.12zM51.2 919.04c-2.56-2.56-5.12-7.68-7.68-10.24 2.56 5.12 5.12 7.68 7.68 10.24zM215.04 998.4c-64 0-122.88-30.72-163.84-76.8 40.96 48.64 99.84 79.36 166.4 79.36h581.12c43.52 0 84.48-12.8 117.76-35.84-33.28 20.48-71.68 33.28-112.64 33.28h-588.8zM10.24 842.24c0-2.56 0-5.12-2.56-5.12 2.56 2.56 2.56 5.12 2.56 5.12zM2.56 798.72c0 10.24 2.56 17.92 2.56 25.6 0-5.12 0-15.36-2.56-25.6zM15.36 860.16zM25.6 878.08c-2.56 0-2.56 0 0 0-2.56 0-2.56 0 0 0z" fill="#FFFFFF" p-id="950"></path></svg>

After

Width:  |  Height:  |  Size: 2.3 KiB

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><rect width="64" height="64" rx="14" fill="#ecfdf5"/><g fill="none" stroke="#059669" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><rect x="12" y="14" width="40" height="35" rx="4"/><path d="M12 24h40M19 19h.01M24 19h.01m-2 16 6 6 13-13"/></g></svg>

After

Width:  |  Height:  |  Size: 330 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48"><rect width="48" height="48" rx="10" fill="white"/><svg x="6" y="6" width="36" height="36" viewBox="0 0 24 24" fill="#4353FF"><path d="m24 4.515-7.658 14.97H9.149l3.205-6.204h-.144C9.566 16.713 5.621 18.973 0 19.485v-6.118s3.596-.213 5.71-2.435H0V4.515h6.417v5.278l.144-.001 2.622-5.277h4.854v5.244h.144l2.72-5.244H24Z"/></svg></svg>

After

Width:  |  Height:  |  Size: 393 B

+12
View File
@@ -0,0 +1,12 @@
<svg viewBox="-1 0 34 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<!-- 蓝色聊天气泡 -->
<path d="M13.053 2.7c7.033 0 12.754 4.954 12.754 11.084 0 .224-.01.445-.021.667l-.01.136-.102-.09a2.554 2.554 0 0 0-2.532-.504l-.091.03v-.451c-.16-4.558-4.573-8.24-10-8.24-1.13 0-2.217.16-3.232.454l-.43.133C5.673 7.164 3.05 10.241 3.05 13.821c0 2.52 1.3 4.788 3.365 6.348l.094.081a.9.9 0 0 1 .263.667l-.012.124-.351 1.996a.172.172 0 0 0 .254.178l2.293-1.323.085-.042c.2-.088.424-.1.633-.037l.4.115a11.53 11.53 0 0 0 2.98.385 11.59 11.59 0 0 0 3.437-.518l.073-.021.012.076a2.57 2.57 0 0 0 1.63 1.956l.166.064-.167.063a14.35 14.35 0 0 1-5.151.942 14.48 14.48 0 0 1-3.792-.5l-3.79 1.848a.96.96 0 0 1-1.374-.963l.345-3.292C1.9 19.938.3 17.025.3 13.788.3 7.653 6.018 2.7 13.054 2.7z" fill="#0082EF"/>
<!-- 绿色节点 -->
<path fill-rule="evenodd" clip-rule="evenodd" d="M25.29 15.105c.315.315.493.714.533 1.13h.003a4.591 4.591 0 0 0 1.42 2.798l.024.024a.467.467 0 0 1-.009.648.459.459 0 0 1-.648.01l-.024-.025c-.03-.036-.064-.07-.094-.103a4.629 4.629 0 0 0-2.686-1.32 1.877 1.877 0 0 1-1.148-.533 1.853 1.853 0 0 1 0-2.626 1.853 1.853 0 0 1 2.626 0l.003-.003z" fill="#2DBC00"/>
<!-- 蓝色节点 -->
<path fill-rule="evenodd" clip-rule="evenodd" d="M30.275 22.61a1.836 1.836 0 0 1-1.13.533h-.003a4.624 4.624 0 0 0-2.701 1.326 1.291 1.291 0 0 0-.091.097l-.025.024a.467.467 0 0 1-.648-.009.459.459 0 0 1-.009-.648l.024-.024c.037-.03.07-.063.103-.094a4.63 4.63 0 0 0 1.32-2.686 1.877 1.877 0 0 1 .533-1.148 1.854 1.854 0 0 1 2.627 0 1.854 1.854 0 0 1 0 2.626v.003z" fill="#0082EF"/>
<!-- 橙色节点 -->
<path fill-rule="evenodd" clip-rule="evenodd" d="M22.773 27.595a1.836 1.836 0 0 1-.533-1.13h-.003a4.592 4.592 0 0 0-1.324-2.707.994.994 0 0 0-.097-.091l-.024-.024a.467.467 0 0 1 .01-.649.459.459 0 0 1 .647-.009l.025.025c.03.036.063.07.093.102a4.628 4.628 0 0 0 2.687 1.321 1.877 1.877 0 0 1 1.148.533 1.853 1.853 0 0 1 0 2.626 1.854 1.854 0 0 1-2.626 0l-.003.003z" fill="#FB6500"/>
<!-- 黄色节点 -->
<path fill-rule="evenodd" clip-rule="evenodd" d="M17.785 20.093a1.836 1.836 0 0 1 1.13-.533h.002a4.625 4.625 0 0 0 2.702-1.327c.033-.033.067-.066.09-.097l.025-.024a.467.467 0 0 1 .648.01c.182.181.185.46.01.647l-.025.025c-.036.03-.07.063-.103.093a4.63 4.63 0 0 0-1.32 2.687 1.877 1.877 0 0 1-.533 1.148 1.854 1.854 0 0 1-2.626 0 1.853 1.853 0 0 1 0-2.626v-.003z" fill="#FC0"/>
</svg>

After

Width:  |  Height:  |  Size: 2.4 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 22 KiB

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48"><image width="48" height="48" href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAFoAAABaCAYAAAA4qEECAAAACXBIWXMAACE4AAAhOAFFljFgAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAOdEVYdFNvZnR3YXJlAEZpZ21hnrGWYwAABRxJREFUeAHtnaFTG0EUxh9MLQy1YK4KJMyAT1EoKAYDM23BgKIGFDOFwaFSBQamzIDBdFDgGoMCgQTVGNAM/AF0v8tee9lsQjbZt7m929/MJtcjaciXl7dvv+PuEQUCgYAxfdQBr6+vkbgriTEkRxF4EqOK0dfXd0uGtC20EBeCrovxRYyIik1VjJ9iHAvRq+08oS2hhcgQeJtk9N7f39P19TU9Pj7Sy8sLFYHh4WEaGBigqakpGh0dTXYjystC7J23nt9SaBnFv6iWJuj8/JxOT0/p7u6OigxEX1tbo7m5uWRXRYyvraK7qdBS5N9ijCNy19fX40gO/AeCHx0dxfdUSycTQuwn3WNbCQ2RSxB3eXm5MCnCFKSTw8NDGhsbwz8rQuiPusf163YKkb+LuxIiOYjcGmizsrKSpNOS0O6b7nENES1LN0RzNDMzE094gbdB+ri8vMQmUscHNYXoIvqTGBEmviBy+0Cr/f19bGJua4hqXUT/oRDNHYF8fXV1hc0nEdHv0z+ri2iZNiJMgEFkc5Cvb25usDkktfyHmjrGcYPFSKAzUmuMUnq/KnSEm1BldE4qE0Tp/arQQ8qDA4Y0C9J+CjghCO2IILQjgtCOeEeWgGWYsg3Z2dvbq7Nrl5aWaHp6mmyDUleu+LrCmtBY609OTpIL8MbTIsOM39zcJNug+oIVYQPvUkfKU4gZGRmh3d1d4gCvY6vU9U5o2LZpVldXE+PdKohkW9EMvBJajTDkZY55Qf3W2MAboWHWqCkDx+04wGE726tjL4SGyFtbW3X7cPgItqRt8GFyHBu1VnUgn0mL0CrPz88NbxyRzJGXOVJGgjWh8Uu6MKO4UgbMIHWitYlXk+Hg4GCcMjiwWcrp8EpozlLu5OSEOPFGaJRxKOdsw5mX03ghNGcpx50yErwQGktsjpSBdGFz9deKzAuNSOYwqxDFcABdkWmhOVMGZymnI9NC+1rK6cis0FyrP9UzcUUmhYaRz5EyEMWqZ+KKzAnti5FvSuaE9sXINyVTQvtk5JuSGaF9M/JNyYzQvhn5pmRCaB+NfFN6LrSvRr4pPRXaZyPflJ4K7bORb0rPhPbdyDelJ0Lnwcg3pSdC58HIN8W50Hkx8k1xKnSejHxTnApdlFJOhzOh82bkm+JE6Dwa+aawC51XI98UdqHzauSbwip0no18U9iEzruRb4q1v49WcWnk48JRHK+FD/Ph4YFswCK0KyM/mWi5zm/Ea9lKUdZTh0sjf2Njw9lJpN1iVWiXRj4+TI5TkrmwKrQrI59zouXCmtAujXyubw0nVoR2aeRzTbTcWKk6ZmdnWU5/wyUc0qs/fKDwTTjOZ9Rh8/1YEdpmGdQK1LRZ952bEa5A44ggtCOC0I4IQjtCFTq+5jGHQVMUmmmnFdrHOjUrpLSr69WiCl3BDWrVQGektKum99cJLdtbVNFnRDYHCBiABZXs0dLQfUg3GR7jZnFxkQJmwFSTHKs/0116Hpc2xuXnhxYWFgrf3KZdEM0XFxfYxDw3oTa/aYho2W0hbklULpdjjznQGsWH/6HrMNRWwxv0GcFFpAKNJCLL3GzW8EYyL8Yt/oOzs7P4qxGoBwUDtEkmQKpppsW4KdnBwYG1I8O+AmGVv1mpiDHfrE8WaLfN3ra4+0yyQUDSZg8HTIvSDwArPixGNG32doTA5beeb9I4MqJa1yH0Noyo2FSpVsKVW0Vxmm5aoaJnC+6L1AoVo9JuV85AIBDokr8WvnnavEglMwAAAABJRU5ErkJggg=="/></svg>

After

Width:  |  Height:  |  Size: 2.0 KiB

@@ -0,0 +1,8 @@
import { expect, it, vi } from 'vitest'
import { api } from '@/api/client'
import { composerReferencesApi } from './composerReferences'
vi.mock('@/api/client', () => ({ api: { get: vi.fn() } }))
it('requests available capabilities for the exact workspace without credentials or catalog installation', () => {
composerReferencesApi.list('/repo/中文 folder')
expect(api.get).toHaveBeenCalledWith('/api/skills/mentions?cwd=%2Frepo%2F%E4%B8%AD%E6%96%87%20folder')
})
+9
View File
@@ -0,0 +1,9 @@
import { api } from './client'
import type { ComposerReferenceCandidate } from '@/types/composerReference'
export const composerReferencesApi = {
list(cwd?: string) {
const query = cwd ? `?cwd=${encodeURIComponent(cwd)}` : ''
return api.get<{ skills: ComposerReferenceCandidate[], plugins: ComposerReferenceCandidate[] }>(`/api/skills/mentions${query}`)
},
}
+10
View File
@@ -0,0 +1,10 @@
import { expect, it, vi } from 'vitest'
import { api } from '@/api/client'
import { connectorsApi } from './connectors'
vi.mock('@/api/client', () => ({ api: { get: vi.fn(), post: vi.fn() } }))
it('passes explicit shared-credential acknowledgment only in the requested action', () => {
connectorsApi.action('feishu', 'authenticate', { acknowledgeSharedCredentials: true })
expect(api.post).toHaveBeenCalledWith('/api/connectors/feishu/authenticate', { acknowledgeSharedCredentials: true })
connectorsApi.list()
expect(api.get).toHaveBeenCalledWith('/api/connectors', { signal: undefined })
})
+9
View File
@@ -0,0 +1,9 @@
import { api } from '@/api/client'
import type { ConnectorAction, ConnectorActionOptions, ConnectorDto, ConnectorId } from '@/types/connector'
export const connectorsApi = {
list: (signal?: AbortSignal) => api.get<{ items: ConnectorDto[] }>('/api/connectors', { signal }),
detail: (id: ConnectorId, signal?: AbortSignal) => api.get<{ connector: ConnectorDto }>(`/api/connectors/${encodeURIComponent(id)}`, { signal }),
action: (id: ConnectorId, action: ConnectorAction, options: ConnectorActionOptions = {}) =>
api.post<{ connector: ConnectorDto }>(`/api/connectors/${encodeURIComponent(id)}/${action}`, options),
}
+50 -2
View File
@@ -19,6 +19,7 @@ const mocks = vi.hoisted(() => ({
getGitInfo: vi.fn(),
getSlashCommands: vi.fn(),
listAgents: vi.fn(),
listReferences: vi.fn(),
getRepositoryContext: vi.fn(),
createRepositoryBranch: vi.fn(),
getRecentProjects: vi.fn(),
@@ -44,6 +45,10 @@ vi.mock('../../api/sessions', () => ({
},
}))
vi.mock('../../api/composerReferences', () => ({
composerReferencesApi: { list: mocks.listReferences },
}))
vi.mock('../../api/agents', () => ({
agentsApi: {
list: mocks.listAgents,
@@ -288,6 +293,9 @@ describe('ChatInput file mentions', () => {
mocks.getMessages.mockResolvedValue({ messages: [] })
mocks.getSlashCommands.mockResolvedValue({ commands: [] })
mocks.listAgents.mockResolvedValue({ activeAgents: [], allAgents: [] })
mocks.listReferences.mockResolvedValue({ skills: [], plugins: [] })
mocks.browse.mockResolvedValue({ currentPath: '/repo', parentPath: null, entries: [] })
mocks.search.mockResolvedValue({ currentPath: '/repo', parentPath: null, entries: [] })
})
afterEach(() => {
@@ -1675,6 +1683,46 @@ describe('ChatInput file mentions', () => {
},
)
it.each(['@', '/'])('selects an installed plugin with %s as a clickable chip before sending its explicit identity', async (trigger) => {
const modelText = 'Use plugin "hyperframes@curated" and its available skills for this request.'
mocks.listReferences.mockResolvedValue({ skills: [], plugins: [{
kind: 'plugin', id: 'hyperframes@curated', name: 'hyperframes', displayName: 'HyperFrames',
description: 'Create HTML videos', source: 'curated', icon: '/connectors/hyperframes.svg', modelText,
}] })
render(<ChatInput compact />)
setComposerText(`${trigger}hyper`, 6)
fireEvent.click(await screen.findByRole('option', { name: /hyperframes/i }))
await waitFor(() => expect(document.querySelector('[data-mention-kind="plugin"]')).toBeInTheDocument())
expect(mocks.wsSend).not.toHaveBeenCalled()
const chip = document.querySelector('[data-mention-kind="plugin"]') as HTMLElement
expect(chip.querySelector('img')).toHaveAttribute('src', '/connectors/hyperframes.svg')
fireEvent.keyDown(chip, { key: 'Enter' })
expect(await screen.findByRole('dialog', { name: 'HyperFrames' })).toHaveTextContent('Create HTML videos')
expect(mocks.wsSend).not.toHaveBeenCalled()
fireEvent.click(screen.getByRole('button', { name: 'Close dialog' }))
fireEvent.keyDown(getComposerElement(), { key: 'Enter' })
expect(mocks.wsSend).toHaveBeenCalledWith(sessionId, {
type: 'user_message', content: modelText, attachments: [],
})
})
it('selects an exact slash skill on Enter without executing and preserves its canonical identity', async () => {
mocks.listReferences.mockResolvedValue({ plugins: [], skills: [{
kind: 'skill', id: 'skill:team:review', name: 'team:review', displayName: 'Review',
description: 'Review a change', source: 'plugin', modelText: 'Use the Skill tool with skill: "team:review" for this request.',
}] })
render(<ChatInput compact />)
setComposerText('/team:review', 12)
await screen.findByRole('option', { name: /Review/i })
fireEvent.keyDown(getComposerElement(), { key: 'Enter' })
await waitFor(() => expect(document.querySelector('[data-mention-kind="skill"]')).toBeInTheDocument())
expect(mocks.wsSend).not.toHaveBeenCalled()
fireEvent.keyDown(getComposerElement(), { key: 'Enter' })
expect(mocks.wsSend).toHaveBeenCalledWith(sessionId, {
type: 'user_message', content: 'Use the Skill tool with skill: "team:review" for this request.', attachments: [],
})
})
it('inserts a selected @ file as an inline mention pill and sends its absolute path', async () => {
mocks.search.mockResolvedValueOnce({
currentPath: '/repo/backend/src',
@@ -1690,7 +1738,7 @@ describe('ChatInput file mentions', () => {
const mention = '@backend/src/conditions.py'
setComposerText(`${mention} 记一下这个文件讲了什么东西。`, mention.length)
fireEvent.click(await screen.findByText('backend/src/conditions.py'))
fireEvent.click(await screen.findByRole('option', { name: 'conditions.py' }))
// The trigger text becomes an inline pill — no attachment chip is added.
await waitFor(() => {
@@ -2174,7 +2222,7 @@ describe('ChatInput file mentions', () => {
setComposerText('@cond', 5)
expect(await screen.findByText('conditions.py')).toBeInTheDocument()
const fileSearchMenu = document.getElementById('file-search-menu')
const fileSearchMenu = screen.getByRole('listbox', { name: 'References' })
expect(fileSearchMenu).toHaveClass('min-w-0')
expect(fileSearchMenu).not.toHaveClass('min-w-[480px]')
expect(fileSearchMenu).not.toHaveTextContent('Navigate')
+89 -33
View File
@@ -24,7 +24,10 @@ import { AttachmentGallery } from './AttachmentGallery'
import { ComposerDropOverlay } from './ComposerDropOverlay'
import { ProjectContextChip } from '@/components/chat/ProjectContextChip'
import { RepositoryLaunchControls } from '@/components/chat/RepositoryLaunchControls'
import { FileSearchMenu, type FileSearchMenuHandle } from './FileSearchMenu'
import { ComposerReferenceMenu, type ComposerReferenceMenuHandle } from './ComposerReferenceMenu'
import { ComposerReferenceDetail } from './ComposerReferenceDetail'
import { composerReferencesApi } from '@/api/composerReferences'
import type { ComposerReferenceCandidate } from '@/types/composerReference'
import { LocalSlashCommandPanel, type LocalSlashCommandName } from './LocalSlashCommandPanel'
import { getSlashCommandOptionId, SlashCommandMenu } from './SlashCommandMenu'
import { ContextUsageIndicator } from './ContextUsageIndicator'
@@ -52,6 +55,7 @@ import { useComposerFileDrop } from './useComposerFileDrop'
import { shouldSubmitOnEnter } from './sendShortcut'
import { MentionComposer, type MentionComposerHandle } from './MentionComposer'
import {
composerReferenceToMention,
findMentionRanges,
insertMentionIntoText,
type ComposerMention,
@@ -69,6 +73,8 @@ type ChatInputProps = {
compact?: boolean
}
const EMPTY_COMPOSER_REFERENCES: ComposerReferenceCandidate[] = []
const EMPTY_WORKSPACE_REFERENCES: WorkspaceChatReference[] = []
/**
@@ -131,6 +137,9 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
const [shellRef, shellWidth] = useElementWidth<HTMLDivElement>()
const [input, setInput] = useState('')
const [mentions, setMentions] = useState<ComposerMention[]>([])
const [referenceDetail, setReferenceDetail] = useState<ComposerMention | null>(null)
const [referenceOptionId, setReferenceOptionId] = useState<string | undefined>()
const [referenceState, setReferenceState] = useState<{ context: string, items: ComposerReferenceCandidate[], loading: boolean, error: boolean } | null>(null)
const [attachments, setAttachments] = useState<Attachment[]>([])
const [plusMenuOpen, setPlusMenuOpen] = useState(false)
const [slashMenuOpen, setSlashMenuOpen] = useState(false)
@@ -153,9 +162,10 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
const modelSelectorRef = useRef<ModelSelectorHandle>(null)
const plusMenuRef = useRef<HTMLDivElement>(null)
const slashMenuRef = useRef<HTMLDivElement>(null)
const fileSearchRef = useRef<FileSearchMenuHandle>(null)
const fileSearchRef = useRef<ComposerReferenceMenuHandle>(null)
const slashItemRefs = useRef<(HTMLElement | null)[]>([])
const slashMenuId = useId()
const referenceMenuId = useId()
const previousActiveTabIdRef = useRef<string | null>(null)
const inputRef = useRef(input)
const mentionsRef = useRef(mentions)
@@ -280,6 +290,28 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
const fitsAtLeast = (minWidth: number) => shellWidth === null ? !compact : shellWidth >= minWidth
const useCompactControls = isMobileComposer || !fitsAtLeast(TOOLBAR_LOCATION_MIN_WIDTH)
const activeLaunchWorkDir = showLaunchControls ? (launchWorkDir || resolvedWorkDir || '') : (resolvedWorkDir || '')
const referenceCwd = activeLaunchWorkDir || resolvedWorkDir || ''
const referenceContext = `${activeTabId ?? ''}\0${referenceCwd}`
const referenceCurrent = referenceState?.context === referenceContext ? referenceState : null
const composerReferences = referenceCurrent?.items ?? EMPTY_COMPOSER_REFERENCES
useEffect(() => {
let active = true
if (isMemberSession) return
setReferenceState(previous => ({ context: referenceContext, items: previous?.context === referenceContext ? previous.items : [], loading: true, error: false }))
void composerReferencesApi.list(referenceCwd || undefined).then(data => {
if (active) setReferenceState({ context: referenceContext, items: [...data.plugins, ...data.skills], loading: false, error: false })
}).catch(() => {
if (active) setReferenceState({ context: referenceContext, items: [], loading: false, error: true })
})
return () => { active = false }
}, [referenceContext, referenceCwd, isMemberSession, slashMenuOpen, fileSearchOpen])
useEffect(() => {
setReferenceDetail(null)
setReferenceOptionId(undefined)
setFileSearchOpen(false)
setSlashMenuOpen(false)
}, [referenceContext])
// The run location lives in the toolbar on the wide desktop composer, and it
// stays there for the whole session: editable while the session is still a
// draft, read-only once the first message lands. It used to jump from inside
@@ -525,19 +557,23 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
// when it is absent preserves the original behavior: with no menu in the
// DOM, an outside press was ignored.
isExempt: (target) => {
const menu = document.getElementById('file-search-menu')
const menu = document.getElementById(referenceMenuId)
if (!menu) return true
return target instanceof Node && menu.contains(target)
},
})
const allSlashCommands = useMemo(
() => appendAgentSlashCommands(
mergeSlashCommands(slashCommands, getLocalizedFallbackCommands(t)),
agentSlashCommands,
),
[agentSlashCommands, slashCommands, t],
)
const allSlashCommands = useMemo(() => {
const commands = appendAgentSlashCommands(mergeSlashCommands(slashCommands, getLocalizedFallbackCommands(t)), agentSlashCommands)
const names = new Set(commands.map(command => command.name.toLowerCase()))
for (const reference of composerReferences) {
const name = reference.kind === 'plugin' ? reference.id : reference.name
if (names.has(name.toLowerCase())) continue
names.add(name.toLowerCase())
commands.push({ name, description: reference.description, kind: reference.kind })
}
return commands
}, [agentSlashCommands, slashCommands, composerReferences, t])
const filteredCommandGroups = useMemo(() => {
return groupSlashCommands(filterSlashCommands(allSlashCommands, slashFilter))
@@ -545,6 +581,7 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
const filteredCommands = filteredCommandGroups.ordered
const isSlashMenuVisible = !isMemberSession && slashMenuOpen && filteredCommands.length > 0
const isReferenceMenuVisible = !isMemberSession && fileSearchOpen
const exactSlashCommand = useMemo(() => {
const normalized = slashFilter.trim().toLowerCase()
@@ -626,14 +663,28 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
const selectSlashCommand = useCallback((command: string) => {
const cursorPos = composerRef.current?.getSelectionOffsets().start ?? input.length
const replacement = replaceSlashToken(input, cursorPos, command)
setComposerInput(replacement.value)
const option = allSlashCommands.find(item => item.name === command)
let nextCursor: number
const reference = (option?.kind === 'skill' || option?.kind === 'plugin')
? composerReferences.find(item => item.kind === option.kind && (item.id === command || item.name === command))
: undefined
if (reference) {
const mention = composerReferenceToMention(reference)
const trigger = findSlashTrigger(input, cursorPos)
const inserted = insertMentionIntoText(input, mentions, trigger?.slashPos ?? cursorPos, cursorPos, mention)
setComposerInput(inserted.text, inserted.mentions)
nextCursor = inserted.cursorPos
} else {
const replacement = replaceSlashToken(input, cursorPos, command)
setComposerInput(replacement.value)
nextCursor = replacement.cursorPos
}
setSlashMenuOpen(false)
requestAnimationFrame(() => {
composerRef.current?.focus()
composerRef.current?.setSelectionOffsets(replacement.cursorPos)
composerRef.current?.setSelectionOffsets(nextCursor)
})
}, [input])
}, [input, mentions, allSlashCommands, composerReferences, setComposerInput])
const replaceEmptySession = useCallback(async (
workDir: string,
@@ -862,7 +913,7 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
// Ignore key events during IME composition (e.g. Chinese input method)
if (composingRef.current || event.isComposing || event.keyCode === 229) return false
// Route file search navigation keys to FileSearchMenu
// Route reference selection and directory navigation to the unified menu
if (fileSearchOpen) {
const key = event.key
if (key === 'ArrowDown' || key === 'ArrowUp' || key === 'ArrowRight' || key === 'Enter' || key === 'Tab' || key === 'Escape') {
@@ -903,6 +954,7 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
const selected = filteredCommands[slashSelectedIndex]
if (
exactSlashCommand &&
!composerReferences.some(item => item.kind === selected?.kind && (item.id === selected?.name || item.name === selected?.name)) &&
selected?.name.toLowerCase() === exactSlashCommand.name.toLowerCase() &&
slashFilter.trim().toLowerCase() === exactSlashCommand.name.toLowerCase() &&
shouldSubmitOnEnter(event, chatSendBehavior)
@@ -1113,8 +1165,13 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
)}
{!isMemberSession && fileSearchOpen && (
<FileSearchMenu
<ComposerReferenceMenu
ref={fileSearchRef}
id={referenceMenuId}
references={composerReferences}
referencesLoading={referenceCurrent?.loading ?? true}
referencesError={referenceCurrent?.error}
onActiveChange={setReferenceOptionId}
cwd={activeLaunchWorkDir || resolvedWorkDir || ''}
filter={atFilter}
compact={isMobileComposer}
@@ -1131,15 +1188,10 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
composerRef.current?.setSelectionOffsets(newCursorPos)
})
}}
onSelect={(path, name, isDirectory) => {
onSelect={(mention) => {
if (atCursorPos < 0) return
const referenceName = name.split('/').filter(Boolean).pop() ?? name
const tokenEnd = atCursorPos + 1 + atFilter.length
const inserted = insertMentionIntoText(input, mentions, atCursorPos, tokenEnd, {
label: isDirectory ? `${referenceName}/` : referenceName,
path,
isDirectory,
})
const inserted = insertMentionIntoText(input, mentions, atCursorPos, tokenEnd, mention)
setComposerInput(inserted.text, inserted.mentions)
setFileSearchOpen(false)
setAtFilter('')
@@ -1169,6 +1221,7 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
ref={slashMenuRef}
id={slashMenuId}
groups={filteredCommandGroups}
references={composerReferences}
selectedIndex={slashSelectedIndex}
itemRefs={slashItemRefs}
onSelect={selectSlashCommand}
@@ -1300,6 +1353,7 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
rootRef={composerContainerRef}
value={input}
mentions={mentions}
onMentionClick={setReferenceDetail}
onChange={handleComposerChange}
onKeyDown={handleComposerKeyDown}
onPaste={handleComposerPaste}
@@ -1310,11 +1364,11 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
className="flex-1"
editorClassName="max-h-[200px] overflow-y-auto py-2 leading-relaxed text-[var(--color-text-primary)]"
aria={{
role: isSlashMenuVisible ? 'combobox' : 'textbox',
'aria-autocomplete': isSlashMenuVisible ? 'list' : undefined,
'aria-expanded': isSlashMenuVisible ? 'true' : undefined,
'aria-controls': isSlashMenuVisible ? slashMenuId : undefined,
'aria-activedescendant': isSlashMenuVisible
role: isSlashMenuVisible || isReferenceMenuVisible ? 'combobox' : 'textbox',
'aria-autocomplete': isSlashMenuVisible || isReferenceMenuVisible ? 'list' : undefined,
'aria-expanded': isSlashMenuVisible || isReferenceMenuVisible ? 'true' : undefined,
'aria-controls': isReferenceMenuVisible ? referenceMenuId : isSlashMenuVisible ? slashMenuId : undefined,
'aria-activedescendant': isReferenceMenuVisible ? referenceOptionId : isSlashMenuVisible
? getSlashCommandOptionId(slashMenuId, slashSelectedIndex)
: undefined,
}}
@@ -1326,6 +1380,7 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
rootRef={composerContainerRef}
value={input}
mentions={mentions}
onMentionClick={setReferenceDetail}
onChange={handleComposerChange}
onKeyDown={handleComposerKeyDown}
onPaste={handleComposerPaste}
@@ -1337,11 +1392,11 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
useCompactChrome ? 'py-1.5' : 'py-2'
}`}
aria={{
role: isSlashMenuVisible ? 'combobox' : 'textbox',
'aria-autocomplete': isSlashMenuVisible ? 'list' : undefined,
'aria-expanded': isSlashMenuVisible ? 'true' : undefined,
'aria-controls': isSlashMenuVisible ? slashMenuId : undefined,
'aria-activedescendant': isSlashMenuVisible
role: isSlashMenuVisible || isReferenceMenuVisible ? 'combobox' : 'textbox',
'aria-autocomplete': isSlashMenuVisible || isReferenceMenuVisible ? 'list' : undefined,
'aria-expanded': isSlashMenuVisible || isReferenceMenuVisible ? 'true' : undefined,
'aria-controls': isReferenceMenuVisible ? referenceMenuId : isSlashMenuVisible ? slashMenuId : undefined,
'aria-activedescendant': isReferenceMenuVisible ? referenceOptionId : isSlashMenuVisible
? getSlashCommandOptionId(slashMenuId, slashSelectedIndex)
: undefined,
}}
@@ -1568,6 +1623,7 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
</div>
)}
</div>
<ComposerReferenceDetail mention={referenceDetail} onClose={() => setReferenceDetail(null)} />
</div>
)
}
@@ -0,0 +1,11 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { expect, it, vi } from 'vitest'
import { ComposerReferenceDetail } from './ComposerReferenceDetail'
it('shows the selected capability description without treating it as markup or invoking a task', () => {
const close = vi.fn()
render(<ComposerReferenceDetail mention={{kind:'plugin',id:'test@source',label:'Example',description:'<script>not markup</script>',path:'',isDirectory:false,tokenOrdinal:0,icon:'/connectors/hyperframes.svg'}} onClose={close} />)
expect(screen.getByRole('dialog', {name:'Example'})).toBeTruthy()
expect(screen.getByText('<script>not markup</script>')).toBeTruthy()
fireEvent.click(screen.getByRole('button', {name:'Close dialog'}))
expect(close).toHaveBeenCalledOnce()
})
@@ -0,0 +1,18 @@
import { Modal } from '@/components/ui/Modal'
import { useTranslation } from '@/i18n'
import { safeMentionIcon, type ComposerMention } from '@/lib/composerMentions'
export function ComposerReferenceDetail({ mention, onClose }: { mention: ComposerMention | null, onClose: () => void }) {
const t = useTranslation()
const icon = safeMentionIcon(mention?.icon)
return <Modal open={!!mention} onClose={onClose} title={mention?.label || t('chat.referenceDetails')} width={440}>
{mention && <div className="space-y-4 pb-1">
<div className="flex items-center gap-2 text-xs text-[var(--color-text-secondary)]">
{icon ? <img src={`${import.meta.env.BASE_URL}${icon.slice(1)}`} alt="" className="h-6 w-6 object-contain" /> : <span aria-hidden="true" className="material-symbols-outlined text-xl">{mention.kind === 'plugin' ? 'extension' : 'deployed_code'}</span>}
<span>{t(mention.kind === 'plugin' ? 'chat.referencePlugins' : 'chat.referenceSkills')}</span>
</div>
<p className="whitespace-pre-wrap text-sm leading-6 text-[var(--color-text-primary)]">{mention.description || mention.label}</p>
{mention.id && <p className="break-words text-xs text-[var(--color-text-tertiary)]">{mention.id}</p>}
</div>}
</Modal>
}
@@ -0,0 +1,91 @@
import { createRef } from 'react'
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { beforeEach, expect, it, vi } from 'vitest'
import '@testing-library/jest-dom'
import { filesystemApi } from '@/api/filesystem'
import type { ComposerReferenceCandidate } from '@/types/composerReference'
import { ComposerReferenceMenu, type ComposerReferenceMenuHandle } from './ComposerReferenceMenu'
vi.mock('@/api/filesystem', () => ({ filesystemApi: { browse: vi.fn(), search: vi.fn() } }))
const directory = { name: 'src', path: '/work/src', isDirectory: true }
const file = { name: 'app.ts', path: '/work/app.ts', isDirectory: false }
const references: ComposerReferenceCandidate[] = [
{ kind: 'plugin', id: 'hyperframes', name: 'hyperframes', displayName: 'HyperFrames', description: 'Video creation', source: 'plugin', modelText: 'Use HyperFrames', icon: '/connectors/hyperframes.svg' },
{ kind: 'skill', id: 'design', name: 'design', displayName: 'Design', description: 'Create interfaces', source: 'user', path: '/skills/design/SKILL.md', modelText: 'Use design' },
]
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(filesystemApi.browse).mockResolvedValue({ currentPath: '/work', parentPath: '/', entries: [directory, file] })
vi.mocked(filesystemApi.search).mockResolvedValue({ currentPath: '/work', parentPath: '/', entries: [file] })
})
it('unifies plugin, skill and file groups with structured selection and active option ids', async () => {
const ref = createRef<ComposerReferenceMenuHandle>()
const onSelect = vi.fn()
const onActiveChange = vi.fn()
render(<ComposerReferenceMenu ref={ref} id="references" cwd="/work" references={references} onSelect={onSelect} onActiveChange={onActiveChange} />)
await screen.findByRole('option', { name: 'app.ts' })
expect(screen.getAllByRole('option').map(row => row.textContent)).toEqual(['HyperFramesVideo creation', 'DesignCreate interfaces', 'src', 'app.ts'])
expect(onActiveChange).toHaveBeenLastCalledWith('references-option-0')
act(() => { ref.current!.handleKeyDown(new KeyboardEvent('keydown', { key: 'Enter' })) })
expect(onSelect).toHaveBeenLastCalledWith(expect.objectContaining({ kind: 'plugin', id: 'hyperframes', modelText: 'Use HyperFrames', path: '', isDirectory: false }))
act(() => { ref.current!.handleKeyDown(new KeyboardEvent('keydown', { key: 'ArrowDown' })) })
expect(screen.getByRole('option', { name: 'Design' })).toHaveAttribute('aria-selected', 'true')
act(() => { ref.current!.handleKeyDown(new KeyboardEvent('keydown', { key: 'Tab' })) })
expect(onSelect).toHaveBeenLastCalledWith(expect.objectContaining({ kind: 'skill', id: 'design', path: '/skills/design/SKILL.md' }))
fireEvent.click(screen.getByRole('option', { name: 'app.ts' }))
expect(onSelect).toHaveBeenLastCalledWith({ label: 'app.ts', path: '/work/app.ts', isDirectory: false })
expect(screen.getByRole('option', { name: 'HyperFrames' })).toHaveAccessibleDescription('Video creation')
})
it('keeps directory selection separate from ArrowRight and pointer navigation and ignores IME Enter', async () => {
const ref = createRef<ComposerReferenceMenuHandle>()
const onSelect = vi.fn()
const onNavigate = vi.fn()
render(<ComposerReferenceMenu ref={ref} id="files" cwd="/work" references={[]} onSelect={onSelect} onNavigate={onNavigate} />)
const option = await screen.findByRole('option', { name: 'src' })
act(() => { ref.current!.handleKeyDown(new KeyboardEvent('keydown', { key: 'Enter', isComposing: true })) })
expect(onSelect).not.toHaveBeenCalled()
act(() => { ref.current!.handleKeyDown(new KeyboardEvent('keydown', { key: 'ArrowRight' })) })
expect(onNavigate).toHaveBeenLastCalledWith('src/')
expect(onSelect).not.toHaveBeenCalled()
fireEvent.click(option.querySelector('[data-navigate-directory]')!)
expect(onNavigate).toHaveBeenCalledTimes(2)
fireEvent.click(option)
expect(onSelect).toHaveBeenCalledWith({ label: 'src/', path: '/work/src', isDirectory: true })
})
it('discards late query and workspace results and never selects stale files while loading', async () => {
let oldResolve!: (value: Awaited<ReturnType<typeof filesystemApi.search>>) => void
vi.mocked(filesystemApi.search).mockImplementationOnce(() => new Promise(resolve => { oldResolve = resolve }))
const onSelect = vi.fn()
const ref = createRef<ComposerReferenceMenuHandle>()
const view = render(<ComposerReferenceMenu ref={ref} id="search" cwd="/old" filter="old" references={[]} onSelect={onSelect} />)
view.rerender(<ComposerReferenceMenu ref={ref} id="search" cwd="/work" filter="new" references={[]} onSelect={onSelect} />)
act(() => { ref.current!.handleKeyDown(new KeyboardEvent('keydown', { key: 'Enter' })) })
expect(onSelect).not.toHaveBeenCalled()
await screen.findByRole('option', { name: 'app.ts' })
await act(async () => { oldResolve({ currentPath: '/old', parentPath: '/', entries: [{ name: 'old.ts', path: '/old/old.ts', isDirectory: false }] }) })
expect(screen.queryByRole('option', { name: 'old.ts' })).not.toBeInTheDocument()
expect(screen.getByRole('option', { name: 'app.ts' })).toBeInTheDocument()
})
it('keeps plugin matches usable when file loading fails without exposing raw errors or remote icons', async () => {
vi.mocked(filesystemApi.browse).mockRejectedValue(new Error('secret-server-error'))
const onSelect = vi.fn()
render(<ComposerReferenceMenu id="failure" cwd="/work" references={[{ ...references[0]!, icon: 'https://untrusted.test/tracker.svg' }]} referencesError="secret-reference-error" onSelect={onSelect} />)
await waitFor(() => expect(screen.getAllByRole('alert')).toHaveLength(2))
expect(document.body.textContent).not.toContain('secret-')
expect(document.querySelector('img')).toBeNull()
fireEvent.click(screen.getByRole('option', { name: 'HyperFrames' }))
expect(onSelect).toHaveBeenCalled()
})
it('browses explicit path filters and reports no active descendant for empty results', async () => {
vi.mocked(filesystemApi.browse).mockResolvedValue({ currentPath: '/work/src', parentPath: '/work', entries: [] })
const onActiveChange = vi.fn()
render(<ComposerReferenceMenu id="path" cwd="/work" filter="src/" references={references} onSelect={vi.fn()} onActiveChange={onActiveChange} />)
await waitFor(() => expect(filesystemApi.browse).toHaveBeenCalledWith('/work/src', { includeFiles: true }))
expect(screen.queryAllByRole('option')).toHaveLength(0)
expect(onActiveChange).toHaveBeenLastCalledWith(undefined)
})
@@ -0,0 +1,146 @@
import { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react'
import { ChevronRight, File, Folder, Package, Sparkles } from 'lucide-react'
import { ApiError } from '@/api/client'
import { filesystemApi } from '@/api/filesystem'
import { useTranslation } from '@/i18n'
import { safeMentionIcon, type NewComposerMention } from '@/lib/composerMentions'
import type { ComposerReferenceCandidate } from '@/types/composerReference'
type FileEntry = { name: string, path: string, isDirectory: boolean, relativePath?: string }
type Row = { key: string, label: string, description: string, mention: NewComposerMention, file?: FileEntry }
export type ComposerReferenceMenuHandle = { handleKeyDown(event: KeyboardEvent): void }
type Props = {
id: string
cwd: string
filter?: string
compact?: boolean
references: ComposerReferenceCandidate[]
referencesLoading?: boolean
referencesError?: string | boolean | null
onSelect(mention: NewComposerMention): void
onNavigate?(relativePath: string): void
onActiveChange?(optionId: string | undefined): void
}
export function getComposerReferenceOptionId(id: string, index: number): string { return `${id}-option-${index}` }
export const ComposerReferenceMenu = forwardRef<ComposerReferenceMenuHandle, Props>(function ComposerReferenceMenu({
id, cwd, filter = '', compact = false, references, referencesLoading = false, referencesError,
onSelect, onNavigate, onActiveChange,
}, ref) {
const t = useTranslation()
const [manualPath, setManualPath] = useState<{ cwd: string, filter: string, path: string } | null>(null)
const override = manualPath?.cwd === cwd && manualPath.filter === filter ? manualPath.path : undefined
const queryKey = `${cwd}\0${filter}\0${override ?? ''}`
const [result, setResult] = useState<{ key: string, entries: FileEntry[], root: string, current: string, error?: 'denied' | 'failed' } | null>(null)
const [selection, setSelection] = useState<{ query: string, key: string } | null>(null)
const listRef = useRef<HTMLDivElement>(null)
const rootRef = useRef({ cwd, path: cwd })
if (rootRef.current.cwd !== cwd) rootRef.current = { cwd, path: cwd }
const currentResult = result?.key === queryKey ? result : null
const loading = currentResult === null
useEffect(() => {
let active = true
const base = (cwd || rootRef.current.path).replace(/[\\/]+$/, '')
const directoryQuery = filter.replace(/\\/g, '/').trim()
const browsing = directoryQuery.endsWith('/')
const path = override ?? (browsing && base ? `${base}/${directoryQuery.replace(/\/+$/, '')}` : base)
const search = override || browsing ? '' : directoryQuery
const request = search ? filesystemApi.search(search, path) : filesystemApi.browse(path, { includeFiles: true })
void request.then(data => {
if (!active) return
if (!rootRef.current.path) rootRef.current = { cwd, path: data.currentPath }
setResult({ key: queryKey, entries: data.entries, current: data.currentPath, root: rootRef.current.path })
}, error => {
if (active) setResult({ key: queryKey, entries: [], current: path, root: base, error: error instanceof ApiError && error.status === 403 ? 'denied' : 'failed' })
})
return () => { active = false }
}, [cwd, filter, override, queryKey])
const groups = useMemo(() => {
const query = filter.trim().toLocaleLowerCase()
const matches = references.filter(item => !query.endsWith('/') && query.split(/\s+/).every(word => `${item.displayName} ${item.name} ${item.description}`.toLocaleLowerCase().includes(word)))
const referenceRow = (item: ComposerReferenceCandidate): Row => ({
key: `${item.kind}:${item.id}`, label: item.displayName || item.name, description: item.description,
mention: { kind: item.kind, id: item.id, label: item.displayName || item.name, path: item.path ?? '', isDirectory: false, description: item.description, icon: safeMentionIcon(item.icon), modelText: item.modelText },
})
const files: Row[] = (currentResult?.entries ?? []).map(entry => {
const base = (cwd || currentResult?.root || '').replace(/\\/g, '/').replace(/\/+$/, '')
const path = entry.path.replace(/\\/g, '/')
const relative = path.startsWith(`${base}/`) ? path.slice(base.length + 1) : entry.relativePath ?? entry.name
const name = entry.name.split(/[\\/]/).filter(Boolean).at(-1) ?? entry.name
return { key: `file:${entry.path}`, label: name, description: relative.includes('/') ? relative.slice(0, relative.lastIndexOf('/')) : '', file: { ...entry, relativePath: relative }, mention: { label: entry.isDirectory ? `${name}/` : name, path: entry.path, isDirectory: entry.isDirectory } }
})
return [
{ kind: 'plugins', label: t('chat.referencePlugins'), rows: matches.filter(item => item.kind === 'plugin').map(referenceRow) },
{ kind: 'skills', label: t('chat.referenceSkills'), rows: matches.filter(item => item.kind === 'skill').map(referenceRow) },
{ kind: 'files', label: t('chat.referenceFiles'), rows: files },
]
}, [references, filter, currentResult, cwd, t])
const rows = groups.flatMap(group => group.rows)
const foundIndex = selection?.query === queryKey ? rows.findIndex(row => row.key === selection.key) : -1
const activeIndex = rows.length ? Math.max(0, foundIndex) : -1
const activeOptionId = activeIndex < 0 ? undefined : getComposerReferenceOptionId(id, activeIndex)
useEffect(() => { onActiveChange?.(activeOptionId) }, [activeOptionId, onActiveChange])
useEffect(() => {
if (activeOptionId) listRef.current?.ownerDocument.getElementById(activeOptionId)?.scrollIntoView?.({ block: 'nearest' })
}, [activeOptionId])
const highlight = useCallback((row: Row) => setSelection({ query: queryKey, key: row.key }), [queryKey])
const navigate = useCallback((row: Row) => {
if (!row.file?.isDirectory) return
const relative = `${(row.file.relativePath ?? row.file.name).replace(/\/+$/, '')}/`
if (onNavigate) onNavigate(relative)
else setManualPath({ cwd, filter, path: row.file.path })
}, [cwd, filter, onNavigate])
useImperativeHandle(ref, () => ({ handleKeyDown(event) {
if (event.isComposing || event.keyCode === 229) return
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
event.preventDefault()
if (rows.length) highlight(rows[(Math.max(activeIndex, 0) + (event.key === 'ArrowDown' ? 1 : rows.length - 1)) % rows.length]!)
} else if (event.key === 'Enter' || event.key === 'Tab') {
event.preventDefault()
if (rows[activeIndex]) onSelect(rows[activeIndex]!.mention)
} else if (event.key === 'ArrowRight' && rows[activeIndex]?.file?.isDirectory) {
event.preventDefault()
navigate(rows[activeIndex]!)
}
} }), [activeIndex, rows, highlight, navigate, onSelect])
let offset = 0
return (
<div className={`absolute bottom-full left-0 right-0 z-[var(--z-dropdown)] mb-2 overflow-hidden rounded-[var(--radius-xl)] border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] shadow-[var(--shadow-overlay)] ${compact ? 'max-w-[calc(100vw-32px)]' : ''}`} onMouseDown={event => event.preventDefault()}>
<div ref={listRef} id={id} role="listbox" aria-label={t('chat.references')} aria-busy={loading || referencesLoading} className="min-w-0 max-h-[360px] overflow-y-auto p-1.5">
{groups.map(group => {
const start = offset
offset += group.rows.length
if (!group.rows.length) return null
return <div key={group.kind} role="group" aria-label={group.label}>
<div className="px-3 pb-1 pt-2 text-xs font-medium text-[var(--color-text-tertiary)]">{group.label}</div>
{group.rows.map((row, position) => {
const index = start + position
const Icon = row.file ? (row.file.isDirectory ? Folder : File) : row.mention.kind === 'plugin' ? Package : Sparkles
return <div key={row.key} id={getComposerReferenceOptionId(id, index)} role="option" tabIndex={-1} aria-selected={activeIndex === index} aria-labelledby={`${id}-label-${index}`} aria-describedby={`${id}-description-${index}`}
onMouseEnter={() => highlight(row)} onClick={event => {
if ((event.target as Element).closest('[data-navigate-directory]')) navigate(row)
else onSelect(row.mention)
}}
className={`flex min-w-0 cursor-default items-center gap-3 rounded-[var(--radius-md)] px-3 py-2 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)] ${activeIndex === index ? 'bg-[var(--color-surface-hover)]' : 'hover:bg-[var(--color-surface-hover)]'}`}>
{row.mention.icon ? <img src={row.mention.icon} alt="" className="h-5 w-5 shrink-0 object-contain" /> : <Icon aria-hidden="true" className="h-5 w-5 shrink-0 text-[var(--color-text-secondary)]" strokeWidth={1.7} />}
<span id={`${id}-label-${index}`} className="max-w-[45%] shrink-0 truncate text-sm font-medium text-[var(--color-text-primary)]">{row.label}</span>
<span id={`${id}-description-${index}`} className="min-w-0 flex-1 truncate text-xs text-[var(--color-text-tertiary)]">{row.description}</span>
{row.file?.isDirectory ? <span data-navigate-directory title={t('fileSearch.openFolder')} className="-my-2 -mr-2 flex h-8 w-8 shrink-0 items-center justify-center"><ChevronRight aria-hidden="true" className="h-4 w-4 text-[var(--color-text-tertiary)]" /></span> : null}
</div>
})}
</div>
})}
{loading || referencesLoading ? <div role="status" className="px-3 py-2 text-xs text-[var(--color-text-tertiary)]">{t('fileSearch.searching')}</div> : null}
{currentResult?.error ? <div role="alert" className="px-3 py-2 text-xs text-[var(--color-error)]">{t(currentResult.error === 'denied' ? 'fileSearch.accessDenied' : 'fileSearch.loadFailed')}</div> : null}
{referencesError ? <div role="alert" className="px-3 py-2 text-xs text-[var(--color-error)]">{t('chat.referencesLoadFailed')}</div> : null}
{!rows.length && !loading && !referencesLoading && !currentResult?.error && !referencesError ? <div className="px-3 py-3 text-xs text-[var(--color-text-tertiary)]">{t('chat.referencesEmpty')}</div> : null}
</div>
{!compact ? <div className="flex items-center gap-2 border-t border-[var(--color-border)] px-4 py-2 text-[10px] text-[var(--color-text-tertiary)]"><kbd>↑↓</kbd><span>{t('fileSearch.navigate')}</span><kbd className="ml-2">Enter / Tab</kbd><span>{t('fileSearch.select')}</span><kbd className="ml-2">→</kbd><span>{t('fileSearch.open')}</span><kbd className="ml-2">Esc</kbd><span>{t('fileSearch.close')}</span></div> : null}
</div>
)
})
@@ -1,202 +0,0 @@
import { describe, expect, it, vi, beforeEach } from 'vitest'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import '@testing-library/jest-dom'
import { FileSearchMenu } from './FileSearchMenu'
import { ApiError } from '../../api/client'
import { filesystemApi } from '../../api/filesystem'
import { useSettingsStore } from '../../stores/settingsStore'
vi.mock('../../api/filesystem', () => ({
filesystemApi: {
browse: vi.fn(),
search: vi.fn(),
},
}))
describe('FileSearchMenu', () => {
beforeEach(() => {
vi.clearAllMocks()
useSettingsStore.setState({ locale: 'en' })
})
it('shows an explicit error when directory browsing is denied', async () => {
vi.mocked(filesystemApi.browse).mockRejectedValueOnce(
new ApiError(403, { error: 'Access denied: path outside allowed directory' }),
)
render(
<FileSearchMenu
cwd="/private/tmp"
onSelect={() => {}}
/>,
)
expect(await screen.findByText('Cannot access this directory')).toBeInTheDocument()
expect(screen.queryByText('No files in this directory')).not.toBeInTheDocument()
})
it('renders returned files when browsing succeeds', async () => {
vi.mocked(filesystemApi.browse).mockResolvedValueOnce({
currentPath: '/tmp',
parentPath: '/',
entries: [
{ name: 'preview.png', path: '/tmp/preview.png', isDirectory: false },
],
})
render(
<FileSearchMenu
cwd="/tmp"
onSelect={() => {}}
/>,
)
await waitFor(() => {
expect(screen.getByText('preview.png')).toBeInTheDocument()
})
})
it('selects directories from search results and keeps a separate drill-in action', async () => {
const onSelect = vi.fn()
const onNavigate = vi.fn()
vi.mocked(filesystemApi.search).mockResolvedValueOnce({
currentPath: '/repo',
parentPath: '/',
query: 'backend',
entries: [
{ name: 'backend', path: '/repo/backend', relativePath: 'backend', isDirectory: true },
],
})
vi.mocked(filesystemApi.browse).mockResolvedValueOnce({
currentPath: '/repo/backend',
parentPath: '/repo',
entries: [
{ name: 'src', path: '/repo/backend/src', isDirectory: true },
],
})
vi.mocked(filesystemApi.browse).mockResolvedValueOnce({
currentPath: '/repo/backend/src',
parentPath: '/repo/backend',
entries: [
{ name: 'commands', path: '/repo/backend/src/commands', isDirectory: true },
],
})
render(
<FileSearchMenu
cwd="/repo"
filter="backend"
onSelect={onSelect}
onNavigate={onNavigate}
/>,
)
fireEvent.click(await screen.findByText('backend/'))
expect(onSelect).toHaveBeenCalledWith('/repo/backend', 'backend', true)
expect(onNavigate).not.toHaveBeenCalled()
fireEvent.click(screen.getByLabelText('Open folder'))
expect(onNavigate).toHaveBeenCalledWith('backend/')
await waitFor(() => {
expect(filesystemApi.browse).toHaveBeenCalledWith('/repo/backend', { includeFiles: true })
})
fireEvent.click(await screen.findByText('src'))
expect(onSelect).toHaveBeenLastCalledWith('/repo/backend/src', 'backend/src', true)
fireEvent.click(screen.getByLabelText('Open folder'))
expect(onNavigate).toHaveBeenLastCalledWith('backend/src/')
await waitFor(() => {
expect(filesystemApi.browse).toHaveBeenCalledWith('/repo/backend/src', { includeFiles: true })
})
})
it('passes nested relative file paths when selecting a file', async () => {
const onSelect = vi.fn()
vi.mocked(filesystemApi.search).mockResolvedValueOnce({
currentPath: '/repo',
parentPath: '/',
query: 'pictactic',
entries: [
{ name: 'pictactic', path: '/repo/backend/src/pictactic', relativePath: 'backend/src/pictactic', isDirectory: false },
],
})
render(
<FileSearchMenu
cwd="/repo"
filter="backend/src/pictactic"
onSelect={onSelect}
/>,
)
fireEvent.click(await screen.findByText('backend/src/pictactic'))
expect(onSelect).toHaveBeenCalledWith('/repo/backend/src/pictactic', 'backend/src/pictactic', false)
})
it('renders search results as insertable paths instead of repeated basenames', async () => {
vi.mocked(filesystemApi.search).mockResolvedValueOnce({
currentPath: '/repo',
parentPath: '/',
query: 'src',
entries: [
{ name: 'src', path: '/repo/src', relativePath: 'src', isDirectory: true },
{ name: 'hooks', path: '/repo/src/hooks', relativePath: 'src/hooks', isDirectory: true },
{ name: 'src', path: '/repo/desktop/src', relativePath: 'desktop/src', isDirectory: true },
],
})
render(
<FileSearchMenu
cwd="/repo"
filter="src"
onSelect={() => {}}
/>,
)
expect(await screen.findByText('src/')).toBeInTheDocument()
expect(screen.getByText('src/hooks/')).toBeInTheDocument()
expect(screen.getByText('desktop/src/')).toBeInTheDocument()
})
it('uses the resolved home root for typed folder filters when no workspace is selected', async () => {
vi.mocked(filesystemApi.search).mockResolvedValueOnce({
currentPath: '/Users/nanmi',
parentPath: '/',
query: 'workspace',
entries: [
{ name: 'workspace', path: '/Users/nanmi/workspace', relativePath: 'workspace', isDirectory: true },
],
})
vi.mocked(filesystemApi.browse).mockResolvedValueOnce({
currentPath: '/Users/nanmi/workspace',
parentPath: '/Users/nanmi',
entries: [],
})
const { rerender } = render(
<FileSearchMenu
cwd=""
filter="workspace"
onSelect={() => {}}
/>,
)
expect(await screen.findByText('workspace/')).toBeInTheDocument()
rerender(
<FileSearchMenu
cwd=""
filter="workspace/"
onSelect={() => {}}
/>,
)
await waitFor(() => {
expect(filesystemApi.browse).toHaveBeenCalledWith('/Users/nanmi/workspace', { includeFiles: true })
})
})
})
@@ -1,330 +0,0 @@
import { forwardRef, useState, useEffect, useRef, useCallback, useImperativeHandle } from 'react'
import { ApiError } from '../../api/client'
import { filesystemApi } from '../../api/filesystem'
import { useTranslation } from '../../i18n'
import type { TranslationKey } from '../../i18n'
import { IconButton } from '@/components/ui/IconButton'
type DirEntry = {
name: string
path: string
isDirectory: boolean
relativePath?: string
}
export type FileSearchMenuHandle = {
handleKeyDown: (e: KeyboardEvent) => void
}
type Props = {
cwd: string
filter?: string
compact?: boolean
onSelect: (path: string, relativePath: string, isDirectory: boolean) => void
onNavigate?: (relativePath: string) => void
}
export const FileSearchMenu = forwardRef<FileSearchMenuHandle, Props>(({ cwd, filter = '', compact = false, onSelect, onNavigate }, ref) => {
const t = useTranslation()
const [entries, setEntries] = useState<DirEntry[]>([])
const [errorMessage, setErrorMessage] = useState<string | null>(null)
const [errorKey, setErrorKey] = useState<TranslationKey | null>(null)
const [currentPath, setCurrentPath] = useState(cwd)
const [isSearchMode, setIsSearchMode] = useState(false)
const [loading, setLoading] = useState(false)
const [selectedIndex, setSelectedIndex] = useState(0)
const [rootPath, setRootPath] = useState(cwd)
const listRef = useRef<HTMLDivElement>(null)
const currentPathRef = useRef(cwd)
const rootPathRef = useRef(cwd)
const getErrorState = (error: unknown): { errorKey: TranslationKey | null; errorMessage: string | null } => {
if (error instanceof ApiError) {
if (error.status === 403) {
return { errorKey: 'fileSearch.accessDenied', errorMessage: null }
}
const apiMessage =
typeof error.body === 'string'
? error.body
: typeof error.body === 'object' &&
error.body !== null &&
'error' in error.body &&
typeof error.body.error === 'string'
? error.body.error
: null
if (apiMessage) {
return { errorKey: null, errorMessage: apiMessage }
}
}
return { errorKey: 'fileSearch.loadFailed', errorMessage: null }
}
const getRelativePath = useCallback((entry: DirEntry) => {
const basePath = (cwd || rootPath).replace(/\/+$/, '')
if (entry.path.startsWith(`${basePath}/`)) return entry.path.slice(basePath.length + 1)
if (entry.relativePath) return entry.relativePath
return entry.name
}, [cwd, rootPath])
const getDisplayPath = useCallback((entry: DirEntry) => {
const relativePath = getRelativePath(entry).replace(/\\/g, '/')
if (!entry.isDirectory) return relativePath
return `${relativePath.replace(/\/+$/, '')}/`
}, [getRelativePath])
const selectEntry = useCallback((entry: DirEntry) => {
onSelect(entry.path, getRelativePath(entry), entry.isDirectory)
}, [getRelativePath, onSelect])
const parseFilter = (rawFilter: string): { navigateTo: string; searchQuery: string } => {
const trimmed = rawFilter.trim()
const basePath = (cwd || rootPathRef.current).replace(/\/+$/, '')
if (!trimmed) return { navigateTo: basePath, searchQuery: '' }
if (trimmed.endsWith('/')) {
if (!basePath) return { navigateTo: '', searchQuery: trimmed.replace(/\/+$/, '') }
return { navigateTo: `${basePath}/${trimmed.replace(/\/+$/, '')}`, searchQuery: '' }
}
return { navigateTo: basePath, searchQuery: trimmed }
}
// Load directory entries
const loadDir = useCallback(async (dirPath: string, searchQuery: string) => {
setLoading(true)
setErrorMessage(null)
setErrorKey(null)
// Only update currentPath if actually navigating to a different directory
if (dirPath !== currentPathRef.current) {
setCurrentPath(dirPath)
currentPathRef.current = dirPath
}
try {
if (searchQuery) {
setIsSearchMode(true)
const result = await filesystemApi.search(searchQuery, dirPath)
setCurrentPath(result.currentPath)
currentPathRef.current = result.currentPath
if (!cwd) {
setRootPath(result.currentPath)
rootPathRef.current = result.currentPath
}
setEntries(result.entries)
} else {
setIsSearchMode(false)
const result = await filesystemApi.browse(dirPath, { includeFiles: true })
setCurrentPath(result.currentPath)
currentPathRef.current = result.currentPath
if (!cwd) {
setRootPath(result.currentPath)
rootPathRef.current = result.currentPath
}
setEntries(result.entries)
}
setSelectedIndex(0)
} catch (error) {
setEntries([])
const nextError = getErrorState(error)
setErrorKey(nextError.errorKey)
setErrorMessage(nextError.errorMessage)
}
setLoading(false)
}, [cwd])
const navigateEntry = useCallback((entry: DirEntry) => {
if (!entry.isDirectory) return
const relativePath = `${getRelativePath(entry).replace(/\/+$/, '')}/`
void loadDir(entry.path, '')
onNavigate?.(relativePath)
}, [getRelativePath, loadDir, onNavigate])
// Keep the explicit workspace root stable when the host session changes.
useEffect(() => {
currentPathRef.current = cwd
rootPathRef.current = cwd
setRootPath(cwd)
setCurrentPath(cwd)
}, [cwd])
// Initial load: parse filter path and navigate accordingly
useEffect(() => {
const { navigateTo, searchQuery } = parseFilter(filter)
void loadDir(navigateTo, searchQuery)
}, [cwd, filter, loadDir])
// Keyboard navigation handler exposed via ref
const handleKeyDown = useCallback((e: KeyboardEvent) => {
if (e.key === 'ArrowDown') {
e.preventDefault()
setSelectedIndex((prev) => Math.min(prev + 1, entries.length - 1))
return
}
if (e.key === 'ArrowUp') {
e.preventDefault()
setSelectedIndex((prev) => Math.max(prev - 1, 0))
return
}
if (e.key === 'Enter' || e.key === 'Tab') {
e.preventDefault()
const selected = entries[selectedIndex]
if (selected) {
selectEntry(selected)
}
return
}
if (e.key === 'ArrowRight') {
const selected = entries[selectedIndex]
if (selected?.isDirectory) {
e.preventDefault()
navigateEntry(selected)
}
return
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [entries, selectedIndex, selectEntry, navigateEntry])
useImperativeHandle(ref, () => ({ handleKeyDown }), [handleKeyDown])
// Scroll selected into view
useEffect(() => {
const el = listRef.current?.querySelector(`[data-index="${selectedIndex}"]`) as HTMLButtonElement | null
el?.scrollIntoView({ block: 'nearest' })
}, [selectedIndex])
// Build breadcrumb segments from current path relative to cwd
const breadcrumbs: string[] = []
if (currentPath !== cwd && currentPath.startsWith(cwd)) {
const rel = currentPath.slice(cwd.length).replace(/^\//, '')
if (rel) breadcrumbs.push(...rel.split('/'))
}
const renderEntry = (entry: DirEntry, index: number) => {
const relativePath = getRelativePath(entry)
const displayPath = getDisplayPath(entry)
const parentPath = relativePath.split('/').slice(0, -1).join('/')
const selected = selectedIndex === index
return (
<div
key={entry.path}
data-index={index}
className={`group flex items-stretch px-1.5 py-0.5 ${
selected ? 'bg-[var(--color-surface-hover)]' : ''
}`}
onMouseEnter={() => setSelectedIndex(index)}
>
<button
type="button"
onClick={() => selectEntry(entry)}
className={`flex min-w-0 flex-1 items-center rounded-[var(--radius-md)] px-2.5 text-left transition-colors hover:bg-[var(--color-surface-hover)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)] ${
isSearchMode ? 'gap-2.5 py-2' : 'gap-3 py-2'
}`}
role="option"
aria-selected={selected}
>
<span className={`material-symbols-outlined shrink-0 text-[17px] ${entry.isDirectory ? 'text-[var(--color-brand)]' : 'text-[var(--color-text-secondary)]'}`}>
{entry.isDirectory ? 'folder' : 'description'}
</span>
<span className="min-w-0 flex-1">
{isSearchMode ? (
<span
className="block truncate font-mono text-sm text-[var(--color-text-primary)]"
title={displayPath}
>
{displayPath}
</span>
) : (
<>
<span className="block truncate text-sm font-medium text-[var(--color-text-primary)]">{entry.name}</span>
<span className="block truncate font-mono text-[11px] text-[var(--color-text-tertiary)]">
{parentPath || (entry.isDirectory ? t('fileSearch.directory') : t('fileSearch.currentDirectory'))}
</span>
</>
)}
</span>
{!isSearchMode ? (
<span className="shrink-0 rounded-[var(--radius-sm)] border border-[var(--color-border)] px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-[0.02em] text-[var(--color-text-tertiary)]">
{entry.isDirectory ? t('fileSearch.folderTag') : t('fileSearch.fileTag')}
</span>
) : null}
</button>
{entry.isDirectory ? (
<IconButton
icon="chevron_right"
label={t('fileSearch.openFolder')}
size="lg"
tone="muted"
className="my-1 opacity-70 group-hover:opacity-100"
onClick={(event) => {
event.stopPropagation()
navigateEntry(entry)
}}
/>
) : null}
</div>
)
}
return (
<div
id="file-search-menu"
className={`absolute bottom-full mb-2 z-[var(--z-dropdown)] w-full overflow-hidden rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] shadow-[var(--shadow-overlay)] ${
compact ? 'left-0 right-0 min-w-0 max-w-[calc(100vw-32px)]' : 'left-0 min-w-[480px]'
}`}
onMouseDown={(e) => e.stopPropagation()}
>
{/* Header with path */}
<div className="flex items-center gap-1.5 border-b border-[var(--color-border)] px-3 py-2 text-[11px]">
<span className="material-symbols-outlined text-[14px] text-[var(--color-text-tertiary)]">folder_open</span>
<span className="text-[var(--color-text-tertiary)] font-mono">{cwd.split('/').pop() || cwd}</span>
{breadcrumbs.map((seg, i) => (
<span key={i} className="flex items-center gap-1">
<span className="text-[var(--color-text-tertiary)]">/</span>
<span className="text-[var(--color-text-primary)] font-mono">{seg}</span>
</span>
))}
{isSearchMode && filter ? (
<span className="ml-auto truncate font-mono text-[11px] text-[var(--color-text-tertiary)]">@{filter}</span>
) : null}
{loading && (
<span className="material-symbols-outlined text-[12px] text-[var(--color-text-tertiary)] animate-spin ml-1">progress_activity</span>
)}
</div>
{/* File list */}
<div ref={listRef} className="max-h-[300px] overflow-y-auto py-1">
{loading && entries.length === 0 ? (
<div className="px-4 py-6 text-center text-xs text-[var(--color-text-tertiary)]">{t('fileSearch.searching')}</div>
) : (errorKey || errorMessage) ? (
<div className="px-4 py-6 text-center text-xs text-[var(--color-error)]">
{errorKey ? t(errorKey) : errorMessage}
</div>
) : entries.length === 0 ? (
<div className="px-4 py-6 text-center text-xs text-[var(--color-text-tertiary)]">
{filter ? t('fileSearch.noMatch') : t('fileSearch.noFiles')}
</div>
) : (
<>
{entries.map(renderEntry)}
</>
)}
</div>
{/* Footer hint */}
{!compact ? (
<div className="flex items-center gap-1.5 border-t border-[var(--color-border)] px-3 py-1.5 text-[10px] text-[var(--color-text-tertiary)]">
<kbd className="rounded border border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-1 py-0.5 font-mono">↑↓</kbd>
<span>{t('fileSearch.navigate')}</span>
<kbd className="ml-2 rounded border border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-1 py-0.5 font-mono">Enter</kbd>
<span>{t('fileSearch.select')}</span>
<kbd className="ml-2 rounded border border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-1 py-0.5 font-mono">→</kbd>
<span>{t('fileSearch.open')}</span>
<kbd className="ml-2 rounded border border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-1 py-0.5 font-mono">Esc</kbd>
<span>{t('fileSearch.close')}</span>
</div>
) : null}
</div>
)
})
FileSearchMenu.displayName = 'FileSearchMenu'
@@ -44,7 +44,7 @@ export type MentionComposerHandle = {
setSelectionOffsets: (start: number, end?: number) => void
/**
* Content for the model: text with each mention pill serialized to
* `@"absolute path"`. Read from the live document, so literal text that
* file paths or explicit skill/plugin requests. Read from the live document, so literal text that
* merely looks like a pill's token is never rewritten.
*/
getModelContent: () => string
@@ -56,6 +56,7 @@ export type MentionComposerProps = {
onChange: (text: string, mentions: ComposerMention[]) => void
onKeyDown?: (event: KeyboardEvent) => boolean
onPaste?: (event: ClipboardEvent) => boolean
onMentionClick?: (mention: ComposerMention) => void
onCompositionStart?: () => void
onCompositionEnd?: () => void
placeholder?: string
@@ -149,7 +150,19 @@ export const MentionComposer = forwardRef<MentionComposerHandle, MentionComposer
new Plugin({
key: new PluginKey('mention-composer-props'),
props: {
handleKeyDown: (_view, event) => propsRef.current.onKeyDown?.(event) ?? false,
handleKeyDown: (_view, event) => {
const chip = event.target instanceof HTMLElement ? event.target.closest<HTMLElement>('[data-mention-kind]') : null
if (chip && (event.key === 'Enter' || event.key === ' ')) {
const mention = propsRef.current.mentions.find(item => item.kind === chip.dataset.mentionKind && item.id === chip.dataset.mentionId)
if (mention) { event.preventDefault(); propsRef.current.onMentionClick?.(mention); return true }
}
return propsRef.current.onKeyDown?.(event) ?? false
},
handleClickOn: (_view, _pos, node, _nodePos, _event, direct) => {
if (!direct || !['skill', 'plugin'].includes(node.attrs.kind)) return false
propsRef.current.onMentionClick?.({ ...node.attrs, tokenOrdinal: 0 } as ComposerMention)
return true
},
handlePaste: (editorView, event, _slice) => {
if (propsRef.current.onPaste?.(event)) return true
@@ -48,3 +48,25 @@ describe('SlashCommandMenu', () => {
expect(onSelect).toHaveBeenCalledWith('project-audit')
})
})
it('uses safe skill branding and keeps the description beside its name without changing selection values', () => {
const onSelect = vi.fn()
render(<SlashCommandMenu id="brand-slash" groups={{ system: [], skills: [{ name: 'video', description: 'Make a video', kind: 'skill' }], ordered: [] }} selectedIndex={0} itemRefs={{ current: [] }} onSelect={onSelect} onHighlight={vi.fn()} showKeyboardHints={false} references={[{ kind: 'skill', id: 'video', name: 'video', displayName: 'Video', description: 'Make a video', source: 'plugin', modelText: '/video', icon: '/connectors/hyperframes.svg' }]} />)
const option = screen.getByRole('option')
expect(option.querySelector('img')).toHaveAttribute('src', '/connectors/hyperframes.svg')
expect(screen.getByText('Make a video')).not.toHaveClass('text-right')
fireEvent.click(option)
expect(onSelect).toHaveBeenCalledWith('video')
})
it('uses the same command-plugin-skill order for option ids and keyboard references', () => {
const onSelect = vi.fn()
const itemRefs = { current: [] as (HTMLElement | null)[] }
render(<SlashCommandMenu id="mixed-slash" groups={{ system: [{ name: 'help', description: 'Help', kind: 'command' }], plugins: [{ name: 'plugin:video', description: 'Video tools', kind: 'plugin' }], skills: [{ name: 'render', description: 'Render video', kind: 'skill' }], ordered: [] }} selectedIndex={1} itemRefs={itemRefs} onSelect={onSelect} onHighlight={vi.fn()} showKeyboardHints={false} references={[{ kind: 'plugin', id: 'plugin:video', name: 'video', displayName: 'Video plugin', description: 'Video tools', source: 'plugin', modelText: 'Use video' }]} />)
const options = screen.getAllByRole('option')
expect(options.map(option => option.id)).toEqual(['mixed-slash-option-0', 'mixed-slash-option-1', 'mixed-slash-option-2'])
expect(screen.getByRole('option', { name: 'Video plugin' })).toHaveAttribute('aria-selected', 'true')
fireEvent.click(options[1]!)
expect(onSelect).toHaveBeenCalledWith('plugin:video')
expect(itemRefs.current[2]).toBe(options[2])
})
@@ -26,6 +26,8 @@ import {
import { useTranslation } from '@/i18n'
import type { SlashCommandGroups } from './composerUtils'
import type { SlashCommandSource } from '@/types/slashCommand'
import type { ComposerReferenceCandidate } from '@/types/composerReference'
import { safeMentionIcon } from '@/lib/composerMentions'
const SYSTEM_SLASH_COMMAND_ICONS: Record<string, LucideIcon> = {
agent: Bot,
@@ -82,6 +84,7 @@ type SlashCommandMenuProps = {
onSelect: (commandName: string) => void
onHighlight: (index: number) => void
showKeyboardHints: boolean
references?: ComposerReferenceCandidate[]
}
export const SlashCommandMenu = forwardRef<HTMLDivElement, SlashCommandMenuProps>(
@@ -94,6 +97,7 @@ export const SlashCommandMenu = forwardRef<HTMLDivElement, SlashCommandMenuProps
onSelect,
onHighlight,
showKeyboardHints,
references = [],
},
ref,
) {
@@ -119,7 +123,7 @@ export const SlashCommandMenu = forwardRef<HTMLDivElement, SlashCommandMenuProps
>
<Icon
aria-hidden="true"
className="h-4 w-4 shrink-0 text-[var(--color-text-secondary)]"
className="h-5 w-5 shrink-0 text-[var(--color-text-secondary)]"
strokeWidth={1.8}
/>
<span className="flex min-w-0 max-w-[52%] shrink-0 items-baseline gap-1.5">
@@ -132,7 +136,7 @@ export const SlashCommandMenu = forwardRef<HTMLDivElement, SlashCommandMenuProps
</span>
) : null}
</span>
<span className="min-w-0 flex-1 truncate text-right text-xs text-[var(--color-text-tertiary)]">
<span className="min-w-0 flex-1 truncate text-left text-xs text-[var(--color-text-tertiary)]">
{command.description}
</span>
</div>
@@ -142,6 +146,7 @@ export const SlashCommandMenu = forwardRef<HTMLDivElement, SlashCommandMenuProps
return (
<div
ref={ref}
onMouseDown={event => event.preventDefault()}
className="absolute bottom-full left-0 right-0 z-[var(--z-dropdown)] mb-2 overflow-hidden rounded-[var(--radius-xl)] border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] shadow-[var(--shadow-overlay)]"
>
<div
@@ -152,56 +157,31 @@ export const SlashCommandMenu = forwardRef<HTMLDivElement, SlashCommandMenuProps
>
{groups.system.map(renderSystemCommand)}
{groups.skills.length > 0 ? (
<div
role="group"
aria-label={t('sidebar.skills')}
className={groups.system.length > 0
? 'mt-1 border-t border-[var(--color-border-separator)] pt-1'
: ''}
>
<div className="px-2.5 py-1.5 text-xs font-medium text-[var(--color-text-tertiary)]">
{t('sidebar.skills')}
</div>
{groups.skills.map((command, skillIndex) => {
const index = groups.system.length + skillIndex
return (
<div
id={getSlashCommandOptionId(id, index)}
key={command.name}
role="option"
tabIndex={-1}
aria-selected={index === selectedIndex}
ref={(element) => { itemRefs.current[index] = element }}
onClick={() => onSelect(command.name)}
onMouseEnter={() => onHighlight(index)}
className={`flex w-full cursor-default items-center gap-3 rounded-[var(--radius-md)] px-3 py-2 text-left transition-colors ${
index === selectedIndex
? 'bg-[var(--color-surface-hover)]'
: 'hover:bg-[var(--color-surface-hover)]'
}`}
>
<Box
aria-hidden="true"
className="h-4 w-4 shrink-0 text-[var(--color-text-secondary)]"
strokeWidth={1.8}
/>
<span className="min-w-0 shrink-0 truncate text-sm font-medium text-[var(--color-text-primary)]">
{command.name}
</span>
<span className="min-w-0 flex-1 truncate text-right text-xs text-[var(--color-text-tertiary)]">
{command.description}
</span>
{command.source ? (
<span className="shrink-0 text-xs text-[var(--color-text-tertiary)]">
{t(getSkillSourceLabelKey(command.source))}
</span>
) : null}
</div>
)
{([
{ kind: 'plugin' as const, items: groups.plugins ?? [], label: t('chat.referencePlugins'), offset: groups.system.length },
{ kind: 'skill' as const, items: groups.skills, label: t('sidebar.skills'), offset: groups.system.length + (groups.plugins?.length ?? 0) },
]).map(group => group.items.length > 0 ? (
<div key={group.kind} role="group" aria-label={group.label}>
<div className="px-3 pb-1 pt-2 text-xs font-medium text-[var(--color-text-tertiary)]">{group.label}</div>
{group.items.map((command, position) => {
const index = group.offset + position
const candidate = references.find(item => item.kind === group.kind && (item.name === command.name || item.id === command.name))
const icon = safeMentionIcon(candidate?.icon)
const Icon = group.kind === 'plugin' ? Package : Box
return <div
id={getSlashCommandOptionId(id, index)} key={command.name} role="option" tabIndex={-1}
aria-selected={index === selectedIndex} aria-labelledby={`${id}-label-${index}`} aria-describedby={`${id}-description-${index}`}
ref={element => { itemRefs.current[index] = element }}
onClick={() => onSelect(command.name)} onMouseEnter={() => onHighlight(index)}
className={`flex w-full cursor-default items-center gap-3 rounded-[var(--radius-md)] px-3 py-2 text-left transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)] ${index === selectedIndex ? 'bg-[var(--color-surface-hover)]' : 'hover:bg-[var(--color-surface-hover)]'}`}>
{icon ? <img src={icon} alt="" className="h-5 w-5 shrink-0 object-contain" /> : <Icon aria-hidden="true" className="h-5 w-5 shrink-0 text-[var(--color-text-secondary)]" strokeWidth={1.8} />}
<span id={`${id}-label-${index}`} className="min-w-0 max-w-[45%] shrink-0 truncate text-sm font-medium text-[var(--color-text-primary)]">{candidate?.displayName || command.name}</span>
<span id={`${id}-description-${index}`} className="min-w-0 flex-1 truncate text-xs text-[var(--color-text-tertiary)]">{command.description}</span>
{command.source ? <span className="shrink-0 text-xs text-[var(--color-text-tertiary)]">{t(getSkillSourceLabelKey(command.source))}</span> : null}
</div>
})}
</div>
) : null}
) : null)}
</div>
{showKeyboardHints ? (
<div className="flex items-center gap-1.5 border-t border-[var(--color-border)] px-4 py-2 text-xs text-[var(--color-text-tertiary)]">
@@ -143,3 +143,27 @@ describe('deleteAdjacentMentionAtom', () => {
expect(projectComposerDoc(state.doc)).toEqual({ text: 'tail', mentions: [] })
})
})
describe('capability mentions', () => {
const skill: ComposerMention = { kind: 'skill', id: 'design:poster', label: 'Poster Design', path: '', isDirectory: false, tokenOrdinal: 0, description: 'Create a poster', icon: '/connectors/canvas-design.svg', modelText: 'Use the Skill tool with skill: "design:poster" for this request.' }
const plugin: ComposerMention = { kind: 'plugin', id: 'office-hyperframes@haha-connectors', label: 'HyperFrames', path: '', isDirectory: false, tokenOrdinal: 0, description: 'Create videos', icon: '/connectors/hyperframes.svg', modelText: 'Use enabled plugin "office-hyperframes@haha-connectors" for this request.' }
it('round-trips mixed legacy files, skills and plugins without treating capabilities as paths', () => {
const text = '@Poster Design with @HyperFrames and @main.ts'
const doc = buildComposerDoc(text, [skill, plugin, fileMention])
expect(projectComposerDoc(doc)).toEqual({ text, mentions: [skill, plugin, fileMention] })
expect(serializeComposerDoc(doc)).toBe(`${skill.modelText} with ${plugin.modelText} and @"/repo/src/main.ts"`)
expect(serializeComposerDoc(doc)).not.toContain('@""')
})
it('does not rewrite identical manually typed labels or discard metadata during edits', () => {
const doc = buildComposerDoc('@HyperFrames @HyperFrames', [{ ...plugin, tokenOrdinal: 1 }])
expect(serializeComposerDoc(doc)).toBe(`@HyperFrames ${plugin.modelText}`)
expect(projectComposerDoc(doc).mentions).toEqual([{ ...plugin, tokenOrdinal: 1 }])
})
it('keeps capability atoms single-delete and rejects remote icon URLs', () => {
const doc = buildComposerDoc('@HyperFrames tail', [{ ...plugin, icon: 'https://tracker.invalid/icon.svg' }])
expect(projectComposerDoc(doc).mentions[0]!.icon).toBeUndefined()
let state = EditorState.create({ schema: composerSchema, doc, selection: TextSelection.create(doc, textOffsetToPmPos(doc, '@HyperFrames '.length)) })
deleteAdjacentMentionAtom('backward')(state, transaction => { state = state.apply(transaction) })
expect(projectComposerDoc(state.doc)).toEqual({ text: 'tail', mentions: [] })
})
})
+41 -20
View File
@@ -6,10 +6,10 @@
* on the plain-text projection from composerMentions.ts — these functions are
* the only place that knows how the projection maps onto PM positions.
*/
import { Node as PMNode, Schema } from 'prosemirror-model'
import { Node as PMNode, Schema, type DOMOutputSpec } from 'prosemirror-model'
import type { Command } from 'prosemirror-state'
import type { ComposerMention } from '../../lib/composerMentions'
import { findMentionRanges, mentionToken, tokenOccurrences } from '../../lib/composerMentions'
import { findMentionRanges, mentionToken, tokenOccurrences, safeMentionIcon } from '../../lib/composerMentions'
export const AT_MENTION_NODE = 'atMention'
@@ -35,24 +35,29 @@ export const composerSchema = new Schema({
label: { default: '' },
path: { default: '' },
isDirectory: { default: false },
kind: { default: null }, id: { default: null }, description: { default: null },
icon: { default: null }, modelText: { default: null },
},
toDOM: (node): DOMOutputSpec => {
const kind = node.attrs.kind === 'skill' || node.attrs.kind === 'plugin' ? node.attrs.kind : undefined
const icon = safeMentionIcon(node.attrs.icon)
const attrs: Record<string, string | number> = {
class: `composer-mention${kind ? ` composer-mention--${kind}` : node.attrs.isDirectory ? ' composer-mention--directory' : ''}`,
'data-mention-path': node.attrs.path,
'data-mention-label': node.attrs.label,
title: kind ? node.attrs.description || node.attrs.label : node.attrs.path,
}
if (kind) {
Object.assign(attrs, { 'data-mention-kind': kind, 'data-mention-id': node.attrs.id || '',
'data-mention-description': node.attrs.description || '', 'data-mention-icon': icon || '',
'data-mention-model-text': node.attrs.modelText || '', role: 'button', tabindex: 0,
'aria-label': node.attrs.label, 'aria-haspopup': 'dialog' })
}
const symbol: DOMOutputSpec = icon
? ['img', { class: 'composer-mention-brand-icon', src: `${import.meta.env.BASE_URL}${icon.slice(1)}`, alt: '', draggable: 'false' }]
: ['span', { class: 'material-symbols-outlined composer-mention-icon', 'aria-hidden': 'true' }, kind === 'plugin' ? 'extension' : kind === 'skill' ? 'deployed_code' : node.attrs.isDirectory ? 'folder' : 'draft']
return ['span', attrs, symbol, `@${node.attrs.label as string}`]
},
toDOM: (node) => [
'span',
{
class: node.attrs.isDirectory
? 'composer-mention composer-mention--directory'
: 'composer-mention',
'data-mention-path': node.attrs.path as string,
'data-mention-label': node.attrs.label as string,
title: node.attrs.path as string,
},
['span', {
class: 'material-symbols-outlined composer-mention-icon',
// The icon is decoration; the label already carries the meaning.
'aria-hidden': 'true',
}, node.attrs.isDirectory ? 'folder' : 'draft'],
`@${node.attrs.label as string}`,
],
parseDOM: [
{
tag: 'span.composer-mention',
@@ -62,6 +67,10 @@ export const composerSchema = new Schema({
label: dom.getAttribute('data-mention-label') ?? '',
path: dom.getAttribute('data-mention-path') ?? '',
isDirectory: dom.classList.contains('composer-mention--directory'),
kind: ['skill', 'plugin'].includes(dom.getAttribute('data-mention-kind') || '') ? dom.getAttribute('data-mention-kind') : null,
id: dom.getAttribute('data-mention-id'), description: dom.getAttribute('data-mention-description'),
icon: safeMentionIcon(dom.getAttribute('data-mention-icon') || undefined) || null,
modelText: dom.getAttribute('data-mention-model-text'),
}),
},
],
@@ -95,6 +104,7 @@ export function buildComposerDoc(text: string, mentions: ComposerMention[]): PMN
label: range.mention.label,
path: range.mention.path,
isDirectory: range.mention.isDirectory,
...extensionMentionAttrs(range.mention),
}))
cursor = range.end - lineStart
rangeIndex += 1
@@ -123,6 +133,7 @@ export function projectComposerDoc(doc: PMNode): { text: string; mentions: Compo
label: inline.attrs.label as string,
path: inline.attrs.path as string,
isDirectory: inline.attrs.isDirectory as boolean,
...extensionMentionAttrs(inline.attrs),
tokenOrdinal: 0, // resolved below
}
atoms.push({ projectedStart: text.length, mention })
@@ -152,7 +163,9 @@ export function serializeComposerDoc(doc: PMNode): string {
if (index > 0) text += '\n'
block.forEach((inline) => {
if (inline.type.name === AT_MENTION_NODE) {
text += `@"${inline.attrs.path as string}"`
text += inline.attrs.kind === 'skill' || inline.attrs.kind === 'plugin'
? (inline.attrs.modelText || `Use ${inline.attrs.kind} ${JSON.stringify(inline.attrs.label)} for this request.`)
: `@"${inline.attrs.path as string}"`
} else if (inline.isText) {
text += inline.text ?? ''
}
@@ -264,3 +277,11 @@ export function deleteAdjacentMentionAtom(direction: 'backward' | 'forward'): Co
return true
}
}
function extensionMentionAttrs(attrs: Record<string, unknown>): Partial<ComposerMention> {
if (attrs.kind !== 'skill' && attrs.kind !== 'plugin') return {}
return { kind: attrs.kind, id: typeof attrs.id === 'string' ? attrs.id : undefined,
description: typeof attrs.description === 'string' ? attrs.description : undefined,
icon: safeMentionIcon(typeof attrs.icon === 'string' ? attrs.icon : undefined),
modelText: typeof attrs.modelText === 'string' ? attrs.modelText : undefined }
}
@@ -275,3 +275,13 @@ describe('composerUtils', () => {
expect(mcpCmd?.description).not.toBe('slashCmd.mcp.description')
})
})
it('orders plugin mentions between commands and skills without changing their canonical ids', () => {
const groups = groupSlashCommands([
{ name: 'skill:video', description: 'Video', kind: 'skill' },
{ name: 'plugin:hyperframes', description: 'HyperFrames', kind: 'plugin' },
{ name: 'help', description: 'Help', kind: 'command' },
])
expect(groups.plugins?.map(item => item.name)).toEqual(['plugin:hyperframes'])
expect(groups.ordered.map(item => item.name)).toEqual(['help', 'plugin:hyperframes', 'skill:video'])
})
+7 -2
View File
@@ -151,6 +151,7 @@ export function getLocalizedFallbackCommands(t: (key: TranslationKey) => string)
export type SlashCommandGroups = {
system: SlashCommandOption[]
skills: SlashCommandOption[]
plugins?: SlashCommandOption[]
ordered: SlashCommandOption[]
}
@@ -159,9 +160,12 @@ export function groupSlashCommands(
): SlashCommandGroups {
const system: SlashCommandOption[] = []
const skills: SlashCommandOption[] = []
const plugins: SlashCommandOption[] = []
for (const command of commands) {
if (command.kind === 'skill') {
if (command.kind === 'plugin') {
plugins.push(command)
} else if (command.kind === 'skill') {
skills.push(command)
} else {
system.push(command)
@@ -171,7 +175,8 @@ export function groupSlashCommands(
return {
system,
skills,
ordered: [...system, ...skills],
plugins,
ordered: [...system, ...plugins, ...skills],
}
}
@@ -0,0 +1,36 @@
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import '@testing-library/jest-dom'
import { afterEach, expect, it, vi } from 'vitest'
import { ConnectorRow } from './ConnectorRow'
afterEach(cleanup)
it('separates the service details target from the compact add action and recovers a broken icon', () => {
const details = vi.fn()
const add = vi.fn()
const { container } = render(<ConnectorRow id="test" name="Service" description="Search documents" kind="Connector" actionLabel="Add Service" added={false} onDetails={details} onAction={add} />)
fireEvent.click(screen.getByRole('button', { name: 'Service' }))
expect(details).toHaveBeenCalledTimes(1)
expect(add).not.toHaveBeenCalled()
const action = screen.getByRole('button', { name: 'Add Service' })
expect(action.className).toContain('h-8')
fireEvent.click(action)
expect(add).toHaveBeenCalledTimes(1)
fireEvent.error(container.querySelector('img')!)
expect(container.querySelector('img')).toBeNull()
expect(screen.getByText('S')).toBeInTheDocument()
})
it('omits the badge container when no type label is needed', () => {
const { container } = render(<ConnectorRow id="test" name="Service" description="Search documents" actionLabel="Add Service" added={false} onDetails={vi.fn()} onAction={vi.fn()} />)
expect(container.querySelector('span.border')).toBeNull()
expect(screen.getByRole('button', { name: 'Service' })).toBeInTheDocument()
})
it('renders an explicit management action while preserving access to details', () => {
const details = vi.fn()
const remove = vi.fn()
render(<ConnectorRow id="test" name="Service" description="Search documents" actionLabel="Details" added onDetails={details} onAction={vi.fn()} action={<button onClick={remove}>Uninstall</button>} />)
expect(screen.queryByRole('button', { name: 'Details' })).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'Uninstall' }))
expect(remove).toHaveBeenCalledOnce()
expect(details).not.toHaveBeenCalled()
fireEvent.click(screen.getByRole('button', { name: 'Service' }))
expect(details).toHaveBeenCalledOnce()
})
@@ -0,0 +1,31 @@
import { useState, type ReactNode } from 'react'
import { IconButton } from '@/components/ui/IconButton'
export function ConnectorRow({ id, name, description, kind, status, actionLabel, added, disabled, onDetails, onAction, action }: {
action?: ReactNode
id: string
name: string
description: string
kind?: string
status?: string
actionLabel: string
added: boolean
disabled?: boolean
onDetails: () => void
onAction: () => void
}) {
const [failedIcon, setFailedIcon] = useState(false)
return <article className="flex min-h-24 items-center gap-3 rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface)] px-4 py-3 transition-colors hover:bg-[var(--color-surface-hover)]">
<button type="button" aria-label={name} onClick={onDetails} className="flex min-w-0 flex-1 items-start gap-3 rounded-[var(--radius-sm)] text-left outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)]">
<span aria-hidden="true" className="flex h-10 w-10 shrink-0 items-center justify-center text-lg font-medium text-[var(--color-text-secondary)]">
{failedIcon ? name.slice(0, 1) : <img src={`${import.meta.env.BASE_URL}connectors/${id}.svg`} alt="" className="h-10 w-10 object-contain" onError={() => setFailedIcon(true)} />}
</span>
<span className="min-w-0 flex-1">
<span className="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1"><span className="truncate text-sm font-semibold text-[var(--color-text-primary)]">{name}</span>{kind && <span className="rounded-[var(--radius-sm)] border border-[var(--color-border)] px-1 text-[10px] leading-4 text-[var(--color-text-tertiary)]">{kind}</span>}</span>
<span className="mt-1 block line-clamp-2 text-xs leading-5 text-[var(--color-text-secondary)]">{description}</span>
{status && <span className="mt-1 block text-[10px] leading-4 text-[var(--color-text-tertiary)]">{status}</span>}
</span>
</button>
{action ?? <IconButton label={actionLabel} size="md" shape="circle" tone="secondary" bordered disabled={disabled} onClick={onAction} icon={<svg aria-hidden="true" viewBox="0 0 20 20" width="16" height="16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">{added ? <path d="m7 5 5 5-5 5" /> : <path d="M10 4v12M4 10h12" />}</svg>} />}
</article>
}
@@ -0,0 +1,26 @@
import { expect, it } from 'vitest'
import { en } from '@/i18n/locales/en'
import { zh } from '@/i18n/locales/zh'
import { zh as zhTW } from '@/i18n/locales/zh-TW'
import { jp } from '@/i18n/locales/jp'
import { kr } from '@/i18n/locales/kr'
import { ALL_CONNECTORS } from '../../../../src/services/connectors/catalog'
// Catalog definitions contain only data; no adapter or user-state access runs here.
const connectorIds = ALL_CONNECTORS.map(item => item.id)
it('translates all 54 services and skill plugins in all five locales, including legal search', () => {
expect(connectorIds).toHaveLength(54)
for (const id of connectorIds) {
for (const field of ['name', 'description', 'example', 'requirements']) {
const key = `connectors.${id}.${field}`
for (const locale of [en, zh, zhTW, jp, kr]) expect((locale as Record<string, string>)[key], key).toBeTruthy()
if (field !== 'name') {
expect((en as Record<string, string>)[key], key).not.toMatch(/[\u3400-\u9fff]/)
expect((jp as Record<string, string>)[key], key).not.toBe((zh as Record<string, string>)[key])
expect((kr as Record<string, string>)[key], key).toMatch(/[\uac00-\ud7af]/)
}
}
}
for (const locale of [en, zh, zhTW, jp, kr]) expect(locale['connectors.category.legal']).toBeTruthy()
})
@@ -0,0 +1,55 @@
import { describe, expect, it } from 'vitest'
import { hasStoredCredentials, primaryAction, safeConnectorUrl } from './model'
import type { ConnectorDto } from '@/types/connector'
const connector = { supported: true, installed: true, runtime: 'ready', status: 'needs-auth', connection: 'needs-auth' } as ConnectorDto
describe('connector actions', () => {
it('requires authentication instead of claiming an installed connector is ready', () => {
expect(primaryAction(connector)).toBe('authenticate')
expect(primaryAction({ ...connector, installed: false })).toBe('prepare')
expect(primaryAction({ ...connector, supported: false })).toBeNull()
expect(primaryAction({ ...connector, operation: { id: 'x', kind: 'authenticate', phase: 'waiting', startedAt: '' } })).toBeNull()
})
it('does not mistake a negative remote check or stale account label for stored credentials', () => {
expect(hasStoredCredentials(connector)).toBe(false)
expect(hasStoredCredentials({ ...connector, status: 'error', connection: 'needs-auth' })).toBe(false)
expect(hasStoredCredentials({ ...connector, connection: 'connected' })).toBe(true)
expect(hasStoredCredentials({ ...connector, status: 'ready' })).toBe(true)
expect(hasStoredCredentials({ ...connector, status: 'configured' })).toBe(true)
expect(hasStoredCredentials({ ...connector, verification: 'remote' })).toBe(false)
expect(hasStoredCredentials({ ...connector, accountLabel: 'Existing account' })).toBe(false)
})
it('rejects executable and malformed authorization links', () => {
expect(safeConnectorUrl('javascript:alert(1)')).toBeNull()
expect(safeConnectorUrl('file:///private/file')).toBeNull()
expect(safeConnectorUrl('bad')).toBeNull()
expect(safeConnectorUrl('https://example.test/auth')).toBe('https://example.test/auth')
})
})
it('follows prepared and deactivated service DTOs even while runtime registration is missing', () => {
// prepare installs files before runtime registration; runtime=missing does not mean uninstalled.
const prepared = { ...connector, installed: true, runtime: 'missing', status: 'needs-auth', connection: 'needs-auth' } as ConnectorDto
expect(primaryAction(prepared)).toBe('authenticate')
expect(primaryAction({ ...prepared, status: 'configured', connection: 'connected' })).toBeNull()
expect(primaryAction({ ...prepared, status: 'ready', connection: 'connected' })).toBeNull()
expect(primaryAction({ ...prepared, status: 'disabled', enabled: false })).toBe('check')
expect(primaryAction({ ...prepared, status: 'error', failedPhase: 'downloading' })).toBe('prepare')
expect(primaryAction({ ...prepared, status: 'error', failedPhase: 'awaiting-authorization' })).toBe('authenticate')
expect(primaryAction({ ...prepared, status: 'error', failedPhase: 'check' })).toBe('check')
})
it('offers an update for an installed older connector without hiding active operations', () => {
const outdated = { ...connector, status: 'ready', connection: 'connected', installedVersion: '1', version: '2', updateAvailable: true } as ConnectorDto
expect(primaryAction(outdated)).toBe('prepare')
expect(primaryAction({ ...outdated, operation: { id: 'update', kind: 'prepare', phase: 'downloading', startedAt: '' } })).toBeNull()
})
it('loads tool skills without requesting service authentication, including stale authorization errors', () => {
const tool = { ...connector, collection: 'tools', transport: 'skills' } as ConnectorDto
expect(primaryAction(tool)).toBe('check')
expect(primaryAction({ ...tool, status: 'error', failedPhase: 'awaiting-authorization' })).toBe('check')
expect(primaryAction({ ...tool, status: 'error', failedPhase: 'downloading' })).toBe('prepare')
expect(primaryAction({ ...tool, status: 'configured', enabled: true })).toBeNull()
expect(primaryAction({ ...tool, status: 'disabled', enabled: false })).toBe('check')
})
@@ -0,0 +1,32 @@
import type { ConnectorAction, ConnectorDto } from '@/types/connector'
export function primaryAction(connector: ConnectorDto): Exclude<ConnectorAction, 'cancel' | 'remove' | 'deactivate'> | null {
if (!connector.supported || connector.operation) return null
if (!connector.installed || connector.updateAvailable) return 'prepare'
if (connector.status === 'disabled') return 'check'
if (connector.status === 'error') {
if (['prepare', 'downloading', 'extracting', 'verifying'].includes(connector.failedPhase || '')) return 'prepare'
if (connector.transport === 'skills' || connector.collection === 'tools') return 'check'
if (['authenticate', 'authorizing', 'awaiting-authorization', 'configuring-account'].includes(connector.failedPhase || '')) return 'authenticate'
return 'check'
}
if (connector.transport === 'skills' || connector.collection === 'tools') return connector.enabled && (connector.status === 'ready' || connector.status === 'configured') ? null : 'check'
if (connector.status === 'needs-auth' || connector.connection === 'needs-auth') return 'authenticate'
if (connector.status === 'ready' || connector.status === 'configured') return null
return 'check'
}
// A connector that already holds a credential can be connected or re-checked
// with an untouched setup form: blank fields keep the stored secret. Until a
// successful connection proves a credential exists, blank fields are
// missing input instead of an implicit "use what is stored".
export function hasStoredCredentials(connector: ConnectorDto): boolean {
return connector.connection === 'connected' || connector.status === 'ready' || connector.status === 'configured'
}
export function safeConnectorUrl(value: string): string | null {
try {
const url = new URL(value)
return url.protocol === 'https:' || url.protocol === 'http:' ? url.href : null
} catch { return null }
}
@@ -1,3 +1,5 @@
vi.mock('../../pages/ExtensionMarket', () => ({ ExtensionMarket: () => <div data-testid="extension-market-page" /> }))
vi.mock('../../pages/Connectors', () => ({ Connectors: () => <div data-testid="connectors-page" /> }))
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
import '@testing-library/jest-dom'
import { afterEach, describe, expect, it, vi } from 'vitest'
@@ -256,7 +258,7 @@ describe('ContentRouter tab surfaces', () => {
render(<ContentRouter />)
expect(screen.getByTestId('market-page')).toBeInTheDocument()
expect(screen.getByTestId('extension-market-page')).toBeInTheDocument()
expect(screen.queryByTestId('active-session')).not.toBeInTheDocument()
})
@@ -299,3 +301,9 @@ describe('ContentRouter tab surfaces', () => {
expect(useTabStore.getState().tabs.find(tab => tab.sessionId === 'session-1')).toMatchObject({ type: 'session' })
})
})
it('routes the independent connectors tab', () => {
useTabStore.setState({ tabs: [{ sessionId: '__connectors__', title: 'Connectors', type: 'connectors', status: 'idle' }], activeTabId: '__connectors__' })
render(<ContentRouter />)
expect(screen.getByTestId('extension-market-page')).toBeInTheDocument()
})
@@ -3,7 +3,7 @@ import { useTabStore } from '../../stores/tabStore'
import { EmptySession } from '../../pages/EmptySession'
import { ActiveSession } from '../../pages/ActiveSession'
import { ScheduledTasks } from '../../pages/ScheduledTasks'
import { Market } from '../../pages/Market'
import { ExtensionMarket } from '@/pages/ExtensionMarket'
import { Settings } from '../../pages/Settings'
import { TerminalSettings } from '../../pages/TerminalSettings'
import { TraceList } from '../../pages/TraceList'
@@ -36,8 +36,8 @@ export function ContentRouter() {
page = <Settings />
} else if (activeTabType === 'scheduled') {
page = <ScheduledTasks />
} else if (activeTabType === 'market') {
page = <Market />
} else if (activeTabType === 'connectors' || activeTabType === 'market') {
page = <ExtensionMarket />
} else if (activeTabType === 'trace') {
const traceTabId = activeTabId
const traceSessionId = tabs.find((t) => t.sessionId === traceTabId)?.traceSessionId
@@ -59,7 +59,7 @@ vi.mock('../../i18n', () => ({
const translations: Record<string, string> = {
'sidebar.newSession': 'New Session',
'sidebar.scheduled': 'Scheduled',
'sidebar.market': 'Skills Market',
'sidebar.extensions': 'Extension Market',
'sidebar.settings': 'Settings',
'sidebar.searchPlaceholder': 'Search sessions',
'sidebar.noSessions': 'No sessions',
@@ -2073,7 +2073,7 @@ describe('Sidebar', () => {
render(<Sidebar isMobile onRequestClose={onRequestClose} />)
expect(screen.queryByRole('button', { name: 'Scheduled' })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Skills Market' })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Extension Market' })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Settings' })).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: /Open Session/ }))
@@ -2089,15 +2089,17 @@ describe('Sidebar', () => {
expect(onRequestClose).toHaveBeenCalledTimes(2)
})
it('keeps the market entry available in desktop navigation', () => {
it('keeps one unified extension market entry in desktop navigation', () => {
render(<Sidebar />)
fireEvent.click(screen.getByRole('button', { name: 'Skills Market' }))
expect(screen.queryByRole('button', { name: 'sidebar.connectors' })).not.toBeInTheDocument()
expect(screen.getAllByRole('button', { name: 'Extension Market' })).toHaveLength(1)
fireEvent.click(screen.getByRole('button', { name: 'Extension Market' }))
expect(useTabStore.getState().activeTabId).toBe('__market__')
expect(useTabStore.getState().tabs).toEqual(
expect.arrayContaining([
expect.objectContaining({ sessionId: '__market__', title: 'Skills Market', type: 'market' }),
expect.objectContaining({ sessionId: '__market__', title: 'Extension Market', type: 'market' }),
]),
)
})
+6 -5
View File
@@ -27,7 +27,7 @@ import {
} from './sidebarTaskGroups'
import { sessionsApi } from '../../api/sessions'
import type { SessionListItem } from '../../types/session'
import { useTabStore, SETTINGS_TAB_ID, SCHEDULED_TAB_ID, MARKET_TAB_ID } from '../../stores/tabStore'
import { useTabStore, SETTINGS_TAB_ID, SCHEDULED_TAB_ID, MARKET_TAB_ID, CONNECTORS_TAB_ID } from '../../stores/tabStore'
import { useChatStore } from '../../stores/chatStore'
import { useOpenTargetStore } from '../../stores/openTargetStore'
import {
@@ -1054,19 +1054,20 @@ export function Sidebar({
)}
{!isMobile && (
<NavItem
active={activeTabId === MARKET_TAB_ID}
active={activeTabId === MARKET_TAB_ID || activeTabId === CONNECTORS_TAB_ID}
collapsed={!expanded}
label={t('sidebar.market')}
label={t('sidebar.extensions')}
touchFriendly={isMobile}
onClick={() => {
useTabStore.getState().openTab(MARKET_TAB_ID, t('sidebar.market'), 'market')
useTabStore.getState().openTab(MARKET_TAB_ID, t('sidebar.extensions'), 'market')
closeMobileDrawer()
}}
icon={<StorefrontIcon />}
>
{t('sidebar.market')}
{t('sidebar.extensions')}
</NavItem>
)}
</div>
{expanded ? (
@@ -143,6 +143,7 @@ vi.mock('../../api/sessions', () => ({
vi.mock('../../i18n', () => ({
useTranslation: () => (key: string, params?: Record<string, string | number>) => {
const translations: Record<string, string> = {
'sidebar.extensions': 'Extension Market',
'tabs.close': 'Close',
'tabs.closeOthers': 'Close Others',
'tabs.closeLeft': 'Close Left',
@@ -1131,7 +1132,7 @@ describe('TabBar', () => {
// message: "this tab is not a conversation".
expect(screen.getByText('Idle Session').previousElementSibling?.textContent).toBe('')
expect(screen.getByText('Terminal').previousElementSibling?.textContent).toBe('terminal')
expect(screen.getByText('Skill Market').previousElementSibling?.textContent).toBe('storefront')
expect(screen.getByText('Extension Market').previousElementSibling?.textContent).toBe('storefront')
expect(screen.queryByText('chat_bubble')).not.toBeInTheDocument()
})
@@ -2383,7 +2384,7 @@ describe('TabBar', () => {
render(<TabBar />)
})
fireEvent.click(screen.getByLabelText('Close Market'))
fireEvent.click(screen.getByLabelText('Close Extension Market'))
expect(disconnectSession).not.toHaveBeenCalled()
expect(useTabStore.getState().tabs.map((tab) => tab.sessionId)).toEqual(['tab-1'])
+4 -1
View File
@@ -4,6 +4,7 @@ import {
SCHEDULED_TAB_ID,
SETTINGS_TAB_ID,
MARKET_TAB_ID,
CONNECTORS_TAB_ID,
SUBAGENT_TAB_PREFIX,
TEAM_MEMBER_TAB_PREFIX,
TEAM_TAB_PREFIX,
@@ -66,6 +67,7 @@ const TAB_TYPE_ICON: Partial<Record<TabType, string>> = {
settings: 'settings',
scheduled: 'schedule',
market: 'storefront',
connectors: 'link',
terminal: 'terminal',
trace: 'account_tree',
traces: 'account_tree',
@@ -97,6 +99,7 @@ function isSessionTabId(tabId: string | null) {
return tabId !== SETTINGS_TAB_ID &&
tabId !== SCHEDULED_TAB_ID &&
tabId !== MARKET_TAB_ID &&
tabId !== CONNECTORS_TAB_ID &&
tabId !== TRACE_LIST_TAB_ID &&
!tabId.startsWith(TERMINAL_TAB_PREFIX) &&
!tabId.startsWith(TRACE_TAB_PREFIX) &&
@@ -584,7 +587,7 @@ export function TabBar() {
{tabs.map((tab, index) => {
const displayTitle = tab.type === 'settings'
? t('settings.title')
: (tab.title || t('tabs.untitled'))
: tab.type === 'market' || tab.type === 'connectors' ? t('sidebar.extensions') : (tab.title || t('tabs.untitled'))
return (
<TabItem
key={tab.sessionId}
@@ -256,3 +256,10 @@ describe('MarketHome infinite scroll', () => {
expect(loadMore).toHaveBeenCalledTimes(1)
})
})
it('keeps featured skill packages inside the existing scroll surface alongside search and installed skills', () => {
render(<MarketHome onRequestInstall={vi.fn()} featured={<div data-testid="curated-skill-packages" />} />)
expect(screen.getByTestId('market-scroll')).toContainElement(screen.getByTestId('curated-skill-packages'))
expect(screen.getByTestId('market-search-input')).toBeInTheDocument()
expect(screen.getByTestId('market-installed-entry')).toBeInTheDocument()
})
+4 -2
View File
@@ -1,4 +1,4 @@
import { forwardRef, useCallback, useEffect, useRef, useState } from 'react'
import { forwardRef, useCallback, useEffect, useRef, useState, type ReactNode } from 'react'
import { ArrowUpRight, CloudOff, PackageSearch, RefreshCw, Search, Sparkles, Store, X } from 'lucide-react'
import { useTranslation } from '../../i18n'
import { Button } from '@/components/ui/Button'
@@ -28,7 +28,7 @@ const PREFETCH_MARGIN = '400px'
const CATALOG_GRID_STYLE = { gridTemplateColumns: CATALOG_GRID_TEMPLATE, gap: CATALOG_GAP }
export function MarketHome({ onRequestInstall }: { onRequestInstall: (id: string) => void }) {
export function MarketHome({ onRequestInstall, featured }: { onRequestInstall: (id: string) => void, featured?: ReactNode }) {
const t = useTranslation()
const {
items,
@@ -171,6 +171,8 @@ export function MarketHome({ onRequestInstall }: { onRequestInstall: (id: string
<FilterBar />
</div>
{featured}
{!isLoading && items.length > 0 && (
<p className="mb-4 mt-5 text-sm tabular-nums text-[var(--color-text-secondary)]">
{t('market.resultCount', { count: String(items.length) })}
+10 -5
View File
@@ -11,8 +11,8 @@ import { Button } from '@/components/ui/Button'
import { ConfirmDialog } from '@/components/ui/ConfirmDialog'
import { LoadingState } from '@/components/ui/LoadingState'
export function SkillDetail() {
const { selectedSkill, selectedSkillReturnTab, isDetailLoading, clearSelection, fetchSkills } = useSkillStore()
export function SkillDetail({ embedded = false }: { embedded?: boolean }) {
const { selectedSkill, selectedSkillReturnTab, selectedSkillContext, isDetailLoading, clearSelection, fetchSkills } = useSkillStore()
const t = useTranslation()
const [confirmUninstall, setConfirmUninstall] = useState(false)
const [uninstalling, setUninstalling] = useState(false)
@@ -20,10 +20,10 @@ export function SkillDetail() {
const handleBack = useCallback(() => {
const returnTab = selectedSkillReturnTab
clearSelection()
if (returnTab === 'plugins') {
if (!embedded && returnTab === 'plugins') {
useUIStore.getState().setPendingSettingsTab('plugins')
}
}, [selectedSkillReturnTab, clearSelection])
}, [selectedSkillReturnTab, clearSelection, embedded])
const files = selectedSkill?.files ?? []
@@ -92,7 +92,7 @@ export function SkillDetail() {
})
setConfirmUninstall(false)
clearSelection()
void fetchSkills()
void fetchSkills(selectedSkillContext || undefined)
// Keep the market list in sync when it has this skill loaded.
const market = useMarketStore.getState()
const detailCache = new Map(market.detailCache)
@@ -130,6 +130,11 @@ export function SkillDetail() {
return (
<>
{embedded && !marketMeta && (
<p className="mb-4 text-sm leading-6 text-[var(--color-text-secondary)]">
{t('extensions.managedSkillHint')}
</p>
)}
<SkillDetailView
name={skillMeta.displayName || skillMeta.name}
version={skillMeta.version}
+13 -1
View File
@@ -23,6 +23,7 @@ import { EmptyState } from '@/components/ui/EmptyState'
import { ErrorState } from '@/components/ui/ErrorState'
import { IconButton } from '@/components/ui/IconButton'
import { LoadingState } from '@/components/ui/LoadingState'
import { SearchField } from '@/components/ui/SearchField'
import type { SkillMeta, SkillSource } from '../../types/skill'
const SOURCE_ORDER: SkillSource[] = ['user', 'project', 'plugin', 'mcp', 'bundled']
@@ -48,7 +49,7 @@ function estimateTokens(contentLength: number) {
return Math.ceil(contentLength / 4)
}
export function SkillList() {
export function SkillList({ compact = false }: { compact?: boolean }) {
const { skills, isLoading, error, fetchSkills, fetchSkillDetail } =
useSkillStore()
const sessions = useSessionStore((s) => s.sessions)
@@ -124,6 +125,16 @@ export function SkillList() {
return (
<div className="flex min-w-0 flex-col gap-6">
{compact ? (
<SearchField
label={t('settings.skills.searchLabel')}
placeholder={t('settings.skills.searchPlaceholder')}
value={searchQuery}
onChange={setSearchQuery}
onClear={() => setSearchQuery('')}
clearLabel={t('settings.skills.clearSearch')}
/>
) : (
<Card radius="xl" surface="low" padding="none" className="overflow-hidden">
<div className="grid min-w-0 gap-5 px-5 py-5 xl:grid-cols-[minmax(0,1.15fr)_minmax(400px,1fr)] xl:items-end">
<div className="min-w-0">
@@ -201,6 +212,7 @@ export function SkillList() {
</div>
</div>
</Card>
)}
{filteredSkills.length === 0 && (
<EmptyState
+351
View File
@@ -1,4 +1,355 @@
export const en = {
"extensions.myPlugins": "My plugins",
"extensions.mySkills": "My skills",
"extensions.browse": "Back to browse",
"extensions.managedSkillHint": "Companion skills are managed with their plugin. Manage local and project skills in their source directories.",
"extensions.searchPlugins": "Search plugins",
"extensions.searchSkills": "Search featured skills",
"extensions.pluginsSubtitle": "Connect everyday services or add creative and development tools to your assistant.",
"extensions.filter": "Filter extensions",
"extensions.categories": "Extension categories",
"extensions.empty": "No matching extensions",
"chat.references": "References",
"chat.referencePlugins": "Plugins",
"chat.referenceSkills": "Skills",
"chat.referenceFiles": "Files",
"chat.referencesLoadFailed": "Could not load skills and plugins",
"chat.referencesEmpty": "No matching references",
"chat.referenceDescription": "Description",
"chat.referenceSource": "Source",
"chat.referenceDetails": "Reference details",
"sidebar.extensions": "Skills · Connectors",
"extensions.sections": "Market sections",
"extensions.plugins": "Plugins",
"extensions.skills": "Skills",
"extensions.featuredSkills": "Featured skills",
"connectors.airtable.description": "Query tables, records and team information.",
"connectors.airtable.example": "Review recently updated project records in Airtable.",
"connectors.airtable.name": "Airtable",
"connectors.airtable.requirements": "Authorize workspaces or bases in the browser. Enterprise administrators may restrict client access.",
"connectors.algorithmic-art.name": "Algorithmic art",
"connectors.algorithmic-art.description": "Create adjustable algorithmic art and interactive visuals with p5.js.",
"connectors.algorithmic-art.example": "Build a flow-field artwork page with adjustable colors and density.",
"connectors.algorithmic-art.requirements": "Includes templates and reference code. Previewing needs a browser and p5.js resources. Installation does not execute scripts or call generation models.",
"connectors.atlassian.description": "Search Jira, Confluence and project collaboration content.",
"connectors.atlassian.example": "Summarize my Jira issues and link relevant Confluence pages.",
"connectors.atlassian.name": "Atlassian",
"connectors.atlassian.requirements": "Requires an Atlassian Cloud account and product access through browser authorization. Some products and operations need administrator enablement.",
"connectors.bazhuayu.description": "Find collection templates, cloud tasks and collected data.",
"connectors.bazhuayu.example": "List supported platforms without creating or starting collection tasks.",
"connectors.bazhuayu.name": "Octoparse China",
"connectors.bazhuayu.requirements": "Requires an Octoparse China account API Key. Cloud collection only; running tasks may consume balance and some templates require specific plans.",
"connectors.canvas-design.name": "Canvas design",
"connectors.canvas-design.description": "Create posters, static artwork and PDF canvases, with bundled fonts.",
"connectors.canvas-design.example": "Design a technology event poster and export it as PDF.",
"connectors.canvas-design.requirements": "Includes the complete design skill, fonts and their licenses. Image or PDF generation needs Python and suitable drawing libraries, which are not installed automatically.",
"connectors.clickup.description": "Find projects, tasks and team work materials.",
"connectors.clickup.example": "List unfinished ClickUp tasks assigned to me.",
"connectors.clickup.name": "ClickUp",
"connectors.clickup.requirements": "Requires a ClickUp account and workspace access, authorized in the browser. The service is in public beta.",
"connectors.cloudflare.description": "Inspect and manage websites, networking and cloud resources.",
"connectors.cloudflare.example": "Review my Cloudflare resources and summarize their configuration.",
"connectors.cloudflare.name": "Cloudflare",
"connectors.cloudflare.requirements": "Requires a user or account API Token with the necessary resource permissions. Deployments and configuration changes require an explicit task.",
"connectors.drawio.name": "draw.io diagrams",
"connectors.drawio.description": "Create editable architecture, flow and relationship diagrams.",
"connectors.drawio.example": "Turn this system description into an editable draw.io architecture diagram.",
"connectors.drawio.requirements": "Write .drawio XML directly. Mermaid conversion, automatic layout and image export require the draw.io desktop CLI. Installing this skill does not install the desktop app.",
"connectors.firecrawl.description": "Search the web and extract pages for research.",
"connectors.firecrawl.example": "Find technical resources on browser automation and keep source links.",
"connectors.firecrawl.name": "Firecrawl",
"connectors.firecrawl.requirements": "Requires a Firecrawl API Key. Usage is billed against the account quota; the key is sent in a secure request header.",
"connectors.frontend-design.name": "Frontend design",
"connectors.frontend-design.description": "Build frontend interfaces with clear visual hierarchy.",
"connectors.frontend-design.example": "Design and implement a home page for my knowledge management app.",
"connectors.frontend-design.requirements": "Installs the Anthropic frontend design skill. Previewing and building require the target project dependencies and runtime. Hosting and image generation services are not included.",
"connectors.hyperframes.name": "HyperFrames",
"connectors.hyperframes.description": "Create HTML videos, captions and product demos.",
"connectors.hyperframes.example": "Create a 20-second product introduction with HyperFrames.",
"connectors.hyperframes.requirements": "Installs five complete skills and references, not a renderer. Rendering needs Node.js 22+, HyperFrames CLI and FFmpeg. Local speech may need Python 3.10+; online media services need separate setup and may charge fees.",
"connectors.intercom.description": "Search customer conversations, contacts and help content.",
"connectors.intercom.example": "Summarize common questions in recent customer feedback.",
"connectors.intercom.name": "Intercom",
"connectors.intercom.requirements": "US-hosted workspaces only. Requires an access token with contact, conversation and article permissions.",
"connectors.jufa.description": "Find court cases and summarize judicial reasoning.",
"connectors.jufa.example": "Find construction contract cases and cite case numbers, courts and reasoning.",
"connectors.jufa.name": "Jufa Cases",
"connectors.jufa.requirements": "Requires a Jufa Agent API Key. This connector covers case research; other topics need separate integration. Plan and call quotas apply.",
"connectors.kuaicha.description": "Research company registrations, operations and risks.",
"connectors.kuaicha.example": "Check a company’s registration and business exceptions, identifying missing data.",
"connectors.kuaicha.name": "THS Kuaicha",
"connectors.kuaicha.requirements": "Requires a Kuaicha API Key. Only mainland China IP addresses are supported. Account permissions and quotas apply.",
"connectors.listinggood.description": "Review Amazon listings for compliance and readability.",
"connectors.listinggood.example": "Review the Amazon listing text I provide and suggest improvements.",
"connectors.listinggood.name": "ListingGood",
"connectors.listinggood.requirements": "Requires a ListingGood China API Key. China and global keys are not interchangeable. Paid tools consume account quota.",
"connectors.mcp-builder.name": "MCP builder",
"connectors.mcp-builder.description": "Build and evaluate MCP servers with TypeScript and Python references.",
"connectors.mcp-builder.example": "Design an MCP server for our internal knowledge base.",
"connectors.mcp-builder.requirements": "Includes design references and evaluation scripts. Development needs Node.js or Python and an MCP SDK. Model evaluations and third-party APIs require user-provided credentials and may incur charges.",
"connectors.miro.description": "Review collaborative boards and organize design discussions.",
"connectors.miro.example": "Summarize the ideas and action items on a Miro board I choose.",
"connectors.miro.name": "Miro",
"connectors.miro.requirements": "Requires a Miro account and board access. Organization administrators may need to enable MCP.",
"connectors.neon.description": "Inspect database projects, branches and development schemas.",
"connectors.neon.example": "Inspect my Neon development database schema without changing data.",
"connectors.neon.name": "Neon",
"connectors.neon.requirements": "Requires a Neon API Key and project access. The provider recommends development and test databases.",
"connectors.obsidian.name": "Obsidian toolkit",
"connectors.obsidian.description": "Edit Markdown notes, Canvas boards and Bases views.",
"connectors.obsidian.example": "Turn these project notes into an Obsidian Canvas relationship map.",
"connectors.obsidian.requirements": "Local file editing needs no cloud account. CLI operations require Obsidian to be installed and running. Optional extraction and templating features need their own CLIs. Only skill files are installed.",
"connectors.postman.description": "Find API workspaces, collections and development resources.",
"connectors.postman.example": "Find login-related APIs in my Postman workspace.",
"connectors.postman.name": "Postman",
"connectors.postman.requirements": "Authorize a US-region Postman account in the browser. This connector uses the minimal tool set; EU accounts need a separate API-key configuration.",
"connectors.qcc.description": "Look up companies and verify registration information.",
"connectors.qcc.example": "Find registration information for a company I specify and cite sources.",
"connectors.qcc.name": "Qichacha",
"connectors.qcc.requirements": "Requires a Qichacha Agent API Key. This connector covers basic company information only; other topics require separate integration. Account permissions and credits apply.",
"connectors.qingflow.description": "Query workspace apps and business data for workflow collaboration.",
"connectors.qingflow.example": "Review accessible Qingflow apps and summarize pending work.",
"connectors.qingflow.name": "Qingflow",
"connectors.qingflow.requirements": "Requires a Qingflow account with browser authorization. Workspace and app permissions apply; capabilities depend on the plan.",
"connectors.qveris.description": "Discover data tools for information research.",
"connectors.qveris.example": "Find company research tools in QVeris and explain parameters and costs without running them.",
"connectors.qveris.name": "QVeris",
"connectors.qveris.requirements": "Requires a QVeris API Key. Available tools and costs are provider-controlled. Reconnect after changing the key.",
"connectors.render.description": "Review deployed services, databases, logs and metrics.",
"connectors.render.example": "Review recent Render service logs and summarize errors.",
"connectors.render.name": "Render",
"connectors.render.requirements": "Requires a Render API Key. Select a workspace first. The key accesses workspaces you belong to; deployments require an explicit task.",
"connectors.sorftime.description": "Research cross-border commerce, products and keywords.",
"connectors.sorftime.example": "Research a product’s market trends with marketplace, dates and data sources.",
"connectors.sorftime.name": "Sorftime",
"connectors.sorftime.requirements": "Requires Sorftime MCP activation and an Account-SK. Purchased services determine platforms, marketplaces and query limits.",
"connectors.todoist.description": "Review tasks, projects and personal schedules.",
"connectors.todoist.example": "List my overdue Todoist tasks and tasks due today.",
"connectors.todoist.name": "Todoist",
"connectors.todoist.requirements": "Requires a Todoist account with browser authorization for tasks and projects.",
"connectors.variflight.description": "Look up flight status, airports and travel information.",
"connectors.variflight.example": "Check a flight’s scheduled time and current status with a query timestamp.",
"connectors.variflight.name": "VariFlight",
"connectors.variflight.requirements": "Sign in to the VariFlight MCP platform to generate an MCP Key. Personal itineraries need account access; provider quotas and data limits apply.",
"connectors.webapp-testing.name": "Web app testing",
"connectors.webapp-testing.description": "Inspect local web pages, interactions and screenshots with Playwright.",
"connectors.webapp-testing.example": "Check form validation and layout in my local app.",
"connectors.webapp-testing.requirements": "Includes the testing skill, Python helpers and examples. Running tests needs Python, Playwright, a browser and the target app. Installation does not download browsers.",
"connectors.webflow.description": "Read website structure, CMS content and design context.",
"connectors.webflow.example": "Inspect CMS collections and fields on my Webflow site.",
"connectors.webflow.name": "Webflow",
"connectors.webflow.requirements": "Requires a Webflow account with site access. OAuth installs the MCP Bridge App; Designer operations require the bridge connection.",
"connectors.xmind.description": "Read and organize online mind maps for planning.",
"connectors.xmind.example": "List recent online Xmind maps and summarize the one I select.",
"connectors.xmind.name": "Xmind",
"connectors.xmind.requirements": "Requires a mainland China Xmind account with browser authorization. Only authorized team maps are accessible; editing depends on the plan.",
"connectors.toolsRemoveInfo": "Remove this tool plugin and its skill files. Existing tasks and output files are kept.",
"connectors.collection": "Catalog type",
"connectors.services": "Service connectors",
"connectors.tools": "Tool plugins",
"connectors.region": "Service region",
"connectors.region.all": "All regions",
"connectors.region.china": "China services",
"connectors.region.global": "Global services",
"connectors.toolKind": "Tool plugin",
"connectors.toolsSubtitle": "Add focused skills and working methods to your tasks.",
"connectors.skillsLoaded": "Skills loaded",
"connectors.skillsPending": "Skills not loaded",
"connectors.verifySkills": "Verify skills",
"connectors.skillsRuntimeInfo": "This installs a skill package. Node.js, command-line tools, or other runtimes required by a task must be available separately. Loaded skills do not mean those dependencies are installed.",
"connectors.saveAndVerify": "Save and verify",
"connectors.needsService": "Needs service connection",
"connectors.jinshuju.name": "Jinshuju",
"connectors.jinshuju.description": "Review forms and collected responses for analysis.",
"connectors.jinshuju.example": "Summarize recently collected questionnaire responses in Jinshuju.",
"connectors.jinshuju.requirements": "Requires a Jinshuju account. Authorize form and data access in the browser.",
"connectors.tencent-docs.name": "Tencent Docs",
"connectors.tencent-docs.description": "Read and organize online documents and spreadsheets.",
"connectors.tencent-docs.example": "Find project planning materials in Tencent Docs.",
"connectors.tencent-docs.requirements": "Requires a Tencent Docs MCP access token. Only documents already accessible to the account can be used.",
"connectors.pkulaw.name": "PKULaw",
"connectors.pkulaw.description": "Search laws and regulations with original sources.",
"connectors.pkulaw.example": "Find legal provisions on employment probation periods and cite the sources.",
"connectors.pkulaw.requirements": "Requires a PKULaw MCP access token and access to the relevant materials. Check whether retrieved provisions are currently effective.",
"connectors.datayes.name": "Datayes",
"connectors.datayes.description": "Retrieve stock prices and financial market data for research.",
"connectors.datayes.example": "Find historical prices for a stock I specify and record the data sources.",
"connectors.datayes.requirements": "Requires a Datayes MCP Token. Data access depends on account permissions and quota. Enterprise accounts must meet the provider’s verification requirements.",
"connectors.tencent-search.name": "Tencent Cloud Search",
"connectors.tencent-search.description": "Search web resources with traceable sources.",
"connectors.tencent-search.example": "Research the development of China’s low-altitude economy and collect official sources.",
"connectors.tencent-search.requirements": "Requires Tencent Cloud WSA service activation and an API Key. Account quotas and billing rules apply.",
"connectors.connectService": "Connect service",
"connectors.officialMcp": "Official MCP service",
"connectors.amap.name": "Amap",
"connectors.amap.description": "Search places, plan routes and check travel information.",
"connectors.amap.example": "Plan a public transit route from Hangzhou East Station to West Lake.",
"connectors.amap.requirements": "Requires an Amap Web Service API Key. Services depend on the account permissions and quota.",
"connectors.baidu-maps.name": "Baidu Maps",
"connectors.baidu-maps.description": "Geocode addresses, search nearby places and plan routes.",
"connectors.baidu-maps.example": "Find hotels and transport near Shanghai Hongqiao Station.",
"connectors.baidu-maps.requirements": "Requires a Baidu Maps server-side AK and access to the relevant services.",
"connectors.tencent-maps.name": "Tencent Maps",
"connectors.tencent-maps.description": "Look up locations, distances and travel routes.",
"connectors.tencent-maps.example": "Compare transit and driving routes from Guangzhou South Station to Canton Tower.",
"connectors.tencent-maps.requirements": "Requires a Tencent Location Service Key with WebServiceAPI permissions and quota.",
"connectors.zhipu-search.name": "Zhipu Web Search",
"connectors.zhipu-search.description": "Search Chinese webpages, news and current information with sources.",
"connectors.zhipu-search.example": "Find recent Chinese electric vehicle policies and link the original sources.",
"connectors.zhipu-search.requirements": "Requires a GLM Coding Plan API Key. Team plan keys and general platform keys are not interchangeable.",
"connectors.notion.name": "Notion",
"connectors.notion.description": "Search workspace content and organize pages and project knowledge.",
"connectors.notion.example": "Find recent project reports in Notion and summarize progress.",
"connectors.notion.requirements": "Requires a Notion account. Browser authorization only grants access to workspace content available to that account.",
"connectors.linear.name": "Linear",
"connectors.linear.description": "Find issues, projects and cycles to follow team work.",
"connectors.linear.example": "List unfinished Linear issues assigned to me.",
"connectors.linear.requirements": "Requires a Linear workspace account and project permissions. Connect through browser authorization.",
"connectors.github.name": "GitHub",
"connectors.github.description": "Search repository code, issues and pull requests.",
"connectors.github.example": "Find pull requests awaiting review in my GitHub repositories.",
"connectors.github.requirements": "Requires a GitHub Personal Access Token with permissions for the needed repositories and operations. Organization policies still apply.",
"connectors.context7.name": "Context7",
"connectors.context7.description": "Find current library documentation and usage examples.",
"connectors.context7.example": "Find React useEffect documentation and cleanup function examples.",
"connectors.context7.requirements": "Uses the official anonymous access allowance. Access is limited and the service may return quota errors.",
"connectors.sentry.name": "Sentry",
"connectors.sentry.description": "Inspect errors, issues and application runtime information.",
"connectors.sentry.example": "Summarize errors first seen in Sentry during the last day.",
"connectors.sentry.requirements": "Requires a Sentry account with organization and project permissions. Connect through browser authorization.",
"connectors.tavily.name": "Tavily",
"connectors.tavily.description": "Search webpages and extract content for research.",
"connectors.tavily.example": "Compare open-source vector databases and cite sources.",
"connectors.tavily.requirements": "Requires a Tavily API Key. Calls use the account subscription and quota.",
"connectors.exa.name": "Exa",
"connectors.exa.description": "Discover related webpages, code and technical resources.",
"connectors.exa.example": "Find technical articles about observability in distributed systems.",
"connectors.exa.requirements": "Uses the official basic anonymous allowance. Available tools and usage limits are set by the provider.",
"connectors.supabase.name": "Supabase",
"connectors.supabase.description": "Inspect projects, database schemas and development resources.",
"connectors.supabase.example": "Inspect the tables in my Supabase project without changing data.",
"connectors.supabase.requirements": "Requires a Supabase account with organization and project access. Database writes still require an explicit task instruction.",
"connectors.huggingface.name": "Hugging Face",
"connectors.huggingface.description": "Discover models, datasets and machine learning research.",
"connectors.huggingface.example": "Find public models and datasets for Chinese text classification.",
"connectors.huggingface.requirements": "Requires a Hugging Face token. Read permissions are recommended for search; compute tasks may incur separate charges.",
"connectors.canva.name": "Canva",
"connectors.canva.description": "Find design assets and collaborate on design content.",
"connectors.canva.example": "Find recent brand design assets in Canva.",
"connectors.canva.requirements": "Each user authorizes their own Canva account. Some capabilities depend on subscription and team permissions.",
"connectors.figma.name": "Figma",
"connectors.figma.description": "Read design context, components and interface structure.",
"connectors.figma.example": "Analyze the components in a Figma design link I provide.",
"connectors.figma.requirements": "Requires Figma account authorization. Design access, seats and plan determine available tools and usage.",
"connectors.stripe.name": "Stripe",
"connectors.stripe.description": "Find customer, billing and payment business information.",
"connectors.stripe.example": "Review recent Stripe invoices without creating payments or refunds.",
"connectors.stripe.requirements": "Requires Stripe account authorization. The official service is in preview; payments, refunds and other writes need explicit task instructions.",
"connectors.category.legal": "Legal",
"connectors.categories": "Service categories",
"connectors.allCategories": "Featured",
"connectors.category.office": "Office",
"connectors.category.development": "Development",
"connectors.category.search": "Search",
"connectors.category.maps": "Maps",
"connectors.category.data": "Data",
"connectors.category.design": "Design",
"connectors.category.productivity": "Productivity",
"connectors.category.finance": "Finance",
"connectors.kind": "Connector",
"connectors.loadFailed": "Could not complete the action",
"connectors.emptyAdded": "Add your workplace services to manage accounts and connections here.",
"connectors.emptySearch": "Try another keyword or category.",
"connectors.resetFilters": "View all",
"connectors.configuration": "Connection settings",
"connectors.configurationHint": "Leave blank to keep saved settings. Enter new settings, then choose Save and verify to validate the connection.",
"connectors.fieldRequired": "This field is required",
"connectors.capabilities": "Capabilities",
"connectors.remoteExample": "Use {name} to find information I can access.",
"connectors.remoteRequirements": "A service account and the required access permissions may be needed. Available operations depend on the provider’s granted permissions.",
"connectors.phase.installing-plugin": "Installing companion skills",
"connectors.phase.checking": "Checking account connection",
"connectors.phase.enabling-plugin": "Enabling companion skills",
"connectors.phase.refreshing-sessions": "Refreshing task capabilities",
"connectors.phase.verifying-runtime": "Checking local runtime",
"connectors.phase.persistence": "Saving connection state",
"connectors.phase.cancelling": "Cancelling operation",
"connectors.feishu.requirements": "Requires a Feishu user account and calendar access. Available capabilities depend on permissions granted to the account and app.",
"connectors.dingtalk.requirements": "Requires DingTalk OAuth login and calendar permissions granted by an enterprise administrator. Login alone does not grant all service permissions.",
"connectors.wecom.requirements": "Requires a configured WeCom bot or user identity and calendar permissions. Available operations depend on permissions granted to that identity.",
"connectors.update": "Update",
"connectors.updateAvailable": "Update available",
"connectors.currentAccount": "Current service account",
"connectors.phase.downloading": "Downloading runtime",
"connectors.phase.extracting": "Extracting runtime",
"connectors.phase.verifying": "Verifying runtime",
"connectors.phase.awaiting-authorization": "Waiting for account authorization",
"connectors.phase.configuring-account": "Configuring account",
"connectors.phase.authorizing": "Authorizing",
"connectors.phase.failed": "Failed stage",
"connectors.phase.skillsInfo": "Includes skills for operating this service after installation.",
'connectors.status.configured': 'Configured locally',
'connectors.localVerification': 'Service permissions will be verified on the first task invocation.',
"sidebar.connectors": "Connectors",
"connectors.subtitle": "Connect workplace services to give skills access to real capabilities.",
"connectors.filter": "Filter connectors",
"connectors.all": "All",
"connectors.added": "Added",
"connectors.search": "Search connectors",
"connectors.clear": "Clear search",
"connectors.retry": "Retry",
"connectors.loading": "Loading…",
"connectors.empty": "No matching connectors",
"connectors.details": "Details",
"connectors.start": "Start using",
"connectors.account": "Account",
"connectors.noAccount": "Account not verified",
"connectors.platforms": "Platforms",
"connectors.source": "Source",
"connectors.version": "Version",
"connectors.skills": "Companion skill plugin",
"connectors.homepage": "Project homepage",
"connectors.example": "Example",
"connectors.waiting": "Working. Complete authorization and wait for the status to update.",
"connectors.authorize": "Open authorization page",
"connectors.confirm": "Confirm action",
"connectors.continue": "Continue",
"connectors.shared": "This connector shares the local CLI account credentials. Connecting or switching accounts affects other apps using the same CLI. Confirm before continuing.",
"connectors.isolated": "Credentials are isolated within this app.",
"connectors.removeInfo": "Remove the app-managed connector while preserving shared account credentials.",
"connectors.invalidUrl": "Invalid authorization URL",
"connectors.openFailed": "Could not open the link. Try again.",
"connectors.startFailed": "Could not create a task. Try again.",
"connectors.unsupported": "Platform unsupported",
"connectors.action.prepare": "Add",
"connectors.action.authenticate": "Connect account",
"connectors.action.check": "Check connection",
"connectors.action.deactivate": "Deactivate",
"connectors.action.remove": "Remove",
"connectors.status.not-installed": "Not added",
"connectors.status.preparing": "Preparing",
"connectors.status.needs-auth": "Needs account",
"connectors.status.authorizing": "Authorizing",
"connectors.status.ready": "Ready",
"connectors.status.disabled": "Disabled",
"connectors.status.error": "Needs attention",
"connectors.feishu.name": "Feishu",
"connectors.dingtalk.name": "DingTalk",
"connectors.wecom.name": "WeCom",
"connectors.feishu.description": "Manage Feishu documents, calendars and workplace collaboration.",
"connectors.dingtalk.description": "Connect DingTalk schedules and team services.",
"connectors.wecom.description": "Work with your team through WeCom.",
"connectors.feishu.example": "List my Feishu calendar events for today.",
"connectors.dingtalk.example": "List my DingTalk calendar events for today.",
"connectors.wecom.example": "Show the available features for my WeCom account.",
'settings.providers.compatibilityJsonHint': 'requestCompatibility is saved only for this provider, separately from global settings.',
'settings.providers.compatibilityBudget': 'Reply output budget',
'settings.providers.compatibilityBudgetHint': 'Maximum output tokens requested for ordinary replies. Leave blank for automatic selection; short background requests keep their smaller budgets.',
+351
View File
@@ -1,6 +1,357 @@
import type { TranslationKey } from './en'
export const jp: Record<TranslationKey, string> = {
"extensions.myPlugins": "マイプラグイン",
"extensions.mySkills": "マイスキル",
"extensions.browse": "一覧に戻る",
"extensions.managedSkillHint": "付属スキルはプラグインと一緒に管理します。ローカルとプロジェクトのスキルは元のディレクトリで管理してください。",
"extensions.searchPlugins": "プラグインを検索",
"extensions.searchSkills": "注目のスキルを検索",
"extensions.pluginsSubtitle": "よく使うサービスに接続するか、制作・開発ツールをアシスタントに追加します。",
"extensions.filter": "拡張機能を絞り込む",
"extensions.categories": "拡張機能のカテゴリ",
"extensions.empty": "一致する拡張機能がありません",
"chat.references": "参照",
"chat.referencePlugins": "プラグイン",
"chat.referenceSkills": "スキル",
"chat.referenceFiles": "ファイル",
"chat.referencesLoadFailed": "スキルとプラグインを読み込めませんでした",
"chat.referencesEmpty": "一致する参照がありません",
"chat.referenceDescription": "説明",
"chat.referenceSource": "提供元",
"chat.referenceDetails": "参照の詳細",
"sidebar.extensions": "スキル・コネクター",
"extensions.sections": "マーケットの分類",
"extensions.plugins": "プラグイン",
"extensions.skills": "スキル",
"extensions.featuredSkills": "注目のスキル",
"connectors.airtable.description": "テーブル、レコード、チーム資料を検索します。",
"connectors.airtable.example": "Airtable を使い、次の作業を手伝ってください:テーブル、レコード、チーム資料を検索します。",
"connectors.airtable.name": "Airtable",
"connectors.airtable.requirements": "対象ワークスペースやベースをブラウザーで認可します。企業管理者がクライアントの利用を制限する場合があります。",
"connectors.algorithmic-art.name": "アルゴリズムアート",
"connectors.algorithmic-art.description": "p5.js で調整可能な生成アートやインタラクティブな表現を作成します。",
"connectors.algorithmic-art.example": "色と密度を調整できる流れ場アートのページを作成して。",
"connectors.algorithmic-art.requirements": "テンプレートと参考コードを含みます。プレビューにはブラウザーと p5.js が必要です。インストール時にスクリプト実行や生成モデルの呼び出しは行いません。",
"connectors.atlassian.description": "Jira、Confluence、プロジェクト資料を検索します。",
"connectors.atlassian.example": "Atlassian を使い、次の作業を手伝ってください:Jira、Confluence、プロジェクト資料を検索します。",
"connectors.atlassian.name": "Atlassian",
"connectors.atlassian.requirements": "Atlassian Cloud アカウントと製品のアクセス権限をブラウザーで認可します。一部製品や操作は管理者の有効化が必要です。",
"connectors.bazhuayu.description": "収集テンプレート、クラウドタスク、収集済みデータを確認します。",
"connectors.bazhuayu.example": "Octoparse China を使い、次の作業を手伝ってください:収集テンプレート、クラウドタスク、収集済みデータを確認します。",
"connectors.bazhuayu.name": "Octoparse China",
"connectors.bazhuayu.requirements": "中国版アカウントの API Key が必要です。クラウド収集のみ対応し、実行時に残高を消費する場合があります。一部テンプレートは特定プラン限定です。",
"connectors.canvas-design.name": "キャンバスデザイン",
"connectors.canvas-design.description": "付属フォントでポスター、静止画作品、PDF を制作します。",
"connectors.canvas-design.example": "技術イベントのポスターをデザインして PDF に出力して。",
"connectors.canvas-design.requirements": "完全なスキル、フォント、ライセンスを含みます。画像や PDF の生成には Python と描画ライブラリが必要で、自動インストールは行いません。",
"connectors.clickup.description": "プロジェクト、タスク、チーム資料を検索します。",
"connectors.clickup.example": "ClickUp を使い、次の作業を手伝ってください:プロジェクト、タスク、チーム資料を検索します。",
"connectors.clickup.name": "ClickUp",
"connectors.clickup.requirements": "ClickUp アカウントとワークスペース権限をブラウザーで認可します。サービスは公開ベータ版です。",
"connectors.cloudflare.description": "Web サイト、ネットワーク、クラウドリソースを確認・管理します。",
"connectors.cloudflare.example": "Cloudflare を使い、次の作業を手伝ってください:Web サイト、ネットワーク、クラウドリソースを確認・管理します。",
"connectors.cloudflare.name": "Cloudflare",
"connectors.cloudflare.requirements": "必要なリソース権限を持つユーザーまたはアカウント API Token が必要です。デプロイや設定変更には明示的なタスク指示が必要です。",
"connectors.drawio.name": "draw.io 図表",
"connectors.drawio.description": "編集可能な構成図、フローチャート、関係図を作成します。",
"connectors.drawio.example": "このシステム説明を編集可能な draw.io 構成図にして。",
"connectors.drawio.requirements": ".drawio XML は直接作成できます。Mermaid 変換、自動配置、画像出力には draw.io デスクトップ CLI が必要です。スキルのインストールでアプリは追加されません。",
"connectors.firecrawl.description": "Web 検索とページ抽出で調査資料を整理します。",
"connectors.firecrawl.example": "Firecrawl を使い、次の作業を手伝ってください:Web 検索とページ抽出で調査資料を整理します。",
"connectors.firecrawl.name": "Firecrawl",
"connectors.firecrawl.requirements": "Firecrawl API Key が必要です。利用量はアカウント枠に計上され、キーは安全なリクエストヘッダーで送信されます。",
"connectors.frontend-design.name": "フロントエンドデザイン",
"connectors.frontend-design.description": "視覚的な階層が明確な Web・アプリの画面を構築します。",
"connectors.frontend-design.example": "知識管理アプリのホーム画面を設計して実装して。",
"connectors.frontend-design.requirements": "Anthropic のフロントエンドデザインスキルを追加します。プレビューとビルドには対象プロジェクトの依存関係と実行環境が必要です。ホスティングや画像生成サービスは含まれません。",
"connectors.hyperframes.name": "HyperFrames",
"connectors.hyperframes.description": "HTML とアニメーションコードで動画、字幕、製品デモを制作します。",
"connectors.hyperframes.example": "HyperFrames で製品を紹介する 20 秒の動画を作成して。",
"connectors.hyperframes.requirements": "固定バージョンの 5 種類のスキルと資料のみをインストールします。描画には Node.js 22+、HyperFrames CLI、FFmpeg が必要です。ローカル音声には Python 3.10+ が必要な場合があり、オンライン素材サービスには別途設定や料金が必要です。",
"connectors.intercom.description": "顧客との会話、連絡先、ヘルプ記事を検索します。",
"connectors.intercom.example": "Intercom を使い、次の作業を手伝ってください:顧客との会話、連絡先、ヘルプ記事を検索します。",
"connectors.intercom.name": "Intercom",
"connectors.intercom.requirements": "米国ホストのワークスペースのみ対応。連絡先・会話・記事の権限を持つアクセストークンが必要です。",
"connectors.jufa.description": "裁判例を検索し、裁判所の判断を整理します。",
"connectors.jufa.example": "Jufa Cases を使い、次の作業を手伝ってください:裁判例を検索し、裁判所の判断を整理します。",
"connectors.jufa.name": "Jufa Cases",
"connectors.jufa.requirements": "Jufa Agent API Key が必要です。判例調査のみ対象で、他の分野は別途接続が必要です。プランと呼び出し枠が適用されます。",
"connectors.kuaicha.description": "企業の登記、経営状況、リスク情報を検索します。",
"connectors.kuaicha.example": "THS Kuaicha を使い、次の作業を手伝ってください:企業の登記、経営状況、リスク情報を検索します。",
"connectors.kuaicha.name": "THS Kuaicha",
"connectors.kuaicha.requirements": "Kuaicha API Key が必要です。中国本土の IP アドレスのみ対応し、権限と利用枠が適用されます。",
"connectors.listinggood.description": "Amazon 商品説明の適合性と読みやすさを確認します。",
"connectors.listinggood.example": "ListingGood を使い、次の作業を手伝ってください:Amazon 商品説明の適合性と読みやすさを確認します。",
"connectors.listinggood.name": "ListingGood",
"connectors.listinggood.requirements": "ListingGood 中国版 API Key が必要です。海外版のキーとは互換性がなく、有料ツールは利用枠を消費します。",
"connectors.mcp-builder.name": "MCP 開発ツールキット",
"connectors.mcp-builder.description": "TypeScript・Python の資料で MCP サーバーを構築・評価します。",
"connectors.mcp-builder.example": "社内ナレッジベース向けの MCP サーバーを設計して。",
"connectors.mcp-builder.requirements": "設計資料と評価スクリプトを含みます。開発には Node.js または Python と MCP SDK が必要です。モデル評価や外部 API は利用者の認証情報が必要で、料金が発生する場合があります。",
"connectors.miro.description": "共同編集ボードを読み、デザインの議論を整理します。",
"connectors.miro.example": "Miro を使い、次の作業を手伝ってください:共同編集ボードを読み、デザインの議論を整理します。",
"connectors.miro.name": "Miro",
"connectors.miro.requirements": "Miro アカウントとボード権限が必要です。組織管理者による MCP の有効化が必要な場合があります。",
"connectors.neon.description": "データベースのプロジェクト、ブランチ、スキーマを確認します。",
"connectors.neon.example": "Neon を使い、次の作業を手伝ってください:データベースのプロジェクト、ブランチ、スキーマを確認します。",
"connectors.neon.name": "Neon",
"connectors.neon.requirements": "Neon API Key とプロジェクト権限が必要です。提供元は開発・テスト用データベースでの利用を推奨しています。",
"connectors.obsidian.name": "Obsidian ツールキット",
"connectors.obsidian.description": "Markdown ノート、Canvas、Bases ビューを編集します。",
"connectors.obsidian.example": "このプロジェクトノートを Obsidian Canvas の関係図に整理して。",
"connectors.obsidian.requirements": "ローカルファイル編集にクラウドアカウントは不要です。CLI 操作には起動中の Obsidian が必要です。抽出などの追加機能には対応する CLI が必要です。インストールするのはスキルファイルのみです。",
"connectors.postman.description": "API ワークスペース、コレクション、開発資料を検索します。",
"connectors.postman.example": "Postman を使い、次の作業を手伝ってください:API ワークスペース、コレクション、開発資料を検索します。",
"connectors.postman.name": "Postman",
"connectors.postman.requirements": "米国リージョンの Postman アカウントをブラウザーで認可します。最小ツールセットを使用します。EU アカウントは別途 API Key 設定が必要です。",
"connectors.qcc.description": "企業の登記情報と関連資料を照合します。",
"connectors.qcc.example": "Qichacha を使い、次の作業を手伝ってください:企業の登記情報と関連資料を照合します。",
"connectors.qcc.name": "Qichacha",
"connectors.qcc.requirements": "Qichacha Agent API Key が必要です。企業基本情報のみ対象で、他の分野は別途接続が必要です。権限とポイント制限が適用されます。",
"connectors.qingflow.description": "ワークスペースのアプリと業務データを検索します。",
"connectors.qingflow.example": "Qingflow を使い、次の作業を手伝ってください:ワークスペースのアプリと業務データを検索します。",
"connectors.qingflow.name": "Qingflow",
"connectors.qingflow.requirements": "Qingflow アカウントをブラウザーで認可します。ワークスペースとアプリの権限が適用され、機能はプランによって異なります。",
"connectors.qveris.description": "情報調査に使えるデータツールを検索します。",
"connectors.qveris.example": "QVeris を使い、次の作業を手伝ってください:情報調査に使えるデータツールを検索します。",
"connectors.qveris.name": "QVeris",
"connectors.qveris.requirements": "QVeris API Key が必要です。ツール範囲と料金は提供元が決定します。キー変更後は再接続してください。",
"connectors.render.description": "デプロイ済みサービス、データベース、ログ、指標を確認します。",
"connectors.render.example": "Render を使い、次の作業を手伝ってください:デプロイ済みサービス、データベース、ログ、指標を確認します。",
"connectors.render.name": "Render",
"connectors.render.requirements": "Render API Key が必要です。最初にワークスペースを指定します。所属先にアクセス可能なキーのため、デプロイは明示的なタスク指示に従います。",
"connectors.sorftime.description": "越境 EC 市場、商品、キーワードを調査します。",
"connectors.sorftime.example": "Sorftime を使い、次の作業を手伝ってください:越境 EC 市場、商品、キーワードを調査します。",
"connectors.sorftime.name": "Sorftime",
"connectors.sorftime.requirements": "Sorftime MCP の有効化と Account-SK が必要です。購入サービスによって対象プラットフォーム、地域、照会回数が決まります。",
"connectors.todoist.description": "タスク、プロジェクト、個人の予定を確認します。",
"connectors.todoist.example": "Todoist を使い、次の作業を手伝ってください:タスク、プロジェクト、個人の予定を確認します。",
"connectors.todoist.name": "Todoist",
"connectors.todoist.requirements": "Todoist アカウントを使用し、タスクとプロジェクトへのアクセスをブラウザーで認可します。",
"connectors.variflight.description": "フライト状況、空港、旅程情報を検索します。",
"connectors.variflight.example": "VariFlight を使い、次の作業を手伝ってください:フライト状況、空港、旅程情報を検索します。",
"connectors.variflight.name": "VariFlight",
"connectors.variflight.requirements": "VariFlight MCP にログインして MCP Key を発行してください。個人旅程には権限が必要で、データと利用枠は提供元の条件に従います。",
"connectors.webapp-testing.name": "Web アプリテスト",
"connectors.webapp-testing.description": "Playwright でローカル画面、操作、スクリーンショットを検証します。",
"connectors.webapp-testing.example": "ローカルアプリのフォーム検証とレイアウトを確認して。",
"connectors.webapp-testing.requirements": "テストスキル、Python 補助スクリプト、例を含みます。実行には Python、Playwright、ブラウザー、対象アプリが必要です。ブラウザーの自動ダウンロードは行いません。",
"connectors.webflow.description": "サイト構造、CMS コンテンツ、デザイン情報を読み取ります。",
"connectors.webflow.example": "Webflow を使い、次の作業を手伝ってください:サイト構造、CMS コンテンツ、デザイン情報を読み取ります。",
"connectors.webflow.name": "Webflow",
"connectors.webflow.requirements": "サイト権限のある Webflow アカウントが必要です。OAuth で MCP Bridge App が追加され、Designer 操作にはブリッジ接続が必要です。",
"connectors.xmind.description": "オンラインのマインドマップを読み、計画を整理します。",
"connectors.xmind.example": "Xmind を使い、次の作業を手伝ってください:オンラインのマインドマップを読み、計画を整理します。",
"connectors.xmind.name": "Xmind",
"connectors.xmind.requirements": "中国本土版 Xmind アカウントをブラウザーで認可します。認可済みチームのマップのみ対象で、編集機能はプランに依存します。",
"connectors.toolsRemoveInfo": "このツールプラグインとスキルファイルを削除します。既存のタスクと出力ファイルは保持されます。",
"connectors.collection": "カタログの種類",
"connectors.services": "サービス接続",
"connectors.tools": "ツールプラグイン",
"connectors.region": "サービス地域",
"connectors.region.all": "すべての地域",
"connectors.region.china": "中国のサービス",
"connectors.region.global": "グローバルサービス",
"connectors.toolKind": "ツールプラグイン",
"connectors.toolsSubtitle": "タスクに専門スキルと作業手順を追加します。",
"connectors.skillsLoaded": "スキル読込済み",
"connectors.skillsPending": "スキル未読込",
"connectors.verifySkills": "スキルを確認",
"connectors.skillsRuntimeInfo": "追加されるのはスキルパッケージです。タスクに必要な Node.js、コマンドラインツールなどの実行環境は別途用意する必要があります。スキルの読み込みは依存環境のインストールを意味しません。",
"connectors.saveAndVerify": "保存して確認",
"connectors.needsService": "サービス接続が必要",
"connectors.jinshuju.name": "金数据",
"connectors.jinshuju.description": "フォームと収集した回答を確認し、集計と整理を支援。",
"connectors.jinshuju.example": "金数据で最近収集したアンケートの回答を要約してください。",
"connectors.jinshuju.requirements": "金数据のアカウントが必要です。ブラウザーでフォームとデータへのアクセスを認証します。",
"connectors.tencent-docs.name": "騰訊文書",
"connectors.tencent-docs.description": "オンライン文書や表計算、共同作業の資料を読み取り、整理。",
"connectors.tencent-docs.example": "騰訊文書でプロジェクト計画に関する資料を探してください。",
"connectors.tencent-docs.requirements": "Tencent Docs MCP アクセストークンが必要です。アカウントが既にアクセスできる文書のみ利用できます。",
"connectors.pkulaw.name": "北大法宝",
"connectors.pkulaw.description": "法令を検索し、条文と原典を参照。",
"connectors.pkulaw.example": "雇用契約の試用期間に関する条文を検索し、出典を示してください。",
"connectors.pkulaw.requirements": "PKULaw MCP アクセストークンと対象資料の権限が必要です。検索した条文が現在有効か確認してください。",
"connectors.datayes.name": "通聯データ",
"connectors.datayes.description": "株価と金融市場のデータを照会して調査を支援。",
"connectors.datayes.example": "指定した銘柄の過去の株価を調べ、データの出典を整理してください。",
"connectors.datayes.requirements": "Datayes MCP Token が必要です。アクセス範囲は権限と利用枠によります。企業アカウントは提供元の確認要件を満たす必要があります。",
"connectors.tencent-search.name": "Tencent Cloud 検索",
"connectors.tencent-search.description": "出典を追跡できるウェブ資料を検索。",
"connectors.tencent-search.example": "中国の低空経済産業の発展に関する資料を検索し、公式の出典を整理してください。",
"connectors.tencent-search.requirements": "Tencent Cloud WSA の有効化と API Key が必要です。アカウントの利用枠と料金規則が適用されます。",
"connectors.connectService": "サービスを接続",
"connectors.officialMcp": "公式 MCP サービス",
"connectors.amap.name": "高徳地図",
"connectors.amap.description": "場所の検索、経路の計画、天気や交通情報の確認。",
"connectors.amap.example": "杭州東駅から西湖までの公共交通ルートを計画してください。",
"connectors.amap.requirements": "Amap の Web サービス API Key が必要です。利用範囲はアカウントの権限と割り当てによります。",
"connectors.baidu-maps.name": "百度地図",
"connectors.baidu-maps.description": "住所の解析、周辺施設の検索、経路の計画。",
"connectors.baidu-maps.example": "上海虹橋駅の近くのホテルと交通手段を調べてください。",
"connectors.baidu-maps.requirements": "Baidu Maps のサーバー用 AK と対象サービスの権限が必要です。",
"connectors.tencent-maps.name": "騰訊地図",
"connectors.tencent-maps.description": "位置、距離、経路を調べて移動を計画。",
"connectors.tencent-maps.example": "広州南駅から広州塔までの公共交通と車の経路を比較してください。",
"connectors.tencent-maps.requirements": "Tencent Location Service Key と WebServiceAPI の権限および割り当てが必要です。",
"connectors.zhipu-search.name": "智譜ウェブ検索",
"connectors.zhipu-search.description": "中国語のウェブページ、ニュース、最新情報を出典付きで検索。",
"connectors.zhipu-search.example": "中国の最近の新エネルギー車政策を検索し、原典を示してください。",
"connectors.zhipu-search.requirements": "GLM Coding Plan の API Key が必要です。チームプランのキーと汎用プラットフォームのキーは互換ではありません。",
"connectors.notion.name": "Notion",
"connectors.notion.description": "ワークスペース内の資料を検索し、ページやプロジェクトの知識を整理。",
"connectors.notion.example": "Notion の最近のプロジェクト週報を探し、進捗を要約してください。",
"connectors.notion.requirements": "Notion アカウントが必要です。ブラウザー認証では、そのアカウントがアクセス可能な内容のみ利用できます。",
"connectors.linear.name": "Linear",
"connectors.linear.description": "課題、プロジェクト、サイクルを調べ、チームの作業を追跡。",
"connectors.linear.example": "Linear で自分に割り当てられた未完了の課題を一覧表示してください。",
"connectors.linear.requirements": "Linear のワークスペースアカウントと対象プロジェクトの権限が必要です。ブラウザーで認証します。",
"connectors.github.name": "GitHub",
"connectors.github.description": "リポジトリのコード、Issue、Pull Request を検索。",
"connectors.github.example": "自分の GitHub リポジトリでレビュー待ちの Pull Request を確認してください。",
"connectors.github.requirements": "必要なリポジトリと操作の権限を持つ GitHub Personal Access Token が必要です。組織のポリシーも適用されます。",
"connectors.context7.name": "Context7",
"connectors.context7.description": "ライブラリの最新ドキュメントと使用例を参照。",
"connectors.context7.example": "React useEffect の公式ドキュメントとクリーンアップ関数の例を探してください。",
"connectors.context7.requirements": "公式の匿名利用枠を使用します。無制限ではなく、枠を超えるとサービスが制限を返します。",
"connectors.sentry.name": "Sentry",
"connectors.sentry.description": "例外、課題、アプリケーションの実行情報を確認。",
"connectors.sentry.example": "Sentry で過去一日に初めて発生したエラーを要約してください。",
"connectors.sentry.requirements": "組織とプロジェクトの権限を持つ Sentry アカウントが必要です。ブラウザーで認証します。",
"connectors.tavily.name": "Tavily",
"connectors.tavily.description": "ウェブ検索とコンテンツ抽出で調査を支援。",
"connectors.tavily.example": "オープンソースのベクトルデータベースを比較し、出典を示してください。",
"connectors.tavily.requirements": "Tavily API Key が必要です。呼び出しにはアカウントの契約と利用枠が適用されます。",
"connectors.exa.name": "Exa",
"connectors.exa.description": "関連するウェブページ、コード、技術情報を検索。",
"connectors.exa.example": "分散システムの可観測性に関する技術記事を探してください。",
"connectors.exa.requirements": "公式の基本匿名利用枠を使用します。利用可能なツールと回数は提供元が定めます。",
"connectors.supabase.name": "Supabase",
"connectors.supabase.description": "プロジェクト、データベース構造、開発資料を確認。",
"connectors.supabase.example": "データを変更せずに Supabase プロジェクトのテーブル構造を確認してください。",
"connectors.supabase.requirements": "対象組織とプロジェクトの権限を持つ Supabase アカウントが必要です。データベースへの書き込みには明示的なタスク指示が必要です。",
"connectors.huggingface.name": "Hugging Face",
"connectors.huggingface.description": "モデル、データセット、機械学習の研究資料を発見。",
"connectors.huggingface.example": "中国語テキスト分類に適した公開モデルとデータセットを探してください。",
"connectors.huggingface.requirements": "Hugging Face のアクセストークンが必要です。検索には read 権限を推奨します。計算タスクは別途課金される場合があります。",
"connectors.canva.name": "Canva",
"connectors.canva.description": "デザイン素材を検索、整理し、デザイン作業で協業。",
"connectors.canva.example": "Canva の最近のブランドデザイン素材を探してください。",
"connectors.canva.requirements": "各ユーザーが自身の Canva アカウントで認証します。一部の機能は契約とチーム権限に依存します。",
"connectors.figma.name": "Figma",
"connectors.figma.description": "デザインの背景、コンポーネント、画面構造を読み取り。",
"connectors.figma.example": "提供した Figma デザインリンクのコンポーネント構造を分析してください。",
"connectors.figma.requirements": "Figma アカウントでの認証が必要です。アクセス権限、シート、プランにより利用可能なツールと回数が決まります。",
"connectors.stripe.name": "Stripe",
"connectors.stripe.description": "顧客、請求書、決済業務の情報を照会。",
"connectors.stripe.example": "支払いや返金を作成せずに Stripe の最近の請求書の概要を確認してください。",
"connectors.stripe.requirements": "Stripe アカウントでの認証が必要です。公式サービスはプレビュー版です。支払いや返金などの書き込みには明示的なタスク指示が必要です。",
"connectors.category.legal": "法律",
"connectors.categories": "サービスのカテゴリ",
"connectors.allCategories": "おすすめ",
"connectors.category.office": "業務連携",
"connectors.category.development": "開発",
"connectors.category.search": "検索",
"connectors.category.maps": "地図",
"connectors.category.data": "データ",
"connectors.category.design": "デザイン",
"connectors.category.productivity": "効率化",
"connectors.category.finance": "金融",
"connectors.kind": "コネクター",
"connectors.loadFailed": "操作を完了できませんでした",
"connectors.emptyAdded": "サービスを追加すると、ここでアカウントと接続状態を管理できます。",
"connectors.emptySearch": "別のキーワードやカテゴリをお試しください。",
"connectors.resetFilters": "すべて表示",
"connectors.configuration": "接続設定",
"connectors.configurationHint": "空欄の場合は保存済みの設定を維持します。新しい設定を入力し、「保存して確認」で接続を確認してください。",
"connectors.fieldRequired": "この項目を入力してください",
"connectors.capabilities": "対応する操作",
"connectors.remoteExample": "{name} を使ってアクセス可能な情報を検索してください。",
"connectors.remoteRequirements": "サービスのアカウントと必要なアクセス権限を用意してください。利用可能な操作はサービス提供元が付与した権限によって異なります。",
"connectors.phase.installing-plugin": "関連スキルをインストール中",
"connectors.phase.checking": "アカウント接続を確認中",
"connectors.phase.enabling-plugin": "関連スキルを有効化中",
"connectors.phase.refreshing-sessions": "タスクの機能を更新中",
"connectors.phase.verifying-runtime": "ローカル実行環境を確認中",
"connectors.phase.persistence": "接続状態を保存中",
"connectors.phase.cancelling": "操作をキャンセル中",
"connectors.feishu.requirements": "Feishu のユーザーアカウントと予定表へのアクセス権限が必要です。利用できる機能はアカウントとアプリに付与された権限によって異なります。",
"connectors.dingtalk.requirements": "DingTalk の OAuth ログインと企業管理者による予定表権限の付与が必要です。ログインだけですべてのサービス権限が付与されるわけではありません。",
"connectors.wecom.requirements": "WeCom のボットまたはユーザー ID の設定と予定表権限が必要です。利用できる操作は選択した ID に付与された権限によって異なります。",
"connectors.update": "更新",
"connectors.updateAvailable": "更新先のバージョン",
"connectors.currentAccount": "現在のサービスアカウント",
"connectors.phase.downloading": "実行ファイルをダウンロード中",
"connectors.phase.extracting": "実行ファイルを展開中",
"connectors.phase.verifying": "実行ファイルを検証中",
"connectors.phase.awaiting-authorization": "アカウント認証待ち",
"connectors.phase.configuring-account": "アカウントを設定中",
"connectors.phase.authorizing": "認証中",
"connectors.phase.failed": "失敗した段階",
"connectors.phase.skillsInfo": "インストール後、このサービスを操作するスキルを利用できます。",
'connectors.status.configured': 'ローカル設定済み',
'connectors.localVerification': 'サービスの権限は最初のタスク実行時に確認されます。',
"sidebar.connectors": "コネクター",
"connectors.subtitle": "業務サービスを接続してスキルの機能を広げます。",
"connectors.filter": "コネクターを絞り込む",
"connectors.all": "すべて",
"connectors.added": "追加済み",
"connectors.search": "コネクターを検索",
"connectors.clear": "検索をクリア",
"connectors.retry": "再試行",
"connectors.loading": "読み込み中…",
"connectors.empty": "該当するコネクターはありません",
"connectors.details": "詳細",
"connectors.start": "使い始める",
"connectors.account": "アカウント",
"connectors.noAccount": "アカウント未確認",
"connectors.platforms": "対応環境",
"connectors.source": "配布元",
"connectors.version": "バージョン",
"connectors.skills": "関連スキルプラグイン",
"connectors.homepage": "プロジェクトのページ",
"connectors.example": "使用例",
"connectors.waiting": "処理中です。認証を完了し、状態の更新をお待ちください。",
"connectors.authorize": "認証ページを開く",
"connectors.confirm": "操作の確認",
"connectors.continue": "続行",
"connectors.shared": "このコネクターはローカル CLI の認証情報を共有します。接続やアカウント変更は同じ CLI を使用する他のアプリにも影響します。確認して続行してください。",
"connectors.isolated": "認証情報はこのアプリ内に独立して保存されます。",
"connectors.removeInfo": "アプリが管理するコネクターを削除し、共有認証情報は保持します。",
"connectors.invalidUrl": "認証 URL が無効です",
"connectors.openFailed": "リンクを開けませんでした。再試行してください。",
"connectors.startFailed": "タスクを作成できませんでした。再試行してください。",
"connectors.unsupported": "この環境は未対応",
"connectors.action.prepare": "追加",
"connectors.action.authenticate": "アカウントを接続",
"connectors.action.check": "接続を確認",
"connectors.action.deactivate": "無効にする",
"connectors.action.remove": "削除",
"connectors.status.not-installed": "未追加",
"connectors.status.preparing": "準備中",
"connectors.status.needs-auth": "接続が必要",
"connectors.status.authorizing": "認証中",
"connectors.status.ready": "利用可能",
"connectors.status.disabled": "無効",
"connectors.status.error": "対応が必要",
"connectors.feishu.name": "Feishu",
"connectors.dingtalk.name": "DingTalk",
"connectors.wecom.name": "WeCom",
"connectors.feishu.description": "Feishu の文書、予定表、業務連携を管理します。",
"connectors.dingtalk.description": "DingTalk の予定とチーム業務サービスを接続します。",
"connectors.wecom.description": "WeCom でチームと連携します。",
"connectors.feishu.example": "Feishu の今日の予定を一覧表示してください。",
"connectors.dingtalk.example": "DingTalk の今日の予定を一覧表示してください。",
"connectors.wecom.example": "WeCom アカウントで利用できる機能を確認してください。",
'settings.providers.compatibilityJsonHint': 'requestCompatibility はこのプロバイダーにのみ保存され、グローバル設定には書き込まれません。',
'settings.providers.compatibilityBudget': '応答の出力予算',
'settings.providers.compatibilityBudgetHint': '通常の応答で要求する最大出力トークン数。空欄は自動選択です。短いバックグラウンド要求には小さい予算が維持されます。',
+351
View File
@@ -1,6 +1,357 @@
import type { TranslationKey } from './en'
export const kr: Record<TranslationKey, string> = {
"extensions.myPlugins": "내 플러그인",
"extensions.mySkills": "내 스킬",
"extensions.browse": "둘러보기로 돌아가기",
"extensions.managedSkillHint": "연결된 스킬은 해당 플러그인과 함께 관리합니다. 로컬 및 프로젝트 스킬은 원본 디렉터리에서 관리하세요.",
"extensions.searchPlugins": "플러그인 검색",
"extensions.searchSkills": "추천 스킬 검색",
"extensions.pluginsSubtitle": "자주 사용하는 서비스를 연결하거나 어시스턴트에 창작 및 개발 도구를 추가하세요.",
"extensions.filter": "확장 기능 필터",
"extensions.categories": "확장 기능 분류",
"extensions.empty": "일치하는 확장 기능이 없습니다",
"chat.references": "참조",
"chat.referencePlugins": "플러그인",
"chat.referenceSkills": "스킬",
"chat.referenceFiles": "파일",
"chat.referencesLoadFailed": "스킬과 플러그인을 불러올 수 없습니다",
"chat.referencesEmpty": "일치하는 참조가 없습니다",
"chat.referenceDescription": "설명",
"chat.referenceSource": "출처",
"chat.referenceDetails": "참조 세부 정보",
"sidebar.extensions": "스킬 · 커넥터",
"extensions.sections": "마켓 분류",
"extensions.plugins": "플러그인",
"extensions.skills": "스킬",
"extensions.featuredSkills": "추천 스킬",
"connectors.airtable.description": "테이블, 레코드와 팀 자료를 조회합니다.",
"connectors.airtable.example": "Airtable에서 다음 작업을 도와주세요: 테이블, 레코드와 팀 자료를 조회합니다.",
"connectors.airtable.name": "Airtable",
"connectors.airtable.requirements": "브라우저에서 워크스페이스 또는 베이스 접근을 승인합니다. 기업 관리자가 클라이언트 사용을 제한할 수 있습니다.",
"connectors.algorithmic-art.name": "알고리즘 아트",
"connectors.algorithmic-art.description": "p5.js로 매개변수를 조절하는 알고리즘 아트와 인터랙티브 화면을 만듭니다.",
"connectors.algorithmic-art.example": "색상과 밀도를 조절할 수 있는 흐름장 아트 페이지를 만들어 주세요.",
"connectors.algorithmic-art.requirements": "템플릿과 참고 코드를 포함합니다. 미리 보기에는 브라우저와 p5.js가 필요합니다. 설치 중 스크립트 실행이나 생성 모델 호출은 하지 않습니다.",
"connectors.atlassian.description": "Jira, Confluence와 프로젝트 협업 자료를 검색합니다.",
"connectors.atlassian.example": "Atlassian에서 다음 작업을 도와주세요: Jira, Confluence와 프로젝트 협업 자료를 검색합니다.",
"connectors.atlassian.name": "Atlassian",
"connectors.atlassian.requirements": "Atlassian Cloud 계정과 제품 권한을 브라우저에서 승인해야 합니다. 일부 제품과 작업은 관리자 활성화가 필요합니다.",
"connectors.bazhuayu.description": "수집 템플릿, 클라우드 작업과 수집 데이터를 확인합니다.",
"connectors.bazhuayu.example": "Octoparse China에서 다음 작업을 도와주세요: 수집 템플릿, 클라우드 작업과 수집 데이터를 확인합니다.",
"connectors.bazhuayu.name": "Octoparse China",
"connectors.bazhuayu.requirements": "중국 서비스 계정의 API Key가 필요합니다. 클라우드 수집만 지원하며 실행 시 잔액이 차감될 수 있습니다. 일부 템플릿에는 특정 요금제가 필요합니다.",
"connectors.canvas-design.name": "캔버스 디자인",
"connectors.canvas-design.description": "포함된 글꼴로 포스터, 정적 시각 작품, PDF를 제작합니다.",
"connectors.canvas-design.example": "기술 행사 포스터를 디자인하고 PDF로 내보내 주세요.",
"connectors.canvas-design.requirements": "전체 디자인 스킬, 글꼴, 라이선스를 포함합니다. 이미지나 PDF 생성에는 Python과 그림 라이브러리가 필요하며 자동 설치하지 않습니다.",
"connectors.clickup.description": "프로젝트, 작업과 팀 자료를 검색합니다.",
"connectors.clickup.example": "ClickUp에서 다음 작업을 도와주세요: 프로젝트, 작업과 팀 자료를 검색합니다.",
"connectors.clickup.name": "ClickUp",
"connectors.clickup.requirements": "ClickUp 계정과 워크스페이스 권한을 브라우저에서 승인해야 합니다. 공개 베타 서비스입니다.",
"connectors.cloudflare.description": "웹사이트, 네트워크와 클라우드 리소스를 살펴보고 관리합니다.",
"connectors.cloudflare.example": "Cloudflare에서 다음 작업을 도와주세요: 웹사이트, 네트워크와 클라우드 리소스를 살펴보고 관리합니다.",
"connectors.cloudflare.name": "Cloudflare",
"connectors.cloudflare.requirements": "필요한 리소스 권한을 가진 사용자 또는 계정 API Token이 필요합니다. 배포와 설정 변경에는 명시적인 작업 요청이 필요합니다.",
"connectors.drawio.name": "draw.io 다이어그램",
"connectors.drawio.description": "편집 가능한 구성도, 흐름도, 관계도를 만듭니다.",
"connectors.drawio.example": "이 시스템 설명을 편집 가능한 draw.io 구성도로 만들어 주세요.",
"connectors.drawio.requirements": ".drawio XML은 직접 작성할 수 있습니다. Mermaid 변환, 자동 배치, 이미지 내보내기는 draw.io 데스크톱 CLI가 필요합니다. 스킬 설치로 앱이 설치되지는 않습니다.",
"connectors.firecrawl.description": "웹을 검색하고 페이지를 추출해 조사 자료를 정리합니다.",
"connectors.firecrawl.example": "Firecrawl에서 다음 작업을 도와주세요: 웹을 검색하고 페이지를 추출해 조사 자료를 정리합니다.",
"connectors.firecrawl.name": "Firecrawl",
"connectors.firecrawl.requirements": "Firecrawl API Key가 필요합니다. 사용량은 계정 한도에 반영되며 키는 안전한 요청 헤더로 전송됩니다.",
"connectors.frontend-design.name": "프런트엔드 디자인",
"connectors.frontend-design.description": "시각적 위계가 명확한 웹과 앱 화면을 구현합니다.",
"connectors.frontend-design.example": "지식 관리 앱의 홈 화면을 설계하고 구현해 주세요.",
"connectors.frontend-design.requirements": "Anthropic 프런트엔드 디자인 스킬을 설치합니다. 미리 보기와 빌드에는 대상 프로젝트의 의존성과 실행 환경이 필요합니다. 호스팅이나 이미지 생성 서비스는 포함되지 않습니다.",
"connectors.hyperframes.name": "HyperFrames",
"connectors.hyperframes.description": "HTML과 애니메이션 코드로 영상, 자막, 제품 데모를 제작합니다.",
"connectors.hyperframes.example": "HyperFrames로 20초 제품 소개 영상을 만들어 주세요.",
"connectors.hyperframes.requirements": "고정 버전의 스킬 5종과 참고 자료만 설치합니다. 렌더링에는 Node.js 22+, HyperFrames CLI, FFmpeg가 필요합니다. 로컬 음성에는 Python 3.10+가 필요할 수 있으며 온라인 미디어 서비스는 별도 설정과 비용이 필요할 수 있습니다.",
"connectors.intercom.description": "고객 대화, 연락처와 도움말 자료를 검색합니다.",
"connectors.intercom.example": "Intercom에서 다음 작업을 도와주세요: 고객 대화, 연락처와 도움말 자료를 검색합니다.",
"connectors.intercom.name": "Intercom",
"connectors.intercom.requirements": "미국 호스팅 워크스페이스만 지원합니다. 연락처, 대화, 문서 권한이 있는 액세스 토큰이 필요합니다.",
"connectors.jufa.description": "판례를 검색하고 법원의 판단을 정리합니다.",
"connectors.jufa.example": "Jufa Cases에서 다음 작업을 도와주세요: 판례를 검색하고 법원의 판단을 정리합니다.",
"connectors.jufa.name": "Jufa Cases",
"connectors.jufa.requirements": "Jufa Agent API Key가 필요합니다. 판례 조사만 제공하며 다른 주제는 별도 연동이 필요합니다. 요금제와 호출 한도가 적용됩니다.",
"connectors.kuaicha.description": "기업 등록, 운영과 위험 정보를 검색합니다.",
"connectors.kuaicha.example": "THS Kuaicha에서 다음 작업을 도와주세요: 기업 등록, 운영과 위험 정보를 검색합니다.",
"connectors.kuaicha.name": "THS Kuaicha",
"connectors.kuaicha.requirements": "Kuaicha API Key가 필요합니다. 중국 본토 IP만 지원하며 계정 권한과 한도가 적용됩니다.",
"connectors.listinggood.description": "아마존 상품 설명의 규정 준수와 가독성을 검토합니다.",
"connectors.listinggood.example": "ListingGood에서 다음 작업을 도와주세요: 아마존 상품 설명의 규정 준수와 가독성을 검토합니다.",
"connectors.listinggood.name": "ListingGood",
"connectors.listinggood.requirements": "ListingGood 중국 서비스 API Key가 필요합니다. 글로벌 서비스 키와 호환되지 않으며 유료 도구는 계정 한도를 사용합니다.",
"connectors.mcp-builder.name": "MCP 개발 도구 모음",
"connectors.mcp-builder.description": "TypeScript와 Python 참고 자료로 MCP 서버를 개발하고 평가합니다.",
"connectors.mcp-builder.example": "내부 지식 베이스용 MCP 서버를 설계해 주세요.",
"connectors.mcp-builder.requirements": "설계 참고 자료와 평가 스크립트를 포함합니다. 개발에는 Node.js 또는 Python과 MCP SDK가 필요합니다. 모델 평가와 외부 API는 사용자 인증 정보가 필요하며 비용이 발생할 수 있습니다.",
"connectors.miro.description": "공동 작업 보드를 읽고 디자인 논의를 정리합니다.",
"connectors.miro.example": "Miro에서 다음 작업을 도와주세요: 공동 작업 보드를 읽고 디자인 논의를 정리합니다.",
"connectors.miro.name": "Miro",
"connectors.miro.requirements": "Miro 계정과 보드 권한이 필요합니다. 조직 관리자가 MCP를 활성화해야 할 수 있습니다.",
"connectors.neon.description": "데이터베이스 프로젝트, 브랜치와 개발 스키마를 살펴봅니다.",
"connectors.neon.example": "Neon에서 다음 작업을 도와주세요: 데이터베이스 프로젝트, 브랜치와 개발 스키마를 살펴봅니다.",
"connectors.neon.name": "Neon",
"connectors.neon.requirements": "Neon API Key와 프로젝트 권한이 필요합니다. 제공사는 개발 및 테스트 데이터베이스 사용을 권장합니다.",
"connectors.obsidian.name": "Obsidian 도구 모음",
"connectors.obsidian.description": "Markdown 노트, Canvas 보드, Bases 보기를 편집합니다.",
"connectors.obsidian.example": "이 프로젝트 노트를 Obsidian Canvas 관계도로 정리해 주세요.",
"connectors.obsidian.requirements": "로컬 파일 편집에는 클라우드 계정이 필요 없습니다. CLI 작업에는 실행 중인 Obsidian이 필요합니다. 추출 등 선택 기능은 별도 CLI가 필요합니다. 스킬 파일만 설치합니다.",
"connectors.postman.description": "API 워크스페이스, 컬렉션과 개발 자료를 검색합니다.",
"connectors.postman.example": "Postman에서 다음 작업을 도와주세요: API 워크스페이스, 컬렉션과 개발 자료를 검색합니다.",
"connectors.postman.name": "Postman",
"connectors.postman.requirements": "미국 지역 Postman 계정을 브라우저에서 승인합니다. 최소 도구 모음을 사용하며 EU 계정은 별도 API Key 설정이 필요합니다.",
"connectors.qcc.description": "기업 등록 정보와 관련 자료를 확인합니다.",
"connectors.qcc.example": "Qichacha에서 다음 작업을 도와주세요: 기업 등록 정보와 관련 자료를 확인합니다.",
"connectors.qcc.name": "Qichacha",
"connectors.qcc.requirements": "Qichacha Agent API Key가 필요합니다. 기본 기업 정보만 제공하며 다른 주제는 별도 연동이 필요합니다. 계정 권한과 포인트 제한이 적용됩니다.",
"connectors.qingflow.description": "워크스페이스 앱과 업무 데이터를 조회합니다.",
"connectors.qingflow.example": "Qingflow에서 다음 작업을 도와주세요: 워크스페이스 앱과 업무 데이터를 조회합니다.",
"connectors.qingflow.name": "Qingflow",
"connectors.qingflow.requirements": "Qingflow 계정으로 브라우저 승인이 필요합니다. 워크스페이스와 앱 권한이 적용되며 기능은 요금제에 따라 달라집니다.",
"connectors.qveris.description": "정보 조사에 사용할 데이터 도구를 검색합니다.",
"connectors.qveris.example": "QVeris에서 다음 작업을 도와주세요: 정보 조사에 사용할 데이터 도구를 검색합니다.",
"connectors.qveris.name": "QVeris",
"connectors.qveris.requirements": "QVeris API Key가 필요합니다. 도구 범위와 비용은 제공사가 정합니다. 키를 변경하면 다시 연결해야 합니다.",
"connectors.render.description": "배포된 서비스, 데이터베이스, 로그와 지표를 살펴봅니다.",
"connectors.render.example": "Render에서 다음 작업을 도와주세요: 배포된 서비스, 데이터베이스, 로그와 지표를 살펴봅니다.",
"connectors.render.name": "Render",
"connectors.render.requirements": "Render API Key가 필요합니다. 먼저 워크스페이스를 지정하세요. 키는 소속 워크스페이스에 접근할 수 있으며 배포에는 명시적인 작업 요청이 필요합니다.",
"connectors.sorftime.description": "해외 전자상거래 시장, 상품과 키워드를 조사합니다.",
"connectors.sorftime.example": "Sorftime에서 다음 작업을 도와주세요: 해외 전자상거래 시장, 상품과 키워드를 조사합니다.",
"connectors.sorftime.name": "Sorftime",
"connectors.sorftime.requirements": "Sorftime MCP 활성화와 Account-SK가 필요합니다. 구매한 서비스에 따라 플랫폼, 시장과 조회 한도가 결정됩니다.",
"connectors.todoist.description": "할 일, 프로젝트와 개인 일정을 살펴봅니다.",
"connectors.todoist.example": "Todoist에서 다음 작업을 도와주세요: 할 일, 프로젝트와 개인 일정을 살펴봅니다.",
"connectors.todoist.name": "Todoist",
"connectors.todoist.requirements": "Todoist 계정으로 브라우저에서 작업과 프로젝트 접근을 승인해야 합니다.",
"connectors.variflight.description": "항공편 상태, 공항과 여행 정보를 조회합니다.",
"connectors.variflight.example": "VariFlight에서 다음 작업을 도와주세요: 항공편 상태, 공항과 여행 정보를 조회합니다.",
"connectors.variflight.name": "VariFlight",
"connectors.variflight.requirements": "VariFlight MCP 플랫폼에서 로그인해 MCP Key를 발급받아야 합니다. 개인 여정에는 계정 권한이 필요하며 데이터와 호출 한도는 제공사 조건을 따릅니다.",
"connectors.webapp-testing.name": "웹 앱 테스트",
"connectors.webapp-testing.description": "Playwright로 로컬 페이지, 상호작용, 스크린샷을 검사합니다.",
"connectors.webapp-testing.example": "로컬 앱의 폼 검증과 페이지 배치를 확인해 주세요.",
"connectors.webapp-testing.requirements": "테스트 스킬, Python 보조 스크립트, 예제를 포함합니다. 실행에는 Python, Playwright, 브라우저, 대상 앱이 필요합니다. 브라우저를 자동으로 다운로드하지 않습니다.",
"connectors.webflow.description": "사이트 구조, CMS 콘텐츠와 디자인 정보를 읽습니다.",
"connectors.webflow.example": "Webflow에서 다음 작업을 도와주세요: 사이트 구조, CMS 콘텐츠와 디자인 정보를 읽습니다.",
"connectors.webflow.name": "Webflow",
"connectors.webflow.requirements": "사이트 권한이 있는 Webflow 계정이 필요합니다. OAuth 승인 시 MCP Bridge App이 설치되며 Designer 작업에는 브리지 연결이 필요합니다.",
"connectors.xmind.description": "온라인 마인드맵을 읽고 계획과 지식을 정리합니다.",
"connectors.xmind.example": "Xmind에서 다음 작업을 도와주세요: 온라인 마인드맵을 읽고 계획과 지식을 정리합니다.",
"connectors.xmind.name": "Xmind",
"connectors.xmind.requirements": "중국 본토 Xmind 계정을 브라우저에서 승인해야 합니다. 승인한 팀의 온라인 맵만 접근 가능하며 편집 기능은 요금제에 따라 달라집니다.",
"connectors.toolsRemoveInfo": "이 도구 플러그인과 스킬 파일을 제거합니다. 기존 작업과 결과 파일은 유지됩니다。",
"connectors.collection": "카탈로그 유형",
"connectors.services": "서비스 커넥터",
"connectors.tools": "도구 플러그인",
"connectors.region": "서비스 지역",
"connectors.region.all": "모든 지역",
"connectors.region.china": "중국 서비스",
"connectors.region.global": "글로벌 서비스",
"connectors.toolKind": "도구 플러그인",
"connectors.toolsSubtitle": "작업에 전문 스킬과 작업 방법을 추가합니다.",
"connectors.skillsLoaded": "스킬 로드됨",
"connectors.skillsPending": "스킬 로드 대기",
"connectors.verifySkills": "스킬 확인",
"connectors.skillsRuntimeInfo": "설치되는 것은 스킬 패키지입니다. 작업에 필요한 Node.js, 명령줄 도구 등의 실행 환경은 별도로 준비해야 합니다. 스킬이 로드되어도 해당 의존성이 설치된 것은 아닙니다.",
"connectors.saveAndVerify": "저장 및 확인",
"connectors.needsService": "서비스 연결 필요",
"connectors.jinshuju.name": "진슈쥐",
"connectors.jinshuju.description": "양식과 수집된 응답을 조회하여 분석과 정리를 돕습니다.",
"connectors.jinshuju.example": "진슈쥐에서 최근 수집한 설문 응답을 요약해 주세요.",
"connectors.jinshuju.requirements": "진슈쥐 계정이 필요합니다. 브라우저에서 양식 및 데이터 접근을 인증합니다.",
"connectors.tencent-docs.name": "텐센트 문서",
"connectors.tencent-docs.description": "온라인 문서, 스프레드시트 및 협업 자료를 읽고 정리합니다.",
"connectors.tencent-docs.example": "텐센트 문서에서 프로젝트 계획 관련 자료를 찾아 주세요.",
"connectors.tencent-docs.requirements": "Tencent Docs MCP 접근 토큰이 필요합니다. 계정이 이미 접근 가능한 문서만 사용할 수 있습니다.",
"connectors.pkulaw.name": "북대법보",
"connectors.pkulaw.description": "법률과 규정을 검색하고 조문 및 원문 출처를 확인합니다.",
"connectors.pkulaw.example": "근로 계약의 수습 기간 관련 법률 조항을 찾아 출처를 표시해 주세요.",
"connectors.pkulaw.requirements": "PKULaw MCP 접근 토큰과 해당 자료 권한이 필요합니다. 검색한 조항이 현재 유효한지 확인해야 합니다.",
"connectors.datayes.name": "Datayes",
"connectors.datayes.description": "주가 및 금융 시장 데이터를 조회하여 조사를 돕습니다.",
"connectors.datayes.example": "지정한 주식의 과거 시세를 조회하고 데이터 출처를 정리해 주세요.",
"connectors.datayes.requirements": "Datayes MCP Token이 필요합니다. 데이터 범위는 계정 권한과 할당량에 따라 제한됩니다. 기업 계정은 제공자의 확인 요건을 충족해야 합니다.",
"connectors.tencent-search.name": "Tencent Cloud 검색",
"connectors.tencent-search.description": "출처를 추적할 수 있는 웹 자료를 검색합니다.",
"connectors.tencent-search.example": "중국 저고도 경제 산업 발전 자료를 검색하고 공식 출처를 정리해 주세요.",
"connectors.tencent-search.requirements": "Tencent Cloud WSA 서비스 활성화와 API Key가 필요합니다. 계정 할당량 및 요금 규칙이 적용됩니다.",
"connectors.connectService": "서비스 연결",
"connectors.officialMcp": "공식 MCP 서비스",
"connectors.amap.name": "가오더 지도",
"connectors.amap.description": "장소를 검색하고 경로와 날씨, 이동 정보를 확인합니다.",
"connectors.amap.example": "항저우 동역에서 서호까지 대중교통 경로를 계획해 주세요.",
"connectors.amap.requirements": "Amap Web Service API Key가 필요합니다. 서비스는 계정 권한과 할당량에 따라 제공됩니다.",
"connectors.baidu-maps.name": "바이두 지도",
"connectors.baidu-maps.description": "주소를 분석하고 주변 장소와 경로를 검색합니다.",
"connectors.baidu-maps.example": "상하이 훙차오역 근처 호텔과 교통편을 찾아 주세요.",
"connectors.baidu-maps.requirements": "Baidu Maps 서버용 AK와 해당 서비스 권한이 필요합니다.",
"connectors.tencent-maps.name": "텐센트 지도",
"connectors.tencent-maps.description": "위치, 거리, 경로를 확인하여 이동을 계획합니다.",
"connectors.tencent-maps.example": "광저우 남역에서 광저우 타워까지 대중교통과 자동차 경로를 비교해 주세요.",
"connectors.tencent-maps.requirements": "Tencent Location Service Key와 WebServiceAPI 권한 및 할당량이 필요합니다.",
"connectors.zhipu-search.name": "즈푸 웹 검색",
"connectors.zhipu-search.description": "중국어 웹페이지, 뉴스, 최신 정보를 출처와 함께 검색합니다.",
"connectors.zhipu-search.example": "최근 중국 신에너지 자동차 정책을 검색하고 원문 출처를 보여 주세요.",
"connectors.zhipu-search.requirements": "GLM Coding Plan API Key가 필요합니다. 팀 요금제 키와 일반 플랫폼 키는 호환되지 않습니다.",
"connectors.notion.name": "Notion",
"connectors.notion.description": "워크스페이스 자료를 검색하고 페이지와 프로젝트 지식을 정리합니다.",
"connectors.notion.example": "Notion에서 최근 프로젝트 주간 보고서를 찾아 진행 상황을 요약해 주세요.",
"connectors.notion.requirements": "Notion 계정이 필요합니다. 브라우저 인증 후 해당 계정이 접근 가능한 내용만 사용할 수 있습니다.",
"connectors.linear.name": "Linear",
"connectors.linear.description": "이슈, 프로젝트, 주기를 조회하여 팀 작업을 추적합니다.",
"connectors.linear.example": "Linear에서 나에게 배정된 미완료 이슈를 보여 주세요.",
"connectors.linear.requirements": "Linear 워크스페이스 계정과 프로젝트 접근 권한이 필요하며 브라우저에서 인증합니다.",
"connectors.github.name": "GitHub",
"connectors.github.description": "저장소 코드, Issue, Pull Request를 검색합니다.",
"connectors.github.example": "내 GitHub 저장소에서 검토를 기다리는 Pull Request를 확인해 주세요.",
"connectors.github.requirements": "필요한 저장소와 작업 권한이 있는 GitHub Personal Access Token이 필요합니다. 조직 정책도 적용됩니다.",
"connectors.context7.name": "Context7",
"connectors.context7.description": "라이브러리의 최신 문서와 사용 예시를 확인합니다.",
"connectors.context7.example": "React useEffect 공식 문서와 정리 함수 예시를 찾아 주세요.",
"connectors.context7.requirements": "공식 익명 사용량을 이용합니다. 무제한이 아니며 한도를 넘으면 서비스가 제한을 반환합니다.",
"connectors.sentry.name": "Sentry",
"connectors.sentry.description": "예외, 이슈, 앱 실행 정보를 확인합니다.",
"connectors.sentry.example": "지난 하루 동안 Sentry에서 처음 발생한 오류를 요약해 주세요.",
"connectors.sentry.requirements": "조직 및 프로젝트 권한이 있는 Sentry 계정이 필요하며 브라우저에서 인증합니다.",
"connectors.tavily.name": "Tavily",
"connectors.tavily.description": "웹페이지를 검색하고 내용을 추출하여 조사를 돕습니다.",
"connectors.tavily.example": "오픈소스 벡터 데이터베이스를 비교하고 출처를 제시해 주세요.",
"connectors.tavily.requirements": "Tavily API Key가 필요합니다. 호출에는 계정 구독 및 할당량이 적용됩니다.",
"connectors.exa.name": "Exa",
"connectors.exa.description": "관련 웹페이지, 코드 및 기술 정보를 검색합니다.",
"connectors.exa.example": "분산 시스템의 관측 가능성에 관한 기술 문서를 찾아 주세요.",
"connectors.exa.requirements": "공식 기본 익명 사용량을 이용합니다. 사용 가능한 도구와 횟수는 제공자가 결정합니다.",
"connectors.supabase.name": "Supabase",
"connectors.supabase.description": "프로젝트, 데이터베이스 구조 및 개발 자료를 확인합니다.",
"connectors.supabase.example": "데이터를 변경하지 말고 Supabase 프로젝트의 테이블 구조를 확인해 주세요.",
"connectors.supabase.requirements": "대상 조직 및 프로젝트 권한이 있는 Supabase 계정이 필요합니다. 데이터베이스 쓰기는 명시적인 작업 지시가 필요합니다.",
"connectors.huggingface.name": "Hugging Face",
"connectors.huggingface.description": "모델, 데이터셋 및 머신러닝 연구 자료를 찾습니다.",
"connectors.huggingface.example": "중국어 텍스트 분류에 적합한 공개 모델과 데이터셋을 찾아 주세요.",
"connectors.huggingface.requirements": "Hugging Face 접근 토큰이 필요합니다. 검색에는 read 권한을 권장하며 계산 작업에는 별도 요금이 발생할 수 있습니다.",
"connectors.canva.name": "Canva",
"connectors.canva.description": "디자인 자료를 검색, 정리하고 디자인 작업에 협업합니다.",
"connectors.canva.example": "Canva에서 최근 브랜드 디자인 자료를 찾아 주세요.",
"connectors.canva.requirements": "각 사용자가 자신의 Canva 계정으로 인증합니다. 일부 기능은 구독 및 팀 권한에 따라 제한됩니다.",
"connectors.figma.name": "Figma",
"connectors.figma.description": "디자인 맥락과 컴포넌트, 화면 구조를 읽습니다.",
"connectors.figma.example": "제공한 Figma 디자인 링크의 컴포넌트 구조를 분석해 주세요.",
"connectors.figma.requirements": "Figma 계정 인증이 필요합니다. 접근 권한, 좌석 및 요금제에 따라 사용 가능한 도구와 횟수가 달라집니다.",
"connectors.stripe.name": "Stripe",
"connectors.stripe.description": "고객, 청구서 및 결제 업무 정보를 조회합니다.",
"connectors.stripe.example": "결제나 환불을 만들지 말고 최근 Stripe 청구서 개요를 확인해 주세요.",
"connectors.stripe.requirements": "Stripe 계정 인증이 필요합니다. 공식 서비스는 미리보기 버전이며 결제, 환불 등 쓰기 작업에는 명시적인 지시가 필요합니다.",
"connectors.category.legal": "법률",
"connectors.categories": "서비스 카테고리",
"connectors.allCategories": "추천",
"connectors.category.office": "업무 협업",
"connectors.category.development": "개발",
"connectors.category.search": "검색",
"connectors.category.maps": "지도",
"connectors.category.data": "데이터",
"connectors.category.design": "디자인",
"connectors.category.productivity": "생산성",
"connectors.category.finance": "금융",
"connectors.kind": "커넥터",
"connectors.loadFailed": "작업을 완료하지 못했습니다",
"connectors.emptyAdded": "서비스를 추가하면 여기에서 계정과 연결 상태를 관리할 수 있습니다.",
"connectors.emptySearch": "다른 검색어나 카테고리를 선택해 보세요.",
"connectors.resetFilters": "전체 보기",
"connectors.configuration": "연결 설정",
"connectors.configurationHint": "비워 두면 저장된 설정을 유지합니다. 새 설정을 입력한 후 저장 및 확인을 선택하여 연결을 확인하세요.",
"connectors.fieldRequired": "이 항목을 입력하세요",
"connectors.capabilities": "지원 기능",
"connectors.remoteExample": "{name}을 사용해 접근 가능한 정보를 검색해 주세요.",
"connectors.remoteRequirements": "서비스 계정과 필요한 접근 권한을 준비하세요. 사용 가능한 작업은 서비스 제공자가 부여한 권한에 따라 달라집니다.",
"connectors.phase.installing-plugin": "관련 스킬 설치 중",
"connectors.phase.checking": "계정 연결 확인 중",
"connectors.phase.enabling-plugin": "관련 스킬 활성화 중",
"connectors.phase.refreshing-sessions": "작업 기능 새로 고침 중",
"connectors.phase.verifying-runtime": "로컬 실행 환경 확인 중",
"connectors.phase.persistence": "연결 상태 저장 중",
"connectors.phase.cancelling": "작업 취소 중",
"connectors.feishu.requirements": "Feishu 사용자 계정과 일정 접근 권한이 필요합니다. 사용 가능한 기능은 계정과 앱에 부여된 권한에 따라 달라집니다.",
"connectors.dingtalk.requirements": "DingTalk OAuth 로그인과 기업 관리자가 부여한 일정 권한이 필요합니다. 로그인만으로 모든 서비스 권한을 얻는 것은 아닙니다.",
"connectors.wecom.requirements": "WeCom 봇 또는 사용자 ID 설정과 일정 권한이 필요합니다. 사용 가능한 작업은 선택한 ID에 부여된 권한에 따라 달라집니다.",
"connectors.update": "업데이트",
"connectors.updateAvailable": "업데이트 가능 버전",
"connectors.currentAccount": "현재 서비스 계정",
"connectors.phase.downloading": "실행 파일 다운로드 중",
"connectors.phase.extracting": "실행 파일 압축 해제 중",
"connectors.phase.verifying": "실행 파일 검증 중",
"connectors.phase.awaiting-authorization": "계정 인증 대기 중",
"connectors.phase.configuring-account": "계정 설정 중",
"connectors.phase.authorizing": "인증 중",
"connectors.phase.failed": "실패 단계",
"connectors.phase.skillsInfo": "설치 후 이 서비스를 조작하는 스킬을 사용할 수 있습니다.",
'connectors.status.configured': '로컬 설정됨',
'connectors.localVerification': '첫 작업 호출 시 서비스 권한을 확인합니다.',
"sidebar.connectors": "커넥터",
"connectors.subtitle": "업무 서비스를 연결하여 스킬의 기능을 확장하세요.",
"connectors.filter": "커넥터 필터",
"connectors.all": "전체",
"connectors.added": "추가됨",
"connectors.search": "커넥터 검색",
"connectors.clear": "검색 지우기",
"connectors.retry": "다시 시도",
"connectors.loading": "불러오는 중…",
"connectors.empty": "일치하는 커넥터가 없습니다",
"connectors.details": "상세 정보",
"connectors.start": "사용 시작",
"connectors.account": "계정",
"connectors.noAccount": "계정 미확인",
"connectors.platforms": "지원 환경",
"connectors.source": "출처",
"connectors.version": "버전",
"connectors.skills": "관련 스킬 플러그인",
"connectors.homepage": "프로젝트 홈페이지",
"connectors.example": "예시",
"connectors.waiting": "처리 중입니다. 인증을 완료하고 상태 업데이트를 기다리세요.",
"connectors.authorize": "인증 페이지 열기",
"connectors.confirm": "작업 확인",
"connectors.continue": "계속",
"connectors.shared": "이 커넥터는 로컬 CLI 계정 인증 정보를 공유합니다. 연결하거나 계정을 변경하면 같은 CLI를 사용하는 다른 앱에도 영향을 줍니다. 확인 후 계속하세요.",
"connectors.isolated": "인증 정보는 이 앱에 독립적으로 저장됩니다.",
"connectors.removeInfo": "앱이 관리하는 커넥터를 제거하고 공유 계정 인증 정보는 유지합니다.",
"connectors.invalidUrl": "잘못된 인증 URL입니다",
"connectors.openFailed": "링크를 열 수 없습니다. 다시 시도하세요.",
"connectors.startFailed": "작업을 만들 수 없습니다. 다시 시도하세요.",
"connectors.unsupported": "지원하지 않는 환경",
"connectors.action.prepare": "추가",
"connectors.action.authenticate": "계정 연결",
"connectors.action.check": "연결 확인",
"connectors.action.deactivate": "비활성화",
"connectors.action.remove": "제거",
"connectors.status.not-installed": "추가되지 않음",
"connectors.status.preparing": "준비 중",
"connectors.status.needs-auth": "계정 연결 필요",
"connectors.status.authorizing": "인증 중",
"connectors.status.ready": "사용 가능",
"connectors.status.disabled": "비활성화됨",
"connectors.status.error": "확인 필요",
"connectors.feishu.name": "Feishu",
"connectors.dingtalk.name": "DingTalk",
"connectors.wecom.name": "WeCom",
"connectors.feishu.description": "Feishu 문서, 일정 및 업무 협업을 관리합니다.",
"connectors.dingtalk.description": "DingTalk 일정 및 팀 업무 서비스를 연결합니다.",
"connectors.wecom.description": "WeCom으로 팀과 협업합니다.",
"connectors.feishu.example": "Feishu의 오늘 일정을 보여 주세요.",
"connectors.dingtalk.example": "DingTalk의 오늘 일정을 보여 주세요.",
"connectors.wecom.example": "WeCom 계정에서 사용 가능한 기능을 확인해 주세요.",
'settings.providers.compatibilityJsonHint': 'requestCompatibility는 이 공급자에만 저장되며 전역 설정에는 기록되지 않습니다.',
'settings.providers.compatibilityBudget': '응답 출력 예산',
'settings.providers.compatibilityBudgetHint': '일반 응답에 요청할 최대 출력 토큰 수입니다. 비워 두면 자동으로 선택하며 짧은 백그라운드 요청은 작은 예산을 유지합니다.',
+351
View File
@@ -1,6 +1,357 @@
import type { TranslationKey } from './en'
export const zh: Record<TranslationKey, string> = {
"extensions.myPlugins": "我的外掛",
"extensions.mySkills": "我的技能",
"extensions.browse": "返回瀏覽",
"extensions.managedSkillHint": "配套技能隨所屬外掛管理;本機與專案技能請在對應目錄管理。",
"extensions.searchPlugins": "搜尋外掛",
"extensions.searchSkills": "搜尋精選技能",
"extensions.pluginsSubtitle": "連接常用服務,或為助手加入創作與開發工具。",
"extensions.filter": "篩選擴充功能",
"extensions.categories": "擴充分類",
"extensions.empty": "沒有符合的擴充功能",
"chat.references": "引用",
"chat.referencePlugins": "外掛",
"chat.referenceSkills": "技能",
"chat.referenceFiles": "檔案",
"chat.referencesLoadFailed": "無法載入技能與外掛",
"chat.referencesEmpty": "沒有符合的引用",
"chat.referenceDescription": "描述",
"chat.referenceSource": "來源",
"chat.referenceDetails": "引用詳情",
"sidebar.extensions": "技能·連接器",
"extensions.sections": "市場分類",
"extensions.plugins": "外掛",
"extensions.skills": "技能",
"extensions.featuredSkills": "精選技能",
"connectors.airtable.description": "查詢數據表、記錄與團隊協作資料。",
"connectors.airtable.example": "查看 Airtable 中最近更新的項目記錄。",
"connectors.airtable.name": "Airtable",
"connectors.airtable.requirements": "通過瀏覽器授權目標工作區或數據表;企業管理員可能限制客戶端訪問。",
"connectors.algorithmic-art.name": "生成式藝術",
"connectors.algorithmic-art.description": "使用 p5.js 建立可調整參數的演算法藝術與互動畫面。",
"connectors.algorithmic-art.example": "製作可調整色彩與密度的流場藝術頁面。",
"connectors.algorithmic-art.requirements": "包含範本和參考程式碼。預覽需要瀏覽器及 p5.js 資源;安裝不會執行腳本或呼叫生成模型。",
"connectors.atlassian.description": "檢索 Jira、Confluence 與項目協作資料。",
"connectors.atlassian.example": "匯總 Jira 中分配給我的問題並關聯 Confluence 資料。",
"connectors.atlassian.name": "Atlassian",
"connectors.atlassian.requirements": "需要 Atlassian Cloud 賬號及產品權限,通過瀏覽器授權;部分產品和操作需要管理員啓用。",
"connectors.bazhuayu.description": "查找採集模板、查看雲任務狀態與已採集數據。",
"connectors.bazhuayu.example": "列出八爪魚數據中心支持的平台,不創建或啓動採集任務。",
"connectors.bazhuayu.name": "八爪魚",
"connectors.bazhuayu.requirements": "需要八爪魚賬戶中心 API Key;僅支持雲端採集能力,運行任務可能消耗餘額,部分模板需指定套餐。",
"connectors.canvas-design.name": "海報與視覺畫布",
"connectors.canvas-design.description": "創作海報、靜態視覺作品與 PDF 畫布,附帶字型。",
"connectors.canvas-design.example": "設計一張科技活動海報,並匯出為 PDF。",
"connectors.canvas-design.requirements": "安裝完整設計技能、字型及其授權文件。圖片或 PDF 生成需要 Python 和對應繪圖套件;不會自動安裝執行程式庫。",
"connectors.clickup.description": "查找項目、任務和團隊工作資料。",
"connectors.clickup.example": "列出 ClickUp 中分配給我的未完成任務。",
"connectors.clickup.name": "ClickUp",
"connectors.clickup.requirements": "需要 ClickUp 賬號及工作區權限,通過瀏覽器授權;服務仍為公開測試版。",
"connectors.cloudflare.description": "查詢和管理網站、網絡及雲開發資源。",
"connectors.cloudflare.example": "查看我的 Cloudflare 資源並匯總當前配置。",
"connectors.cloudflare.name": "Cloudflare",
"connectors.cloudflare.requirements": "需要用戶或賬戶 API Token;按所需資源授予權限,部署和配置變更需明確任務授權。",
"connectors.drawio.name": "draw.io 圖表",
"connectors.drawio.description": "產生可編輯的架構圖、流程圖和關係圖。",
"connectors.drawio.example": "把這段系統說明整理成可編輯的 draw.io 架構圖。",
"connectors.drawio.requirements": "可直接編寫 .drawio XML 檔案;Mermaid 轉換、自動排版及圖片匯出需要 draw.io 桌面版 CLI。安裝技能不會安裝桌面應用程式。",
"connectors.firecrawl.description": "搜索網頁、提取頁面並整理研究資料。",
"connectors.firecrawl.example": "搜索關於瀏覽器自動化的技術資料並保留來源。",
"connectors.firecrawl.name": "Firecrawl",
"connectors.firecrawl.requirements": "需要 Firecrawl API Key;調用按賬戶額度計費,密鑰通過安全請求頭髮送。",
"connectors.frontend-design.name": "前端介面設計",
"connectors.frontend-design.description": "為網頁和應用程式建立視覺層次清晰的前端介面。",
"connectors.frontend-design.example": "為我的知識管理應用程式設計並實作首頁。",
"connectors.frontend-design.requirements": "安裝 Anthropic 前端設計技能。預覽與建置需要目標專案的相依套件和執行環境;不提供託管或圖片生成服務。",
"connectors.hyperframes.name": "HyperFrames",
"connectors.hyperframes.description": "用 HTML 和動畫程式碼製作影片、字幕與產品示範。",
"connectors.hyperframes.example": "用 HyperFrames 為產品製作一段 20 秒的介紹影片。",
"connectors.hyperframes.requirements": "安裝固定版本的 5 項完整技能與參考資料,不安裝渲染器。渲染需要 Node.js 22+、HyperFrames CLI 與 FFmpeg;本機語音可能需要 Python 3.10+,線上素材服務須另行設定並可能收費。",
"connectors.intercom.description": "檢索客戶對話、聯繫人和幫助中心資料。",
"connectors.intercom.example": "查找最近客戶反饋中的常見問題並匯總。",
"connectors.intercom.name": "Intercom",
"connectors.intercom.requirements": "目前僅支持美國托管工作區;需要具有聯繫人、對話和文章權限的訪問令牌。",
"connectors.jufa.description": "檢索司法案例,輔助類案研究與裁判觀點整理。",
"connectors.jufa.example": "檢索建設工程合同糾紛案例,整理案號、法院及裁判觀點並注明出處。",
"connectors.jufa.name": "聚法案例",
"connectors.jufa.requirements": "需要聚法智能體數據平台 API Key;本連接器接入案例研究服務,法規等其他專題需另行接入,數據受套餐與調用額度限制。",
"connectors.kuaicha.description": "檢索企業工商、經營和風險資料,輔助主體核驗。",
"connectors.kuaicha.example": "查詢我指定企業的工商登記與經營異常信息,並區分已查到和缺失的數據。",
"connectors.kuaicha.name": "同花順快查",
"connectors.kuaicha.requirements": "需要快查開放平台 API Key;服務僅限大陸 IP 訪問,查詢受賬號額度與權限限制。",
"connectors.listinggood.description": "分析亞馬遜商品文案,輔助合規檢查和內容優化。",
"connectors.listinggood.example": "檢查我提供的亞馬遜商品文案,給出合規與可讀性改進建議。",
"connectors.listinggood.name": "ListingGood",
"connectors.listinggood.requirements": "需要 ListingGood 國內站 API Key;國內站與海外站密鑰不通用,付費工具按賬號額度執行。",
"connectors.mcp-builder.name": "MCP 開發工具包",
"connectors.mcp-builder.description": "建置與評估 MCP 服務,提供 TypeScript 和 Python 參考。",
"connectors.mcp-builder.example": "為我們的內部知識庫設計 MCP 服務。",
"connectors.mcp-builder.requirements": "包含設計參考和評估腳本。開發需要 Node.js 或 Python 及 MCP SDK;模型評估與第三方 API 需要使用者自行設定憑證,可能產生費用。",
"connectors.miro.description": "查閱協作白板,整理設計與討論內容。",
"connectors.miro.example": "總結我指定 Miro 白板中的主要想法和待辦。",
"connectors.miro.name": "Miro",
"connectors.miro.requirements": "需要 Miro 賬號和白板訪問權限,組織管理員可能需要啓用 MCP。",
"connectors.neon.description": "查看數據庫項目、分支與開發數據結構。",
"connectors.neon.example": "查看 Neon 開發項目的數據庫結構,不修改數據。",
"connectors.neon.name": "Neon",
"connectors.neon.requirements": "需要 Neon API Key 和項目權限;官方建議優先用於開發和測試數據庫。",
"connectors.obsidian.name": "Obsidian 工具包",
"connectors.obsidian.description": "編輯 Markdown 筆記、Canvas 畫布與 Bases 資料檢視。",
"connectors.obsidian.example": "把這些專案筆記整理為 Obsidian Canvas 關係圖。",
"connectors.obsidian.requirements": "本機檔案編輯不需雲端帳號。CLI 操作需要安裝並執行 Obsidian;網頁擷取等選用功能需要對應 CLI。本安裝僅提供技能檔案。",
"connectors.postman.description": "查詢 API 工作區、集合與開發資料。",
"connectors.postman.example": "查找 Postman 工作區中與用戶登錄相關的 API。",
"connectors.postman.name": "Postman",
"connectors.postman.requirements": "使用美國區域 Postman 賬號瀏覽器授權;此條目採用精簡工具集,歐盟區域需單獨 API Key 配置。",
"connectors.qcc.description": "檢索企業主體信息,核驗工商登記與關聯資料。",
"connectors.qcc.example": "查詢我指定企業的工商登記信息,並列出來源。",
"connectors.qcc.name": "企查查",
"connectors.qcc.requirements": "需要企查查 Agent 平台 API Key;本連接器僅接入企業基礎信息服務,其他專題需另行接入,查詢受賬號權限與積分限制。",
"connectors.qingflow.description": "查詢輕流工作區應用與業務數據,輔助流程協作。",
"connectors.qingflow.example": "查看輕流工作區中我有權限的應用,並整理待處理事項。",
"connectors.qingflow.name": "輕流",
"connectors.qingflow.requirements": "需要輕流賬號,通過瀏覽器授權;可訪問範圍繼承工作區和應用權限,具體能力受套餐限制。",
"connectors.qveris.description": "發現和查閱可調用的數據工具,輔助信息研究。",
"connectors.qveris.example": "查找 QVeris 中適合企業資料檢索的工具,並說明所需參數和費用條件,暫不執行。",
"connectors.qveris.name": "QVeris",
"connectors.qveris.requirements": "需要 QVeris API Key;工具範圍與調用費用由服務商決定,更換密鑰後需重新建立連接。",
"connectors.render.description": "查看部署服務、數據庫、運行日誌與指標。",
"connectors.render.example": "查看 Render 服務最近的運行日誌並總結異常。",
"connectors.render.name": "Render",
"connectors.render.requirements": "需要 Render API Key;先指定工作區。密鑰可訪問本人所屬工作區,部署等寫操作需明確任務授權。",
"connectors.sorftime.description": "查詢跨境電商市場、商品與關鍵詞數據。",
"connectors.sorftime.example": "查詢我指定商品的市場趨勢並注明站點、時間範圍與數據來源。",
"connectors.sorftime.name": "Sorftime",
"connectors.sorftime.requirements": "需要開通 Sorftime MCP 服務並獲取 Account-SK;平台、站點及查詢次數受所購服務與額度限制。",
"connectors.todoist.description": "查看待辦、項目和個人任務安排。",
"connectors.todoist.example": "列出 Todoist 中今天到期和已經逾期的任務。",
"connectors.todoist.name": "Todoist",
"connectors.todoist.requirements": "需要 Todoist 賬號,通過瀏覽器授權任務和項目訪問權限。",
"connectors.variflight.description": "查詢航班動態、機場信息與航旅行程。",
"connectors.variflight.example": "查詢我指定航班的計劃時間與當前動態,並注明查詢時間。",
"connectors.variflight.name": "飛常准",
"connectors.variflight.requirements": "需要在飛常准 MCP 開放平台登錄並生成 MCP Key;個人行程須有相應賬號權限,數據與調用額度按服務商規定提供。",
"connectors.webapp-testing.name": "網頁測試工具包",
"connectors.webapp-testing.description": "使用 Playwright 檢查本機網頁、互動行為與截圖。",
"connectors.webapp-testing.example": "檢查本機應用程式的表單驗證和頁面排版。",
"connectors.webapp-testing.requirements": "包含測試技能、Python 輔助腳本及範例。執行需要 Python、Playwright、瀏覽器及待測應用程式;安裝不會自動下載瀏覽器。",
"connectors.webflow.description": "讀取網站結構、CMS 內容與設計資料。",
"connectors.webflow.example": "查看我的 Webflow 網站中的 CMS 集合和字段。",
"connectors.webflow.name": "Webflow",
"connectors.webflow.requirements": "需要至少一個可訪問站點的 Webflow 賬號;瀏覽器授權自動安裝 MCP Bridge App,設計器操作需橋接應用連接。",
"connectors.xmind.description": "讀取與整理在線思維導圖,輔助規劃和知識歸納。",
"connectors.xmind.example": "列出 Xmind 最近打開的在線思維導圖,讀取我選擇的導圖並總結。",
"connectors.xmind.name": "Xmind",
"connectors.xmind.requirements": "需要 Xmind 國內版賬號,通過瀏覽器授權;僅訪問已授權團隊的在線導圖,編輯能力受賬號套餐限制。",
"connectors.toolsRemoveInfo": "移除此工具外掛及其技能檔案。現有任務與產出檔案會保留。",
"connectors.collection": "目錄類型",
"connectors.services": "服務連接器",
"connectors.tools": "工具外掛",
"connectors.region": "服務地區",
"connectors.region.all": "全部地區",
"connectors.region.china": "中國服務",
"connectors.region.global": "全球服務",
"connectors.toolKind": "工具外掛",
"connectors.toolsSubtitle": "為任務加入專用技能與工作方法。",
"connectors.skillsLoaded": "技能已載入",
"connectors.skillsPending": "技能待載入",
"connectors.verifySkills": "驗證技能",
"connectors.skillsRuntimeInfo": "加入的是技能套件。任務所需的 Node.js、命令列工具或其他執行環境仍需另行準備;技能載入成功不代表這些環境已安裝。",
"connectors.saveAndVerify": "儲存並驗證",
"connectors.needsService": "待連接服務",
"connectors.jinshuju.name": "金數據",
"connectors.jinshuju.description": "查詢表單與收集的資料,協助統計和整理。",
"connectors.jinshuju.example": "查看金數據中最近收集的問卷結果並整理摘要。",
"connectors.jinshuju.requirements": "需要金數據帳號,透過瀏覽器授權表單與資料存取權限。",
"connectors.tencent-docs.name": "騰訊文件",
"connectors.tencent-docs.description": "讀取和整理線上文件、表格與協作資料。",
"connectors.tencent-docs.example": "查找騰訊文件中與專案計畫相關的資料。",
"connectors.tencent-docs.requirements": "需要騰訊文件 MCP 存取權杖;僅可存取帳號已有權限的文件。",
"connectors.pkulaw.name": "北大法寶",
"connectors.pkulaw.description": "搜尋法律法規,追溯法律條文和原始出處。",
"connectors.pkulaw.example": "搜尋與勞動契約試用期相關的法律條文並註明出處。",
"connectors.pkulaw.requirements": "需要北大法寶 MCP access token 及相應資料權限;搜尋結果需核對現行有效性。",
"connectors.datayes.name": "通聯數據",
"connectors.datayes.description": "查詢股票行情與金融市場資料,協助研究。",
"connectors.datayes.example": "查詢我指定股票的歷史行情並整理資料來源。",
"connectors.datayes.requirements": "需要通聯數據 MCP Token;資料範圍受帳號權限和額度限制,企業帳號需依服務商要求驗證。",
"connectors.tencent-search.name": "騰訊雲智慧搜尋",
"connectors.tencent-search.description": "搜尋網路資料,為研究提供可追溯資訊。",
"connectors.tencent-search.example": "搜尋中國低空經濟產業發展資料,並整理官方來源。",
"connectors.tencent-search.requirements": "需要開通騰訊雲 WSA 服務並取得 API Key,呼叫依帳號額度與計費規則執行。",
"connectors.connectService": "連接服務",
"connectors.officialMcp": "官方 MCP 服務",
"connectors.amap.name": "高德地圖",
"connectors.amap.description": "搜尋地點、規劃路線,查詢天氣與出行資訊。",
"connectors.amap.example": "幫我規劃從杭州東站到西湖的大眾運輸路線。",
"connectors.amap.requirements": "需要高德開放平台 Web 服務 API Key;地圖服務依帳號權限和配額提供。",
"connectors.baidu-maps.name": "百度地圖",
"connectors.baidu-maps.description": "地址解析、周邊搜尋與路線規劃。",
"connectors.baidu-maps.example": "查詢上海虹橋站附近的飯店和交通方式。",
"connectors.baidu-maps.requirements": "需要百度地圖開放平台伺服器端 AK 及對應服務權限。",
"connectors.tencent-maps.name": "騰訊地圖",
"connectors.tencent-maps.description": "查詢位置、距離和路線,協助安排出行。",
"connectors.tencent-maps.example": "比較廣州南站到廣州塔的大眾運輸與開車路線。",
"connectors.tencent-maps.requirements": "需要騰訊位置服務 Key,並開通 WebServiceAPI 權限與配額。",
"connectors.zhipu-search.name": "智譜聯網搜尋",
"connectors.zhipu-search.description": "搜尋中文網頁、新聞與即時資訊,保留來源。",
"connectors.zhipu-search.example": "搜尋近期中國新能源車政策,並列出原始來源。",
"connectors.zhipu-search.requirements": "需要 GLM Coding Plan 方案 API Key;團隊方案金鑰與平台通用金鑰不通用。",
"connectors.notion.name": "Notion",
"connectors.notion.description": "搜尋工作區資料,整理頁面與專案知識。",
"connectors.notion.example": "查找 Notion 中最近的專案週報並摘要進度。",
"connectors.notion.requirements": "需要 Notion 帳號,瀏覽器授權後僅能存取該帳號允許的工作區內容。",
"connectors.linear.name": "Linear",
"connectors.linear.description": "查詢問題、專案與迭代,追蹤團隊任務。",
"connectors.linear.example": "列出 Linear 中指派給我的未完成問題。",
"connectors.linear.requirements": "需要 Linear 工作區帳號及專案存取權限,透過瀏覽器授權。",
"connectors.github.name": "GitHub",
"connectors.github.description": "搜尋儲存庫程式碼、Issue 和 Pull Request。",
"connectors.github.example": "查看我的 GitHub 儲存庫中等待審查的 Pull Request。",
"connectors.github.requirements": "需要 GitHub Personal Access Token;依所需儲存庫與操作授予權限,組織政策仍然適用。",
"connectors.context7.name": "Context7",
"connectors.context7.description": "查閱軟體函式庫的最新文件與用法範例。",
"connectors.context7.example": "查找 React useEffect 的官方文件和清理函式範例。",
"connectors.context7.requirements": "使用官方匿名存取額度;並非無限呼叫,額度不足時服務會返回限制。",
"connectors.sentry.name": "Sentry",
"connectors.sentry.description": "查看例外、追蹤問題與應用程式執行資訊。",
"connectors.sentry.example": "摘要 Sentry 中最近一天新出現的錯誤。",
"connectors.sentry.requirements": "需要 Sentry 帳號及組織、專案權限,透過瀏覽器授權。",
"connectors.tavily.name": "Tavily",
"connectors.tavily.description": "搜尋網頁、擷取內容,協助資料研究。",
"connectors.tavily.example": "研究開源向量資料庫的主要差異,並提供來源。",
"connectors.tavily.requirements": "需要 Tavily API Key,呼叫依該帳號訂閱與額度計費。",
"connectors.exa.name": "Exa",
"connectors.exa.description": "搜尋網頁與程式碼資料,發現相關資訊。",
"connectors.exa.example": "尋找關於分散式系統可觀測性的技術文章。",
"connectors.exa.requirements": "使用官方基本匿名存取額度;可用工具和次數由服務商決定。",
"connectors.supabase.name": "Supabase",
"connectors.supabase.description": "查看專案、資料庫結構和開發資料。",
"connectors.supabase.example": "查看 Supabase 專案的資料表結構,不修改資料。",
"connectors.supabase.requirements": "需要 Supabase 帳號及目標組織、專案權限;資料庫寫入仍需明確任務授權。",
"connectors.huggingface.name": "Hugging Face",
"connectors.huggingface.description": "發現模型、資料集與機器學習研究資源。",
"connectors.huggingface.example": "尋找適合中文文字分類的公開模型與資料集。",
"connectors.huggingface.requirements": "需要 Hugging Face 存取權杖;搜尋建議僅授予 read 權限,運算任務可能另外收費。",
"connectors.canva.name": "Canva",
"connectors.canva.description": "搜尋和整理設計素材,協作處理設計內容。",
"connectors.canva.example": "查找 Canva 中最近的品牌設計素材。",
"connectors.canva.requirements": "每位使用者以自己的 Canva 帳號授權;部分能力受訂閱和團隊權限限制。",
"connectors.figma.name": "Figma",
"connectors.figma.description": "讀取設計脈絡,理解元件與介面結構。",
"connectors.figma.example": "分析我提供的 Figma 設計連結中的元件結構。",
"connectors.figma.requirements": "需要 Figma 帳號授權;設計存取權限、席位和方案決定可用工具及次數。",
"connectors.stripe.name": "Stripe",
"connectors.stripe.description": "查詢客戶、帳單和付款業務資料。",
"connectors.stripe.example": "查看 Stripe 中最近的帳單概況,不建立付款或退款。",
"connectors.stripe.requirements": "需要 Stripe 帳號授權。官方服務為預覽版;付款、退款等寫入操作需要明確任務授權。",
"connectors.category.legal": "法律檢索",
"connectors.categories": "服務分類",
"connectors.allCategories": "精選",
"connectors.category.office": "辦公協作",
"connectors.category.development": "開發工具",
"connectors.category.search": "搜尋",
"connectors.category.maps": "地圖出行",
"connectors.category.data": "資料分析",
"connectors.category.design": "設計創作",
"connectors.category.productivity": "效率工具",
"connectors.category.finance": "金融",
"connectors.kind": "連接器",
"connectors.loadFailed": "暫時無法完成操作",
"connectors.emptyAdded": "新增常用服務後,即可在這裡管理帳號與連線狀態。",
"connectors.emptySearch": "換個關鍵字,或查看其他分類。",
"connectors.resetFilters": "查看全部",
"connectors.configuration": "連線設定",
"connectors.configurationHint": "留空沿用已儲存的設定。填寫新設定後,點選「儲存並驗證」驗證連線。",
"connectors.fieldRequired": "請填寫此項",
"connectors.capabilities": "支援的操作",
"connectors.remoteExample": "請使用 {name} 查詢我可以存取的資訊。",
"connectors.remoteRequirements": "請準備服務帳號及所需存取權限;具體操作以服務商授予的權限為準。",
"connectors.phase.installing-plugin": "安裝配套技能",
"connectors.phase.checking": "檢查帳號連線",
"connectors.phase.enabling-plugin": "啟用配套技能",
"connectors.phase.refreshing-sessions": "重新整理任務能力",
"connectors.phase.verifying-runtime": "檢查本機執行環境",
"connectors.phase.persistence": "儲存連線狀態",
"connectors.phase.cancelling": "取消操作",
"connectors.feishu.requirements": "需要飛書使用者帳號及行事曆存取權限;實際能力取決於帳號與應用程式獲授的權限。",
"connectors.dingtalk.requirements": "需要釘釘 OAuth 登入及企業管理員授予行事曆權限;登入成功不代表具備所有服務權限。",
"connectors.wecom.requirements": "需要設定企業微信機器人或使用者身分及行事曆權限;可用操作取決於所選身分獲授的權限。",
"connectors.update": "更新",
"connectors.updateAvailable": "可更新至",
"connectors.currentAccount": "目前服務帳號",
"connectors.phase.downloading": "下載執行檔",
"connectors.phase.extracting": "解壓執行檔",
"connectors.phase.verifying": "驗證執行檔",
"connectors.phase.awaiting-authorization": "等待帳號授權",
"connectors.phase.configuring-account": "設定帳號",
"connectors.phase.authorizing": "正在授權",
"connectors.phase.failed": "失敗階段",
"connectors.phase.skillsInfo": "安裝後提供對應服務的操作技能。",
'connectors.status.configured': '本機已設定',
'connectors.localVerification': '首次任務呼叫時驗證服務權限。',
"sidebar.connectors": "連接器",
"connectors.subtitle": "連接常用辦公服務,讓技能獲得真實的服務能力。",
"connectors.filter": "篩選連接器",
"connectors.all": "全部",
"connectors.added": "已新增",
"connectors.search": "搜尋連接器",
"connectors.clear": "清除搜尋",
"connectors.retry": "重試",
"connectors.loading": "載入中…",
"connectors.empty": "沒有符合的連接器",
"connectors.details": "查看詳情",
"connectors.start": "開始使用",
"connectors.account": "帳號",
"connectors.noAccount": "尚未驗證帳號",
"connectors.platforms": "支援平台",
"connectors.source": "來源",
"connectors.version": "版本",
"connectors.skills": "配套技能外掛",
"connectors.homepage": "專案首頁",
"connectors.example": "範例",
"connectors.waiting": "正在處理,請完成授權後等待狀態更新。",
"connectors.authorize": "開啟授權頁面",
"connectors.confirm": "確認操作",
"connectors.continue": "繼續",
"connectors.shared": "此連接器會共用本機 CLI 的帳號憑證。連接或切換帳號會影響其他使用相同 CLI 的應用程式,請確認後繼續。",
"connectors.isolated": "帳號憑證獨立儲存在本應用程式中。",
"connectors.removeInfo": "移除本應用程式管理的連接器,保留共用帳號憑證。",
"connectors.invalidUrl": "授權連結格式不正確",
"connectors.openFailed": "無法開啟連結,請重試",
"connectors.startFailed": "無法建立任務,請重試",
"connectors.unsupported": "目前平台不支援",
"connectors.action.prepare": "新增",
"connectors.action.authenticate": "連接帳號",
"connectors.action.check": "檢查連線",
"connectors.action.deactivate": "停用",
"connectors.action.remove": "移除",
"connectors.status.not-installed": "未新增",
"connectors.status.preparing": "準備中",
"connectors.status.needs-auth": "待連接帳號",
"connectors.status.authorizing": "授權中",
"connectors.status.ready": "可用",
"connectors.status.disabled": "已停用",
"connectors.status.error": "需要處理",
"connectors.feishu.name": "飛書",
"connectors.dingtalk.name": "釘釘",
"connectors.wecom.name": "企業微信",
"connectors.feishu.description": "管理飛書文件、行事曆與辦公協作。",
"connectors.dingtalk.description": "連接釘釘行程與團隊辦公服務。",
"connectors.wecom.description": "使用企業微信進行團隊協作。",
"connectors.feishu.example": "請列出我在飛書中今天的行程。",
"connectors.dingtalk.example": "請查詢我在釘釘中今天的行程。",
"connectors.wecom.example": "請查看我的企業微信帳號可用的功能。",
'settings.providers.compatibilityJsonHint': 'requestCompatibility 僅儲存到目前供應商,不寫入全域設定。',
'settings.providers.compatibilityBudget': '回覆輸出預算',
'settings.providers.compatibilityBudgetHint': '一般回覆請求的最大輸出 token 數。留空自動選擇;背景短請求仍使用較小預算。',
+350
View File
@@ -1,6 +1,356 @@
import type { TranslationKey } from './en'
export const zh: Record<TranslationKey, string> = {
"extensions.myPlugins": "我的插件",
"extensions.mySkills": "我的技能",
"extensions.browse": "返回浏览",
"extensions.managedSkillHint": "配套技能随所属插件管理;本地与项目技能请在对应目录管理。",
"extensions.searchPlugins": "搜索插件",
"extensions.searchSkills": "搜索精选技能",
"extensions.pluginsSubtitle": "连接常用服务,或为助手添加创作与开发工具。",
"extensions.filter": "筛选扩展",
"extensions.categories": "扩展分类",
"extensions.empty": "没有匹配的扩展",
"chat.references": "引用",
"chat.referencePlugins": "插件",
"chat.referenceSkills": "技能",
"chat.referenceFiles": "文件",
"chat.referencesLoadFailed": "无法加载技能和插件",
"chat.referencesEmpty": "没有匹配的引用",
"chat.referenceDescription": "描述",
"chat.referenceSource": "来源",
"chat.referenceDetails": "引用详情",
"sidebar.extensions": "技能·连接器",
"extensions.sections": "市场分类",
"extensions.plugins": "插件",
"extensions.skills": "技能",
"extensions.featuredSkills": "精选技能",
"connectors.airtable.description": "查询数据表、记录与团队协作资料。",
"connectors.airtable.example": "查看 Airtable 中最近更新的项目记录。",
"connectors.airtable.name": "Airtable",
"connectors.airtable.requirements": "通过浏览器授权目标工作区或数据表;企业管理员可能限制客户端访问。",
"connectors.algorithmic-art.description": "用 p5.js 创建可调参数的算法艺术与交互画面。",
"connectors.algorithmic-art.example": "制作一个可调整颜色与密度的流场艺术页面。",
"connectors.algorithmic-art.name": "生成式艺术",
"connectors.algorithmic-art.requirements": "包含示例模板和参考代码。预览需要浏览器及 p5.js 资源;安装不执行脚本,也不调用生成模型。",
"connectors.atlassian.description": "检索 Jira、Confluence 与项目协作资料。",
"connectors.atlassian.example": "汇总 Jira 中分配给我的问题并关联 Confluence 资料。",
"connectors.atlassian.name": "Atlassian",
"connectors.atlassian.requirements": "需要 Atlassian Cloud 账号及产品权限,通过浏览器授权;部分产品和操作需要管理员启用。",
"connectors.bazhuayu.description": "查找采集模板、查看云任务状态与已采集数据。",
"connectors.bazhuayu.example": "列出八爪鱼数据中心支持的平台,不创建或启动采集任务。",
"connectors.bazhuayu.name": "八爪鱼",
"connectors.bazhuayu.requirements": "需要八爪鱼账户中心 API Key;仅支持云端采集能力,运行任务可能消耗余额,部分模板需指定套餐。",
"connectors.canvas-design.description": "创作海报、静态视觉作品与 PDF 画布,附带字体。",
"connectors.canvas-design.example": "设计一张科技活动海报,并导出为 PDF。",
"connectors.canvas-design.name": "海报与视觉画布",
"connectors.canvas-design.requirements": "安装完整设计技能、字体及对应许可。生成图像或 PDF 需要 Python 和相应绘图依赖;不会自动安装这些运行库。",
"connectors.clickup.description": "查找项目、任务和团队工作资料。",
"connectors.clickup.example": "列出 ClickUp 中分配给我的未完成任务。",
"connectors.clickup.name": "ClickUp",
"connectors.clickup.requirements": "需要 ClickUp 账号及工作区权限,通过浏览器授权;服务仍为公开测试版。",
"connectors.cloudflare.description": "查询和管理网站、网络及云开发资源。",
"connectors.cloudflare.example": "查看我的 Cloudflare 资源并汇总当前配置。",
"connectors.cloudflare.name": "Cloudflare",
"connectors.cloudflare.requirements": "需要用户或账户 API Token;按所需资源授予权限,部署和配置变更需明确任务授权。",
"connectors.drawio.description": "生成可编辑的架构图、流程图和关系图。",
"connectors.drawio.example": "把这段系统说明整理成可编辑的 draw.io 架构图。",
"connectors.drawio.name": "draw.io 图表",
"connectors.drawio.requirements": "可直接编写 .drawio XML 文件;Mermaid 转换、自动布局及图片导出需要 draw.io 桌面版 CLI。安装技能不会安装桌面应用。",
"connectors.firecrawl.description": "搜索网页、提取页面并整理研究资料。",
"connectors.firecrawl.example": "搜索关于浏览器自动化的技术资料并保留来源。",
"connectors.firecrawl.name": "Firecrawl",
"connectors.firecrawl.requirements": "需要 Firecrawl API Key;调用按账户额度计费,密钥通过安全请求头发送。",
"connectors.frontend-design.description": "为网页和应用编写具有清晰视觉层次的前端界面。",
"connectors.frontend-design.example": "为我的知识管理应用设计并实现首页。",
"connectors.frontend-design.name": "前端界面设计",
"connectors.frontend-design.requirements": "安装 Anthropic 的前端设计技能。预览与构建需要目标项目的前端依赖和运行环境;本技能不提供托管或图片生成服务。",
"connectors.hyperframes.description": "用 HTML 和动画代码制作视频、字幕与产品演示。",
"connectors.hyperframes.example": "用 HyperFrames 为产品制作一段 20 秒的介绍视频。",
"connectors.hyperframes.name": "HyperFrames",
"connectors.hyperframes.requirements": "安装固定版本的 5 项完整技能与参考资料,不安装渲染器。实际渲染需要 Node.js 22+、HyperFrames CLI 与 FFmpeg;语音和在线素材服务可能需要单独配置及付费。",
"connectors.intercom.description": "检索客户对话、联系人和帮助中心资料。",
"connectors.intercom.example": "查找最近客户反馈中的常见问题并汇总。",
"connectors.intercom.name": "Intercom",
"connectors.intercom.requirements": "目前仅支持美国托管工作区;需要具有联系人、对话和文章权限的访问令牌。",
"connectors.jufa.description": "检索司法案例,辅助类案研究与裁判观点整理。",
"connectors.jufa.example": "检索建设工程合同纠纷案例,整理案号、法院及裁判观点并注明出处。",
"connectors.jufa.name": "聚法案例",
"connectors.jufa.requirements": "需要聚法智能体数据平台 API Key;本连接器接入案例研究服务,法规等其他专题需另行接入,数据受套餐与调用额度限制。",
"connectors.kuaicha.description": "检索企业工商、经营和风险资料,辅助主体核验。",
"connectors.kuaicha.example": "查询我指定企业的工商登记与经营异常信息,并区分已查到和缺失的数据。",
"connectors.kuaicha.name": "同花顺快查",
"connectors.kuaicha.requirements": "需要快查开放平台 API Key;服务仅限大陆 IP 访问,查询受账号额度与权限限制。",
"connectors.listinggood.description": "分析亚马逊商品文案,辅助合规检查和内容优化。",
"connectors.listinggood.example": "检查我提供的亚马逊商品文案,给出合规与可读性改进建议。",
"connectors.listinggood.name": "ListingGood",
"connectors.listinggood.requirements": "需要 ListingGood 国内站 API Key;国内站与海外站密钥不通用,付费工具按账号额度执行。",
"connectors.mcp-builder.description": "构建与评估 MCP 服务,提供 TypeScript 和 Python 参考。",
"connectors.mcp-builder.example": "为我们的内部知识库设计一个 MCP 服务。",
"connectors.mcp-builder.name": "MCP 开发工具包",
"connectors.mcp-builder.requirements": "包含协议设计参考和评估脚本。开发需 Node.js 或 Python 及 MCP SDK;模型评估、第三方 API 使用需用户自行配置凭据,可能产生费用。",
"connectors.miro.description": "查阅协作白板,整理设计与讨论内容。",
"connectors.miro.example": "总结我指定 Miro 白板中的主要想法和待办。",
"connectors.miro.name": "Miro",
"connectors.miro.requirements": "需要 Miro 账号和白板访问权限,组织管理员可能需要启用 MCP。",
"connectors.neon.description": "查看数据库项目、分支与开发数据结构。",
"connectors.neon.example": "查看 Neon 开发项目的数据库结构,不修改数据。",
"connectors.neon.name": "Neon",
"connectors.neon.requirements": "需要 Neon API Key 和项目权限;官方建议优先用于开发和测试数据库。",
"connectors.obsidian.description": "编辑 Markdown 笔记、Canvas 画布与 Bases 数据视图。",
"connectors.obsidian.example": "把这些项目笔记整理为 Obsidian Canvas 关系图。",
"connectors.obsidian.name": "Obsidian 工具包",
"connectors.obsidian.requirements": "本地文件编辑无需云端账号。CLI 操作需要安装并运行 Obsidian;网页内容提取等可选功能需要相应 CLI。本安装仅提供技能文件。",
"connectors.postman.description": "查询 API 工作区、集合与开发资料。",
"connectors.postman.example": "查找 Postman 工作区中与用户登录相关的 API。",
"connectors.postman.name": "Postman",
"connectors.postman.requirements": "使用美国区域 Postman 账号浏览器授权;此条目采用精简工具集,欧盟区域需单独 API Key 配置。",
"connectors.qcc.description": "检索企业主体信息,核验工商登记与关联资料。",
"connectors.qcc.example": "查询我指定企业的工商登记信息,并列出来源。",
"connectors.qcc.name": "企查查",
"connectors.qcc.requirements": "需要企查查 Agent 平台 API Key;本连接器仅接入企业基础信息服务,其他专题需另行接入,查询受账号权限与积分限制。",
"connectors.qingflow.description": "查询轻流工作区应用与业务数据,辅助流程协作。",
"connectors.qingflow.example": "查看轻流工作区中我有权限的应用,并整理待处理事项。",
"connectors.qingflow.name": "轻流",
"connectors.qingflow.requirements": "需要轻流账号,通过浏览器授权;可访问范围继承工作区和应用权限,具体能力受套餐限制。",
"connectors.qveris.description": "发现和查阅可调用的数据工具,辅助信息研究。",
"connectors.qveris.example": "查找 QVeris 中适合企业资料检索的工具,并说明所需参数和费用条件,暂不执行。",
"connectors.qveris.name": "QVeris",
"connectors.qveris.requirements": "需要 QVeris API Key;工具范围与调用费用由服务商决定,更换密钥后需重新建立连接。",
"connectors.render.description": "查看部署服务、数据库、运行日志与指标。",
"connectors.render.example": "查看 Render 服务最近的运行日志并总结异常。",
"connectors.render.name": "Render",
"connectors.render.requirements": "需要 Render API Key;先指定工作区。密钥可访问本人所属工作区,部署等写操作需明确任务授权。",
"connectors.sorftime.description": "查询跨境电商市场、商品与关键词数据。",
"connectors.sorftime.example": "查询我指定商品的市场趋势并注明站点、时间范围与数据来源。",
"connectors.sorftime.name": "Sorftime",
"connectors.sorftime.requirements": "需要开通 Sorftime MCP 服务并获取 Account-SK;平台、站点及查询次数受所购服务与额度限制。",
"connectors.todoist.description": "查看待办、项目和个人任务安排。",
"connectors.todoist.example": "列出 Todoist 中今天到期和已经逾期的任务。",
"connectors.todoist.name": "Todoist",
"connectors.todoist.requirements": "需要 Todoist 账号,通过浏览器授权任务和项目访问权限。",
"connectors.variflight.description": "查询航班动态、机场信息与航旅行程。",
"connectors.variflight.example": "查询我指定航班的计划时间与当前动态,并注明查询时间。",
"connectors.variflight.name": "飞常准",
"connectors.variflight.requirements": "需要在飞常准 MCP 开放平台登录并生成 MCP Key;个人行程须有相应账号权限,数据与调用额度按服务商规定提供。",
"connectors.webapp-testing.description": "使用 Playwright 检查本地网页、交互行为与截图。",
"connectors.webapp-testing.example": "检查本地应用的登录表单校验和页面布局。",
"connectors.webapp-testing.name": "网页测试工具包",
"connectors.webapp-testing.requirements": "包含测试技能、Python 辅助脚本及示例。实际运行需要 Python、Playwright 和浏览器,以及待测应用;安装不会自动下载浏览器。",
"connectors.webflow.description": "读取网站结构、CMS 内容与设计资料。",
"connectors.webflow.example": "查看我的 Webflow 网站中的 CMS 集合和字段。",
"connectors.webflow.name": "Webflow",
"connectors.webflow.requirements": "需要至少一个可访问站点的 Webflow 账号;浏览器授权自动安装 MCP Bridge App,设计器操作需桥接应用连接。",
"connectors.xmind.description": "读取与整理在线思维导图,辅助规划和知识归纳。",
"connectors.xmind.example": "列出 Xmind 最近打开的在线思维导图,读取我选择的导图并总结。",
"connectors.xmind.name": "Xmind",
"connectors.xmind.requirements": "需要 Xmind 国内版账号,通过浏览器授权;仅访问已授权团队的在线导图,编辑能力受账号套餐限制。",
"connectors.toolsRemoveInfo": "移除这个工具插件及其技能文件。已有任务和产出文件会保留。",
"connectors.collection": "目录类型",
"connectors.services": "服务连接器",
"connectors.tools": "工具插件",
"connectors.region": "服务地区",
"connectors.region.all": "全部地区",
"connectors.region.china": "国内服务",
"connectors.region.global": "全球服务",
"connectors.toolKind": "工具插件",
"connectors.toolsSubtitle": "为任务添加专用技能与工作方法。",
"connectors.skillsLoaded": "技能已加载",
"connectors.skillsPending": "技能待加载",
"connectors.verifySkills": "验证技能",
"connectors.skillsRuntimeInfo": "已添加的是技能包。任务所需的 Node.js、命令行工具或其他运行环境仍需单独满足;技能加载成功不代表这些环境已安装。",
"connectors.saveAndVerify": "保存并验证",
"connectors.needsService": "待连接服务",
"connectors.tencent-docs.name": "腾讯文档",
"connectors.tencent-docs.description": "读取和整理在线文档、表格与协作资料。",
"connectors.tencent-docs.example": "查找腾讯文档中与项目计划相关的资料。",
"connectors.tencent-docs.requirements": "需要腾讯文档 MCP 访问令牌;仅可访问账号已有权限的文档。",
"connectors.jinshuju.name": "金数据",
"connectors.jinshuju.description": "查询表单与收集的数据,辅助统计和整理。",
"connectors.jinshuju.example": "查看金数据中最近收集的问卷结果并整理摘要。",
"connectors.jinshuju.requirements": "需要金数据账号,通过浏览器授权表单与数据访问权限。",
"connectors.pkulaw.name": "北大法宝",
"connectors.pkulaw.description": "检索法律法规,追溯法律条文和原始出处。",
"connectors.pkulaw.example": "检索与劳动合同试用期相关的法律条文并注明出处。",
"connectors.pkulaw.requirements": "需要北大法宝 MCP access token 及相应资料权限;检索结果需核对现行有效性。",
"connectors.datayes.name": "通联数据",
"connectors.datayes.description": "查询股票行情与金融市场数据,辅助研究。",
"connectors.datayes.example": "查询我指定股票的历史行情并整理数据来源。",
"connectors.datayes.requirements": "需要通联数据 MCP Token;数据范围受账号权限和额度限制,企业账户需按服务商要求核验。",
"connectors.tencent-search.name": "腾讯云智能搜索",
"connectors.tencent-search.description": "检索网络资料,为研究提供可追溯信息。",
"connectors.tencent-search.example": "搜索国内低空经济产业发展资料,并整理官方来源。",
"connectors.tencent-search.requirements": "需要开通腾讯云 WSA 服务并获取 API Key,调用按账号额度与计费规则执行。",
"connectors.amap.name": "高德地图",
"connectors.amap.description": "搜索地点、规划路线,查询天气与出行信息。",
"connectors.amap.example": "帮我规划从杭州东站到西湖的公共交通路线。",
"connectors.amap.requirements": "需要高德开放平台 Web 服务 API Key;地图服务按账号权限和配额提供。",
"connectors.baidu-maps.name": "百度地图",
"connectors.baidu-maps.description": "地址解析、周边检索与路线规划。",
"connectors.baidu-maps.example": "查询上海虹桥站附近的酒店和交通方式。",
"connectors.baidu-maps.requirements": "需要百度地图开放平台服务器端 AK 及对应服务权限。",
"connectors.tencent-maps.name": "腾讯地图",
"connectors.tencent-maps.description": "查询位置、距离和路线,辅助出行安排。",
"connectors.tencent-maps.example": "比较广州南站到广州塔的公共交通与驾车路线。",
"connectors.tencent-maps.requirements": "需要腾讯位置服务 Key,并开通 WebServiceAPI 权限与配额。",
"connectors.zhipu-search.name": "智谱联网搜索",
"connectors.zhipu-search.description": "检索中文网页、新闻与实时信息,保留来源。",
"connectors.zhipu-search.example": "搜索近期国内新能源汽车政策,并列出原始来源。",
"connectors.zhipu-search.requirements": "需要 GLM Coding Plan 套餐 API Key;团队套餐密钥与平台通用密钥不通用。",
"connectors.notion.name": "Notion",
"connectors.notion.description": "搜索工作区资料,整理页面与项目知识。",
"connectors.notion.example": "查找 Notion 中最近的项目周报并总结进展。",
"connectors.notion.requirements": "需要 Notion 账号,浏览器授权后仅能访问该账号允许的工作区内容。",
"connectors.linear.name": "Linear",
"connectors.linear.description": "查询问题、项目与迭代,跟进团队任务。",
"connectors.linear.example": "列出 Linear 中分配给我的未完成问题。",
"connectors.linear.requirements": "需要 Linear 工作区账号及相应项目访问权限,通过浏览器授权。",
"connectors.github.name": "GitHub",
"connectors.github.description": "检索仓库代码、Issue 和 Pull Request。",
"connectors.github.example": "查看我的 GitHub 仓库中等待评审的 Pull Request。",
"connectors.github.requirements": "需要 GitHub Personal Access Token;按所需仓库与操作授予权限,组织策略仍然适用。",
"connectors.context7.name": "Context7",
"connectors.context7.description": "查阅软件库的最新文档与用法示例。",
"connectors.context7.example": "查找 React useEffect 的官方文档和清理函数示例。",
"connectors.context7.requirements": "使用官方匿名访问额度;不保证无限调用,额度不足时服务会返回限制。",
"connectors.sentry.name": "Sentry",
"connectors.sentry.description": "查看异常、追踪问题与应用运行信息。",
"connectors.sentry.example": "总结 Sentry 中最近一天新出现的错误。",
"connectors.sentry.requirements": "需要 Sentry 账号及组织、项目权限,通过浏览器授权。",
"connectors.tavily.name": "Tavily",
"connectors.tavily.description": "搜索网页、提取内容,支持资料研究。",
"connectors.tavily.example": "研究开源向量数据库的主要差异,并给出来源。",
"connectors.tavily.requirements": "需要 Tavily API Key,调用按该账号订阅与额度计费。",
"connectors.exa.name": "Exa",
"connectors.exa.description": "搜索网页与代码资料,发现相关信息。",
"connectors.exa.example": "寻找关于分布式系统可观测性的技术文章。",
"connectors.exa.requirements": "使用官方基础匿名访问额度;可用工具和次数由服务商决定。",
"connectors.supabase.name": "Supabase",
"connectors.supabase.description": "查看项目、数据库结构和开发资料。",
"connectors.supabase.example": "查看 Supabase 项目的数据表结构,不修改数据。",
"connectors.supabase.requirements": "需要 Supabase 账号及目标组织、项目权限;数据库写操作仍需明确任务授权。",
"connectors.huggingface.name": "Hugging Face",
"connectors.huggingface.description": "发现模型、数据集与机器学习研究资源。",
"connectors.huggingface.example": "寻找适合中文文本分类的公开模型与数据集。",
"connectors.huggingface.requirements": "需要 Hugging Face 访问令牌;检索建议仅授予 read 权限,计算任务可能另行收费。",
"connectors.canva.name": "Canva",
"connectors.canva.description": "搜索和整理设计素材,协作处理设计内容。",
"connectors.canva.example": "查找 Canva 中最近的品牌设计素材。",
"connectors.canva.requirements": "每位用户使用自己的 Canva 账号授权;部分能力受订阅和团队权限限制。",
"connectors.figma.name": "Figma",
"connectors.figma.description": "读取设计上下文,理解组件与界面结构。",
"connectors.figma.example": "分析我提供的 Figma 设计链接中的组件结构。",
"connectors.figma.requirements": "需要 Figma 账号授权;设计访问权限、席位和套餐决定可用工具及次数。",
"connectors.stripe.name": "Stripe",
"connectors.stripe.description": "查询客户、账单和支付业务资料。",
"connectors.stripe.example": "查看 Stripe 中最近的账单概况,不创建支付或退款。",
"connectors.stripe.requirements": "需要 Stripe 账号授权。该官方服务为预览版;付款、退款等写操作需要明确任务授权。",
"connectors.category.legal": "法律检索",
"connectors.connectService": "连接服务",
"connectors.officialMcp": "官方 MCP 服务",
"connectors.categories": "服务分类",
"connectors.allCategories": "精选",
"connectors.category.office": "办公协作",
"connectors.category.development": "开发工具",
"connectors.category.search": "搜索",
"connectors.category.maps": "地图出行",
"connectors.category.data": "数据分析",
"connectors.category.design": "设计创作",
"connectors.category.productivity": "效率工具",
"connectors.category.finance": "金融",
"connectors.kind": "连接器",
"connectors.loadFailed": "暂时无法完成操作",
"connectors.emptyAdded": "添加常用服务后,就能在这里管理账号与连接状态。",
"connectors.emptySearch": "换个关键词,或查看其他分类。",
"connectors.resetFilters": "查看全部",
"connectors.configuration": "连接配置",
"connectors.configurationHint": "留空沿用已保存配置。填写新配置后,点击“保存并验证”验证连接。",
"connectors.fieldRequired": "请填写此项",
"connectors.capabilities": "支持的操作",
"connectors.remoteExample": "请使用 {name} 查询我可以访问的信息。",
"connectors.remoteRequirements": "请准备服务账号及所需访问权限;具体操作以服务商授予的权限为准。",
"connectors.phase.installing-plugin": "安装配套技能",
"connectors.phase.checking": "检查账号连接",
"connectors.phase.enabling-plugin": "启用配套技能",
"connectors.phase.refreshing-sessions": "刷新任务能力",
"connectors.phase.verifying-runtime": "检查本地运行环境",
"connectors.phase.persistence": "保存连接状态",
"connectors.phase.cancelling": "取消操作",
"connectors.feishu.requirements": "需要飞书用户账号及日历访问权限;实际能力取决于账号与应用获授的权限。",
"connectors.dingtalk.requirements": "需要钉钉 OAuth 登录及企业管理员授予日历权限;登录成功不代表具备所有服务权限。",
"connectors.wecom.requirements": "需要配置企业微信机器人或用户身份及日历权限;可用操作取决于所选身份获授的权限。",
"connectors.update": "更新",
"connectors.updateAvailable": "可更新至",
"connectors.currentAccount": "当前服务账号",
"connectors.phase.downloading": "下载运行文件",
"connectors.phase.extracting": "解压运行文件",
"connectors.phase.verifying": "校验运行文件",
"connectors.phase.awaiting-authorization": "等待账号授权",
"connectors.phase.configuring-account": "配置账号",
"connectors.phase.authorizing": "正在授权",
"connectors.phase.failed": "失败阶段",
"connectors.phase.skillsInfo": "安装后提供对应服务的操作技能。",
'connectors.status.configured': '本机已配置',
'connectors.localVerification': '首次任务调用时验证服务权限。',
"sidebar.connectors": "连接器",
"connectors.subtitle": "连接常用办公服务,让技能获得真实的服务能力。",
"connectors.filter": "筛选连接器",
"connectors.all": "全部",
"connectors.added": "已添加",
"connectors.search": "搜索连接器",
"connectors.clear": "清除搜索",
"connectors.retry": "重试",
"connectors.loading": "加载中…",
"connectors.empty": "没有匹配的连接器",
"connectors.details": "查看详情",
"connectors.start": "开始使用",
"connectors.account": "账号",
"connectors.noAccount": "尚未验证账号",
"connectors.platforms": "支持平台",
"connectors.source": "来源",
"connectors.version": "版本",
"connectors.skills": "配套技能插件",
"connectors.homepage": "项目主页",
"connectors.example": "示例",
"connectors.waiting": "正在处理,请完成授权后等待状态更新。",
"connectors.authorize": "打开授权页面",
"connectors.confirm": "确认操作",
"connectors.continue": "继续",
"connectors.shared": "此连接器会复用本机该 CLI 的账号凭据。连接或切换账号会影响其他使用同一 CLI 的应用,请确认后继续。",
"connectors.isolated": "账号凭据独立保存在本应用中。",
"connectors.removeInfo": "移除本应用管理的连接器,保留共享账号凭据。",
"connectors.invalidUrl": "授权链接格式不正确",
"connectors.openFailed": "无法打开链接,请重试",
"connectors.startFailed": "无法创建任务,请重试",
"connectors.unsupported": "当前平台不支持",
"connectors.action.prepare": "添加",
"connectors.action.authenticate": "连接账号",
"connectors.action.check": "检查连接",
"connectors.action.deactivate": "停用",
"connectors.action.remove": "移除",
"connectors.status.not-installed": "未添加",
"connectors.status.preparing": "准备中",
"connectors.status.needs-auth": "待连接账号",
"connectors.status.authorizing": "授权中",
"connectors.status.ready": "可用",
"connectors.status.disabled": "已停用",
"connectors.status.error": "需要处理",
"connectors.feishu.name": "飞书",
"connectors.dingtalk.name": "钉钉",
"connectors.wecom.name": "企业微信",
"connectors.feishu.description": "管理飞书文档、日历与办公协作。",
"connectors.dingtalk.description": "连接钉钉的日程与团队办公服务。",
"connectors.wecom.description": "使用企业微信开展团队协作。",
"connectors.feishu.example": "请列出我在飞书中今天的日程。",
"connectors.dingtalk.example": "请查询我在钉钉中今天的日程。",
"connectors.wecom.example": "请查看我的企业微信账号可用的功能。",
'settings.providers.compatibilityJsonHint': 'requestCompatibility 仅保存到当前供应商,不写入全局设置。',
'settings.providers.compatibilityBudget': '回复输出预算',
'settings.providers.compatibilityBudgetHint': '普通回复请求的最大输出 token 数。留空自动选择;后台短请求仍使用较小预算。',
+24 -1
View File
@@ -11,6 +11,13 @@
*/
export type ComposerMention = {
/** Missing kind is a legacy file reference. Drafts remain backwards compatible. */
kind?: 'skill' | 'plugin'
id?: string
description?: string
icon?: string
/** Server-authored invocation text; never serialize extensions as file paths. */
modelText?: string
/** Display label inside the token, e.g. `MediaCrawlerPro-Python/` for a directory. */
label: string
/** Absolute filesystem path sent to the model. */
@@ -46,7 +53,10 @@ export function mentionsEqual(a: ComposerMention[], b: ComposerMention[]): boole
return mention.label === other.label &&
mention.path === other.path &&
mention.isDirectory === other.isDirectory &&
mention.tokenOrdinal === other.tokenOrdinal
mention.tokenOrdinal === other.tokenOrdinal &&
mention.kind === other.kind && mention.id === other.id &&
mention.description === other.description && mention.icon === other.icon &&
mention.modelText === other.modelText
})
}
@@ -124,3 +134,16 @@ export function insertMentionIntoText(
cursorPos: before.length + insertion.length,
}
}
/** Only packaged icons are accepted in inline editor nodes and clipboard HTML. */
export function safeMentionIcon(icon?: string): string | undefined {
return icon && /^\/connectors\/[a-z0-9-]+\.svg$/.test(icon) ? icon : undefined
}
export function composerReferenceToMention(reference: import('../types/composerReference').ComposerReferenceCandidate): NewComposerMention {
return {
kind: reference.kind, id: reference.id, label: reference.displayName || reference.name,
description: reference.description, icon: safeMentionIcon(reference.icon),
modelText: reference.modelText, path: '', isDirectory: false,
}
}
@@ -28,6 +28,27 @@ describe('desktop persistence migrations', () => {
expect(window.localStorage.getItem(DESKTOP_PERSISTENCE_VERSION_KEY)).toBe(String(CURRENT_DESKTOP_PERSISTENCE_SCHEMA_VERSION))
})
test.each([undefined, 'session', 'connectors'])('preserves connector identities from legacy startup fixtures with type %s', (type) => {
window.localStorage.setItem('cc-haha-open-tabs', JSON.stringify({
openTabs: [
{ sessionId: 'session-1', title: 'Task' },
{ sessionId: '__connectors__', title: 'Connectors', ...(type ? { type } : {}) },
],
activeTabId: '__connectors__',
}))
runDesktopPersistenceMigrations()
const expected = {
openTabs: [
{ sessionId: 'session-1', title: 'Task', type: 'session' },
{ sessionId: '__market__', title: 'Connectors', type: 'market' },
],
activeTabId: '__market__',
}
expect(JSON.parse(window.localStorage.getItem('cc-haha-open-tabs')!)).toEqual(expected)
runDesktopPersistenceMigrations()
expect(JSON.parse(window.localStorage.getItem('cc-haha-open-tabs')!)).toEqual(expected)
})
test('preserves persisted market tabs during startup migration', () => {
window.localStorage.setItem('cc-haha-open-tabs', JSON.stringify({
openTabs: [
@@ -367,3 +388,20 @@ describe('desktop persistence migrations', () => {
})
})
test('merges legacy connector and skill market tabs while preserving the active market and unrelated user state', () => {
localStorage.clear()
localStorage.setItem(DESKTOP_PERSISTENCE_VERSION_KEY, '3')
localStorage.setItem('custom-user-state', JSON.stringify({ selectedSkill: 'my-skill' }))
localStorage.setItem('cc-haha-open-tabs', JSON.stringify({ openTabs: [
{ sessionId: 'session-1', title: 'Work', type: 'session' },
{ sessionId: '__market__', title: 'Skills', type: 'market' },
{ sessionId: '__connectors__', title: 'Connectors', type: 'connectors' },
], activeTabId: '__connectors__' }))
runDesktopPersistenceMigrations()
expect(JSON.parse(localStorage.getItem('cc-haha-open-tabs')!)).toEqual({ openTabs: [
{ sessionId: 'session-1', title: 'Work', type: 'session' },
{ sessionId: '__market__', title: 'Skills', type: 'market' },
], activeTabId: '__market__' })
expect(JSON.parse(localStorage.getItem('custom-user-state')!)).toEqual({ selectedSkill: 'my-skill' })
})
+12 -6
View File
@@ -10,7 +10,7 @@ import {
normalizeAppZoomLevel,
} from './appZoom'
export const CURRENT_DESKTOP_PERSISTENCE_SCHEMA_VERSION = 3
export const CURRENT_DESKTOP_PERSISTENCE_SCHEMA_VERSION = 4
export const DESKTOP_PERSISTENCE_VERSION_KEY = 'cc-haha.persistence.schemaVersion'
type DesktopMigrationReport = {
@@ -27,11 +27,12 @@ const LIGHT_THEME_STORAGE_KEY = 'cc-haha-light-theme'
const DARK_THEME_STORAGE_KEY = 'cc-haha-dark-theme'
const LOCALE_STORAGE_KEY = 'cc-haha-locale'
const EFFORT_LEVELS = ['low', 'medium', 'high', 'xhigh', 'max']
const PERSISTED_SPECIAL_TAB_TYPES = ['settings', 'scheduled', 'market', 'traces'] as const
const PERSISTED_SPECIAL_TAB_TYPES = ['settings', 'scheduled', 'market', 'connectors', 'traces'] as const
const PERSISTED_SPECIAL_TAB_IDS: Record<(typeof PERSISTED_SPECIAL_TAB_TYPES)[number], string> = {
settings: '__settings__',
scheduled: '__scheduled__',
market: '__market__',
connectors: '__connectors__',
traces: '__traces__',
}
const SUPPORTED_LOCALES = ['en', 'zh', 'zh-TW', 'jp', 'kr']
@@ -54,9 +55,10 @@ function isPersistedSpecialTabType(value: unknown): value is (typeof PERSISTED_S
function getPersistedSpecialTabType(tab: Record<string, unknown>): (typeof PERSISTED_SPECIAL_TAB_TYPES)[number] | null {
if (tab.sessionId === '__settings__') return 'settings'
if (tab.sessionId === '__scheduled__') return 'scheduled'
if (tab.sessionId === '__connectors__') return 'market'
if (tab.sessionId === '__market__') return 'market'
if (tab.sessionId === '__traces__') return 'traces'
return isPersistedSpecialTabType(tab.type) ? tab.type : null
return isPersistedSpecialTabType(tab.type) ? tab.type === 'connectors' ? 'market' : tab.type : null
}
function writeJson(storage: StorageLike, key: string, value: unknown): void {
@@ -86,11 +88,15 @@ function migrateTabs(storage: StorageLike, report: DesktopMigrationReport): void
type: specialType ?? 'session',
}
})
.filter((tab, index, tabs) => tabs.findIndex(other => other.sessionId === tab.sessionId) === index)
const legacyActive = isRecord(parsed) ? rawTabs.find(tab => isRecord(tab) && tab.sessionId === parsed.activeTabId) : undefined
const activeType = isRecord(legacyActive) ? getPersistedSpecialTabType(legacyActive) : null
const normalizedActive = activeType ? PERSISTED_SPECIAL_TAB_IDS[activeType] : isRecord(parsed) ? parsed.activeTabId : null
const activeTabId =
isRecord(parsed) &&
typeof parsed.activeTabId === 'string' &&
openTabs.some((tab) => tab.sessionId === parsed.activeTabId)
? parsed.activeTabId
typeof normalizedActive === 'string' &&
openTabs.some((tab) => tab.sessionId === normalizedActive)
? normalizedActive
: (openTabs[0]?.sessionId ?? null)
if (openTabs.length === 0) {
+393
View File
@@ -0,0 +1,393 @@
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
import '@testing-library/jest-dom'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { ALL_CONNECTORS } from '../../../src/services/connectors/catalog'
import { Connectors } from './Connectors'
import { useConnectorStore } from '@/stores/connectorStore'
import { en } from '@/i18n/locales/en'
import { zh } from '@/i18n/locales/zh'
import { jp } from '@/i18n/locales/jp'
import { kr } from '@/i18n/locales/kr'
import type { ConnectorDto } from '@/types/connector'
const mocks = vi.hoisted(() => ({ create: vi.fn(), draft: vi.fn(), open: vi.fn(), active: vi.fn(), external: vi.fn(), translations: null as Record<string, string> | null }))
vi.mock('@/i18n', () => ({ useTranslation: () => (key: string) => mocks.translations?.[key] ?? key }))
vi.mock('@/stores/sessionStore', () => ({ useSessionStore: { getState: () => ({ createSession: mocks.create, setActiveSession: mocks.active }) } }))
vi.mock('@/stores/chatStore', () => ({ useChatStore: { getState: () => ({ setComposerDraft: mocks.draft }) } }))
vi.mock('@/stores/tabStore', () => ({ useTabStore: { getState: () => ({ openTab: mocks.open }) } }))
vi.mock('@/lib/desktopHost', () => ({ getDesktopHost: () => ({ shell: { open: mocks.external } }) }))
const item: ConnectorDto = { id: 'feishu', pluginId: 'feishu', packageName: 'feishu-cli', version: '1', homepage: 'https://example.test', credentialMode: 'shared', platforms: ['darwin-arm64'], supported: true, installed: true, enabled: true, connection: 'needs-auth', runtime: 'missing', status: 'needs-auth' }
const refresh = vi.fn(async () => {})
const perform = vi.fn(async () => {})
beforeEach(() => { vi.clearAllMocks(); mocks.translations = null; useConnectorStore.setState({ items: [item], loading: false, error: null, pending: {}, refresh, act: perform }) })
afterEach(() => { cleanup(); vi.useRealTimers() })
describe('Connectors', () => {
it('explains shared credentials and requires confirmation before account connection', () => {
render(<Connectors />)
fireEvent.click(screen.getByRole('button', { name: 'connectors.details' }))
fireEvent.click(screen.getByRole('button', { name: 'connectors.action.authenticate' }))
expect(perform).not.toHaveBeenCalled()
expect(screen.getByRole('dialog')).toHaveTextContent('connectors.shared')
fireEvent.click(screen.getByRole('button', { name: 'connectors.continue' }))
expect(perform).toHaveBeenCalledWith('feishu', 'authenticate', { acknowledgeSharedCredentials: true })
})
it('polls while operations run and stops on unmount', async () => {
vi.useFakeTimers()
useConnectorStore.setState({ items: [{ ...item, operation: { id: 'op', kind: 'authenticate', phase: 'waiting', startedAt: '' } }] })
const view = render(<Connectors />)
expect(refresh).toHaveBeenCalledTimes(1)
await act(async () => { await vi.advanceTimersByTimeAsync(3000) })
expect(refresh).toHaveBeenCalledTimes(3)
view.unmount()
await act(async () => { await vi.advanceTimersByTimeAsync(3000) })
expect(refresh).toHaveBeenCalledTimes(3)
})
it('creates an ordinary task with an unsent draft', async () => {
useConnectorStore.setState({ items: [{ ...item, status: 'ready', connection: 'connected' }] })
mocks.create.mockResolvedValue('new-task')
render(<Connectors />)
fireEvent.click(screen.getByRole('button', { name: 'connectors.details' }))
await act(async () => { fireEvent.click(screen.getByRole('button', { name: 'connectors.start' })) })
expect(mocks.draft).toHaveBeenCalledWith('new-task', { input: 'connectors.feishu.example', attachments: [] })
expect(mocks.open).toHaveBeenCalledWith('new-task', 'sidebar.newSession')
})
})
it('keeps local configuration distinct from service readiness and still allows a draft', () => {
useConnectorStore.setState({ items: [{ ...item, status: 'configured', verification: 'local' }] })
render(<Connectors />)
expect(screen.getByText('connectors.status.configured')).toBeInTheDocument()
expect(screen.queryByText('connectors.status.ready')).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'connectors.details' }))
expect(screen.getByText('connectors.localVerification')).toBeInTheDocument()
})
it('cancels pending authorization without claiming success or opening unsafe URLs', () => {
useConnectorStore.setState({ items: [{ ...item, status: 'authorizing', operation: { id: 'op', kind: 'authenticate', startedAt: '', phase: 'waiting', authUrl: 'javascript:alert(1)' } }] })
render(<Connectors />)
fireEvent.click(screen.getByRole('button', { name: 'connectors.details' }))
fireEvent.click(screen.getByRole('button', { name: 'connectors.authorize' }))
expect(mocks.external).not.toHaveBeenCalled()
expect(screen.getByRole('alert')).toHaveTextContent('connectors.invalidUrl')
fireEvent.click(screen.getAllByRole('button', { name: 'common.cancel' }).at(-1)!)
expect(perform).toHaveBeenCalledWith('feishu', 'cancel')
})
it('filters by added state and keeps a local fallback when a logo cannot load', () => {
useConnectorStore.setState({ items: [{ ...item, installed: false }] })
const view = render(<Connectors />)
fireEvent.error(view.container.querySelector('img')!)
expect(view.container.querySelector('img')).toBeNull()
fireEvent.click(screen.getByRole('button', { name: 'extensions.filter' }))
fireEvent.click(screen.getByRole('checkbox', { name: 'connectors.added' }))
expect(screen.queryByText('connectors.feishu.name')).not.toBeInTheDocument()
expect(screen.getByText('extensions.empty')).toBeInTheDocument()
})
it('shows installed and available versions and labels a connected account without a display name', () => {
useConnectorStore.setState({ items: [{ ...item, status: 'ready', connection: 'connected', installedVersion: '0.9', version: '1.2', updateAvailable: true }] })
render(<Connectors />)
fireEvent.click(screen.getByRole('button', { name: 'connectors.details' }))
expect(screen.getByRole('button', { name: 'connectors.update' })).toBeInTheDocument()
expect(screen.getByText('0.9')).toBeInTheDocument()
expect(screen.getByText('connectors.updateAvailable: 1.2')).toBeInTheDocument()
expect(screen.getByText('connectors.currentAccount')).toBeInTheDocument()
expect(screen.queryByText('connectors.noAccount')).not.toBeInTheDocument()
})
it('retains the catalog version for older DTOs without installedVersion', () => {
render(<Connectors />)
fireEvent.click(screen.getByRole('button', { name: 'connectors.details' }))
expect(screen.getByText('1')).toBeInTheDocument()
expect(screen.getByText('connectors.noAccount')).toBeInTheDocument()
})
it.each(['installing-plugin', 'checking', 'enabling-plugin', 'refreshing-sessions', 'verifying-runtime', 'persistence', 'cancelling'])('shows the concrete %s stage in progress and failures', (phase) => {
useConnectorStore.setState({ items: [{ ...item, error: 'failed', failedPhase: phase, operation: { id: 'op', kind: 'check', startedAt: '', phase } }] })
render(<Connectors />)
fireEvent.click(screen.getByRole('button', { name: 'connectors.details' }))
expect(screen.getByRole('status')).toHaveTextContent(`connectors.phase.${phase}`)
expect(screen.getByRole('alert')).toHaveTextContent(`connectors.phase.${phase}`)
})
it.each(['feishu', 'dingtalk', 'wecom'] as const)('shows service-specific account requirements for %s', (id) => {
useConnectorStore.setState({ items: [{ ...item, id }] })
render(<Connectors />)
fireEvent.click(screen.getByRole('button', { name: 'connectors.details' }))
expect(screen.getByText(`connectors.${id}.requirements`)).toBeInTheDocument()
})
const remote: ConnectorDto = { ...item, id: 'remote-search', displayName: 'Remote Search', description: 'Search project documentation', category: 'search', capabilities: ['Search documents'], transport: 'mcp', credentialMode: 'isolated', setupFields: [{ key: 'apiKey', label: 'API key', secret: true }, { key: 'workspace', label: 'Workspace' }] }
it('filters remote catalog metadata by category and search', () => {
useConnectorStore.setState({ items: [item, remote] })
render(<Connectors />)
fireEvent.click(screen.getByRole('button', { name: 'extensions.filter' }))
fireEvent.change(screen.getByRole('combobox', { name: 'extensions.categories' }), { target: { value: 'search' } })
expect(screen.getByRole('button', { name: 'Remote Search' })).toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'connectors.feishu.name' })).not.toBeInTheDocument()
fireEvent.change(screen.getByRole('searchbox'), { target: { value: 'absent' } })
expect(screen.getByText('extensions.empty')).toBeInTheDocument()
})
it('validates partially entered remote configuration and sends it only on the explicit connect action', () => {
useConnectorStore.setState({ items: [remote] })
render(<Connectors />)
fireEvent.click(screen.getByRole('button', { name: 'Remote Search' }))
expect(screen.getByLabelText('API key')).toHaveAttribute('type', 'password')
fireEvent.change(screen.getByLabelText('API key'), { target: { value: 'fake-key' } })
fireEvent.click(screen.getByRole('button', { name: 'connectors.saveAndVerify' }))
expect(perform).not.toHaveBeenCalled()
expect(screen.getByLabelText('Workspace')).toHaveAttribute('aria-invalid', 'true')
expect(screen.getAllByText('connectors.fieldRequired')).toHaveLength(1)
// A rejected submission reports the missing field, never the entered secret.
expect(screen.getByRole('dialog')).not.toHaveTextContent('fake-key')
fireEvent.change(screen.getByLabelText('Workspace'), { target: { value: 'test-space' } })
fireEvent.click(screen.getByRole('button', { name: 'connectors.saveAndVerify' }))
expect(perform).toHaveBeenCalledWith('remote-search', 'authenticate', { configuration: { apiKey: 'fake-key', workspace: 'test-space' } })
expect(screen.getByLabelText('API key')).toHaveValue('')
})
it('requires every setup field before connecting a connector that has no stored credential', () => {
useConnectorStore.setState({ items: [remote] })
render(<Connectors />)
fireEvent.click(screen.getByRole('button', { name: 'Remote Search' }))
fireEvent.click(screen.getByRole('button', { name: 'connectors.connectService' }))
expect(perform).not.toHaveBeenCalled()
expect(screen.getByLabelText('API key')).toHaveAttribute('aria-invalid', 'true')
expect(screen.getByLabelText('Workspace')).toHaveAttribute('aria-invalid', 'true')
expect(screen.getAllByText('connectors.fieldRequired')).toHaveLength(2)
})
it('does not erase stored credentials when a verified connector is connected with an untouched form', () => {
useConnectorStore.setState({ items: [{ ...remote, status: 'error', failedPhase: 'authorizing', connection: 'connected', verification: 'remote' }] })
render(<Connectors />)
fireEvent.click(screen.getByRole('button', { name: 'Remote Search' }))
fireEvent.click(screen.getByRole('button', { name: 'connectors.connectService' }))
expect(perform).toHaveBeenCalledWith('remote-search', 'authenticate', {})
})
it('still requires every setup field when a stored credential is being replaced', () => {
useConnectorStore.setState({ items: [{ ...remote, status: 'error', failedPhase: 'authorizing', connection: 'connected', verification: 'remote' }] })
render(<Connectors />)
fireEvent.click(screen.getByRole('button', { name: 'Remote Search' }))
fireEvent.change(screen.getByLabelText('API key'), { target: { value: 'rotated-key' } })
fireEvent.click(screen.getByRole('button', { name: 'connectors.saveAndVerify' }))
expect(perform).not.toHaveBeenCalled()
expect(screen.getByLabelText('Workspace')).toHaveAttribute('aria-invalid', 'true')
})
it('opens configuration details from add without automatically sending credentials', () => {
useConnectorStore.setState({ items: [{ ...remote, installed: false, status: 'not-installed' }] })
render(<Connectors />)
fireEvent.click(screen.getByRole('button', { name: 'connectors.action.prepare' }))
expect(screen.getByRole('dialog')).toBeInTheDocument()
expect(perform).not.toHaveBeenCalled()
fireEvent.click(screen.getAllByRole('button', { name: 'connectors.action.prepare' }).at(-1)!)
expect(perform).toHaveBeenCalledWith('remote-search', 'prepare', {})
})
it('renders a stable skeleton instead of empty catalog while loading', () => {
useConnectorStore.setState({ items: [], loading: true })
render(<Connectors />)
expect(screen.getByRole('status', { name: 'connectors.loading' })).toBeInTheDocument()
expect(screen.queryByText('extensions.empty')).not.toBeInTheDocument()
})
it('switches remote catalog copy and draft examples with the UI language without leaking Chinese metadata', async () => {
const localized = { ...remote, id: 'amap', displayName: zh['connectors.amap.name'], description: zh['connectors.amap.description'], requirements: zh['connectors.amap.requirements'], example: zh['connectors.amap.example'], capabilities: [zh['connectors.amap.description']], status: 'ready' as const, connection: 'connected' as const }
useConnectorStore.setState({ items: [localized] })
mocks.translations = en
const view = render(<Connectors />)
expect(screen.getByText(en['connectors.amap.description'])).toBeInTheDocument()
expect(screen.queryByText(zh['connectors.amap.description'])).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: en['connectors.amap.name'] }))
expect(screen.getByText(en['connectors.amap.requirements'])).toBeInTheDocument()
expect(screen.getByText(en['connectors.officialMcp'])).toBeInTheDocument()
expect(screen.queryByText(en['connectors.capabilities'])).not.toBeInTheDocument()
mocks.translations = jp
view.rerender(<Connectors />)
expect(screen.getByText(jp['connectors.amap.requirements'])).toBeInTheDocument()
expect(screen.queryByText(en['connectors.amap.requirements'])).not.toBeInTheDocument()
mocks.translations = kr
view.rerender(<Connectors />)
expect(screen.getByText(kr['connectors.amap.requirements'])).toBeInTheDocument()
mocks.create.mockResolvedValue('translated-draft')
await act(async () => { fireEvent.click(screen.getByRole('button', { name: kr['connectors.start'] })) })
expect(mocks.draft).toHaveBeenCalledWith('translated-draft', { input: kr['connectors.amap.example'], attachments: [] })
})
it.each(['ready', 'disabled', 'error'] as const)('rotates configured credentials from %s by authenticating rather than checking stale settings', (status) => {
useConnectorStore.setState({ items: [{ ...remote, status, connection: 'connected', failedPhase: status === 'error' ? 'checking' : undefined }] })
render(<Connectors />)
fireEvent.click(screen.getByRole('button', { name: 'Remote Search' }))
expect(screen.getByRole('button', { name: 'connectors.saveAndVerify' })).toBeDisabled()
fireEvent.change(screen.getByLabelText('API key'), { target: { value: 'rotated-key' } })
fireEvent.change(screen.getByLabelText('Workspace'), { target: { value: 'workspace' } })
fireEvent.click(screen.getByRole('button', { name: 'connectors.saveAndVerify' }))
expect(perform).toHaveBeenCalledWith('remote-search', 'authenticate', { configuration: { apiKey: 'rotated-key', workspace: 'workspace' } })
})
it('labels remote authorization as a service connection while retaining account wording for native connectors', () => {
useConnectorStore.setState({ items: [remote, item] })
render(<Connectors />)
expect(screen.getByText('connectors.needsService')).toBeInTheDocument()
expect(screen.getByText('connectors.status.needs-auth')).toBeInTheDocument()
})
it('separates tools from services and filters domestic and global services without hiding native legacy DTOs', () => {
const tool = { ...item, id: 'test-skill', displayName: 'Test skill', collection: 'tools' as const, transport: 'skills' as const, region: 'global' as const }
useConnectorStore.setState({ items: [item, { ...remote, region: 'global' }, tool] })
const view = render(<Connectors />)
expect(screen.queryByRole('button', { name: 'Test skill' })).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'extensions.filter' }))
fireEvent.change(screen.getByRole('combobox', { name: 'connectors.region' }), { target: { value: 'china' } })
expect(screen.getByRole('button', { name: 'connectors.feishu.name' })).toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Remote Search' })).not.toBeInTheDocument()
fireEvent.change(screen.getByRole('combobox', { name: 'connectors.region' }), { target: { value: 'global' } })
expect(screen.queryByRole('button', { name: 'connectors.feishu.name' })).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Remote Search' })).toBeInTheDocument()
view.rerender(<Connectors mode="skills" />)
expect(screen.getByRole('button', { name: 'Test skill' })).toBeInTheDocument()
expect(screen.queryByRole('combobox', { name: 'connectors.region' })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Remote Search' })).not.toBeInTheDocument()
})
it('describes installed skill bundles without account or runtime readiness claims and checks without credential consent', () => {
useConnectorStore.setState({ items: [{ ...item, id: 'test-skill', displayName: 'Test skill', collection: 'tools', transport: 'skills', setupFields: [{ key: 'bad', label: 'Should not appear' }] }] })
render(<Connectors mode="skills" />)
fireEvent.click(screen.getByRole('button', { name: 'Test skill' }))
expect(screen.getByRole('dialog')).toHaveTextContent('connectors.skillsPending')
expect(screen.getByText('connectors.skillsRuntimeInfo')).toBeInTheDocument()
expect(screen.queryByText('connectors.account')).not.toBeInTheDocument()
expect(screen.queryByText('connectors.shared')).not.toBeInTheDocument()
expect(screen.queryByLabelText('Should not appear')).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'connectors.action.authenticate' })).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'connectors.verifySkills' }))
expect(perform).toHaveBeenCalledWith('test-skill', 'check', {})
})
it('shows loaded skills and prepares an unsent ordinary task for tool plugins', async () => {
useConnectorStore.setState({ items: [{ ...item, id: 'test-skill', displayName: 'Test skill', example: 'Use this skill', collection: 'tools', transport: 'skills', status: 'configured', enabled: true }] })
mocks.create.mockResolvedValue('skill-task')
render(<Connectors mode="skills" />)
expect(screen.getByText('connectors.skillsLoaded')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'Test skill' }))
expect(screen.queryByText('connectors.localVerification')).not.toBeInTheDocument()
await act(async () => { fireEvent.click(screen.getByRole('button', { name: 'connectors.start' })) })
expect(mocks.draft).toHaveBeenCalledWith('skill-task', { input: 'Use this skill', attachments: [] })
})
it('renders the complete catalog as 49 plugins and five pure skills without duplicated tool packages', () => {
useConnectorStore.setState({ items: ALL_CONNECTORS.map(definition => ({ ...definition, supported: true, installed: false, enabled: false, connection: 'disconnected', runtime: 'missing', status: 'not-installed' })) })
const view = render(<Connectors />)
const rows = () => view.container.querySelectorAll('img').length
expect(rows()).toBe(49)
fireEvent.click(screen.getByRole('button', { name: 'extensions.filter' }))
fireEvent.change(screen.getByRole('combobox', { name: 'connectors.region' }), { target: { value: 'china' } })
expect(rows()).toBe(22)
fireEvent.change(screen.getByRole('combobox', { name: 'connectors.region' }), { target: { value: 'global' } })
expect(rows()).toBe(24)
view.rerender(<Connectors mode="skills" />)
expect(rows()).toBe(5)
for (const category of ['development', 'design']) expect(screen.getByRole('option', { name: `connectors.category.${category}` })).toBeInTheDocument()
for (const category of ['office', 'search', 'maps', 'data', 'finance', 'legal', 'productivity']) expect(screen.queryByRole('option', { name: `connectors.category.${category}` })).not.toBeInTheDocument()
})
it('uses plugin language for the mixed market and omits redundant connector badges on service rows', () => {
render(<Connectors />)
expect(screen.getByRole('searchbox', { name: 'extensions.searchPlugins' })).toHaveAttribute('placeholder', 'extensions.searchPlugins')
expect(screen.queryByText('extensions.pluginsSubtitle')).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: 'extensions.filter' })).toHaveAttribute('aria-expanded', 'false')
expect(screen.queryByRole('radiogroup')).not.toBeInTheDocument()
expect(screen.queryByRole('combobox')).not.toBeInTheDocument()
expect(screen.queryByRole('checkbox')).not.toBeInTheDocument()
expect(screen.queryByText('connectors.kind')).not.toBeInTheDocument()
expect(screen.queryByText('connectors.subtitle')).not.toBeInTheDocument()
})
it('uses the shared skill query and installed filter without presenting a second search or filter bar', () => {
const skill = { ...item, id: 'test-skill', displayName: 'Selected skill', description: 'Create diagrams', collection: 'tools' as const, transport: 'skills' as const }
useConnectorStore.setState({ items: [skill, { ...skill, id: 'another-skill', displayName: 'Another skill', description: 'Write code', installed: false }] })
const view = render(<Connectors mode="skills" embedded externalQuery="diagrams" />)
expect(screen.getByRole('button', { name: 'Selected skill' })).toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Another skill' })).not.toBeInTheDocument()
expect(screen.queryByRole('searchbox')).not.toBeInTheDocument()
expect(screen.queryByRole('radiogroup')).not.toBeInTheDocument()
expect(screen.queryByText('connectors.toolKind')).not.toBeInTheDocument()
view.rerender(<Connectors mode="skills" embedded externalQuery="" installedFilter="installable" />)
expect(screen.queryByRole('button', { name: 'Selected skill' })).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Another skill' })).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'Another skill' }))
expect(screen.getByRole('dialog')).toHaveTextContent('extensions.skills')
expect(screen.getByRole('dialog')).not.toHaveTextContent('connectors.toolKind')
})
it('combines added state, region, category and search in the filter panel, restores focus on Escape, and resets only filters', () => {
useConnectorStore.setState({ items: [item, { ...remote, region: 'global', installed: true }, { ...remote, id: 'uninstalled-search', displayName: 'Uninstalled Search', region: 'global', installed: false }] })
render(<Connectors />)
const trigger = screen.getByRole('button', { name: 'extensions.filter' })
expect(screen.queryByRole('region', { name: 'extensions.filter' })).not.toBeInTheDocument()
fireEvent.change(screen.getByRole('searchbox'), { target: { value: 'Search' } })
fireEvent.click(trigger)
expect(trigger).toHaveAttribute('aria-expanded', 'true')
fireEvent.click(screen.getByRole('checkbox', { name: 'connectors.added' }))
fireEvent.change(screen.getByRole('combobox', { name: 'connectors.region' }), { target: { value: 'global' } })
const category = screen.getByRole('combobox', { name: 'extensions.categories' })
fireEvent.change(category, { target: { value: 'search' } })
expect(screen.getByRole('button', { name: 'Remote Search' })).toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Uninstalled Search' })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'connectors.feishu.name' })).not.toBeInTheDocument()
expect(trigger).toHaveTextContent('3')
category.focus()
fireEvent.keyDown(category, { key: 'Escape' })
expect(screen.queryByRole('region', { name: 'extensions.filter' })).not.toBeInTheDocument()
expect(trigger).toHaveFocus()
expect(trigger).toHaveAttribute('aria-expanded', 'false')
fireEvent.click(trigger)
expect(screen.getByRole('checkbox', { name: 'connectors.added' })).toBeChecked()
fireEvent.click(screen.getByRole('button', { name: 'connectors.resetFilters' }))
expect(screen.getByRole('checkbox', { name: 'connectors.added' })).not.toBeChecked()
expect(screen.getByRole('combobox', { name: 'connectors.region' })).toHaveValue('all')
expect(screen.getByRole('combobox', { name: 'extensions.categories' })).toHaveValue('all')
expect(screen.getByRole('searchbox')).toHaveValue('Search')
expect(screen.getByRole('button', { name: 'Uninstalled Search' })).toBeInTheDocument()
expect(trigger).not.toHaveTextContent('3')
})
it('manages all installed packages including pure skills without market filters or uninstalled entries', () => {
const skill = { ...item, id: 'test-skill', displayName: 'Managed skill', collection: 'tools' as const, transport: 'skills' as const }
useConnectorStore.setState({ items: [item, skill, { ...remote, installed: false }] })
render(<Connectors management />)
expect(screen.getByRole('button', { name: 'connectors.feishu.name' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Managed skill' })).toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Remote Search' })).not.toBeInTheDocument()
expect(screen.getAllByRole('button', { name: 'market.uninstall.action' })).toHaveLength(2)
expect(screen.queryByRole('button', { name: 'extensions.filter' })).not.toBeInTheDocument()
fireEvent.change(screen.getByRole('searchbox'), { target: { value: 'Managed skill' } })
expect(screen.queryByRole('button', { name: 'connectors.feishu.name' })).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Managed skill' })).toBeInTheDocument()
})
it('confirms management removal, allows cancellation and keeps unsupported installed packages removable', () => {
useConnectorStore.setState({ items: [{ ...item, supported: false }] })
render(<Connectors management />)
fireEvent.click(screen.getByRole('button', { name: 'market.uninstall.action' }))
expect(screen.getByRole('dialog')).toHaveTextContent('connectors.removeInfo')
expect(perform).not.toHaveBeenCalled()
fireEvent.click(screen.getByRole('button', { name: 'common.cancel' }))
expect(perform).not.toHaveBeenCalled()
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'market.uninstall.action' }))
fireEvent.click(screen.getByRole('button', { name: 'connectors.continue' }))
expect(perform).toHaveBeenCalledWith('feishu', 'remove', { acknowledgeSharedCredentials: true })
})
it('keeps active installs visible for cancellation and protects pending removals from duplicate clicks', () => {
useConnectorStore.setState({ items: [{ ...remote, installed: false, operation: { id: 'op', kind: 'prepare', phase: 'downloading', startedAt: '' } }, item], pending: { feishu: true } })
render(<Connectors management />)
expect(screen.getByRole('button', { name: 'Remote Search' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'market.uninstall.action' })).toBeDisabled()
fireEvent.click(screen.getByRole('button', { name: 'Remote Search' }))
fireEvent.click(screen.getByRole('button', { name: 'common.cancel' }))
expect(perform).toHaveBeenCalledWith('remote-search', 'cancel')
})
it('keeps an empty management list scoped to installed packages without a market reset', () => {
useConnectorStore.setState({ items: [{ ...item, installed: false }] })
render(<Connectors management />)
expect(screen.getByText('connectors.emptyAdded')).toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'connectors.resetFilters' })).not.toBeInTheDocument()
})
it('distinguishes a management search miss from having no installed packages', () => {
render(<Connectors management />)
fireEvent.change(screen.getByRole('searchbox'), { target: { value: 'missing-service' } })
expect(screen.getByText('connectors.emptySearch')).toBeInTheDocument()
expect(screen.queryByText('connectors.emptyAdded')).not.toBeInTheDocument()
})
+228
View File
@@ -0,0 +1,228 @@
import { useEffect, useState, useRef, useId } from 'react'
import { useTranslation, type TranslationKey } from '@/i18n'
import { SearchField } from '@/components/ui/SearchField'
import { SelectField } from '@/components/ui/SelectField'
import { Checkbox } from '@/components/ui/Checkbox'
import { SlidersHorizontal } from 'lucide-react'
import { useDismissable } from '@/hooks/useDismissable'
import { Button } from '@/components/ui/Button'
import { Badge } from '@/components/ui/Badge'
import { Modal } from '@/components/ui/Modal'
import { ConfirmDialog } from '@/components/ui/ConfirmDialog'
import { Input } from '@/components/ui/Input'
import { Skeleton } from '@/components/ui/Skeleton'
import { EmptyState } from '@/components/ui/EmptyState'
import { ErrorState } from '@/components/ui/ErrorState'
import { ConnectorRow } from '@/components/connectors/ConnectorRow'
import { useConnectorStore } from '@/stores/connectorStore'
import { useSessionStore } from '@/stores/sessionStore'
import { useChatStore } from '@/stores/chatStore'
import { useTabStore } from '@/stores/tabStore'
import { getDesktopHost } from '@/lib/desktopHost'
import { hasStoredCredentials, primaryAction, safeConnectorUrl } from '@/components/connectors/model'
import type { ConnectorAction, ConnectorActionOptions, ConnectorDto, ConnectorId } from '@/types/connector'
const CATEGORIES = ['office', 'development', 'search', 'maps', 'data', 'design', 'productivity', 'finance', 'legal'] as const
const PLATFORM_LABELS: Record<string, string> = { 'darwin-arm64': 'macOS · Apple Silicon', 'darwin-x64': 'macOS · Intel', 'win32-x64': 'Windows · x64', 'win32-arm64': 'Windows · ARM64' }
const PHASE_KEYS: Record<string, TranslationKey> = {
downloading: 'connectors.phase.downloading', extracting: 'connectors.phase.extracting', verifying: 'connectors.phase.verifying',
'awaiting-authorization': 'connectors.phase.awaiting-authorization', 'configuring-account': 'connectors.phase.configuring-account',
'installing-plugin': 'connectors.phase.installing-plugin', checking: 'connectors.phase.checking',
'enabling-plugin': 'connectors.phase.enabling-plugin', 'refreshing-sessions': 'connectors.phase.refreshing-sessions',
'verifying-runtime': 'connectors.phase.verifying-runtime', persistence: 'connectors.phase.persistence', cancelling: 'connectors.phase.cancelling',
authorizing: 'connectors.phase.authorizing', prepare: 'connectors.status.preparing', authenticate: 'connectors.status.authorizing',
check: 'connectors.action.check', deactivate: 'connectors.action.deactivate', remove: 'connectors.action.remove',
}
const TOOL_PLUGIN_IDS = new Set(['hyperframes', 'obsidian', 'drawio'])
export function Connectors({ mode = 'plugins', embedded = false, externalQuery, installedFilter = 'all', management = false }: { mode?: 'plugins' | 'skills', embedded?: boolean, externalQuery?: string, installedFilter?: 'all' | 'installed' | 'installable', management?: boolean } = {}) {
const t = useTranslation()
const { items, loading, error, pending, refresh, act } = useConnectorStore()
const [query, setQuery] = useState('')
const [region, setRegion] = useState<'all' | 'china' | 'global'>('all')
const [filter, setFilter] = useState<'all' | 'added'>('all')
const [category, setCategory] = useState<string>('all')
const [filtersOpen, setFiltersOpen] = useState(false)
const filterRef = useRef<HTMLDivElement>(null)
const filterButtonRef = useRef<HTMLButtonElement>(null)
const filterPanelId = useId()
useDismissable({ open: filtersOpen, refs: [filterRef], onDismiss: reason => {
setFiltersOpen(false)
if (reason === 'escape') filterButtonRef.current?.focus()
} })
const activeFilters = [filter === 'added' ? t('connectors.added') : '', region !== 'all' ? t(`connectors.region.${region}`) : '', category !== 'all' ? t(`connectors.category.${category}` as TranslationKey) : ''].filter(Boolean)
const resetFilters = () => { setFilter('all'); setRegion('all'); setCategory('all') }
const [selected, setSelected] = useState<ConnectorId | null>(null)
const [configuration, setConfiguration] = useState<Record<string, string>>({})
const [invalidFields, setInvalidFields] = useState<string[]>([])
const [confirmation, setConfirmation] = useState<{ item: ConnectorDto, action: ConnectorAction, options: ConnectorActionOptions } | null>(null)
const [localError, setLocalError] = useState<string | null>(null)
const [starting, setStarting] = useState(false)
const operating = items.some(item => item.operation) || Object.values(pending).some(Boolean)
useEffect(() => {
const controller = new AbortController()
void refresh(controller.signal)
return () => controller.abort()
}, [refresh])
useEffect(() => {
if (!operating) return
const controller = new AbortController()
let timer: ReturnType<typeof setTimeout>
const poll = async () => {
await refresh(controller.signal)
if (!controller.signal.aborted) timer = setTimeout(() => { void poll() }, 1500)
}
timer = setTimeout(() => { void poll() }, 1500)
return () => { clearTimeout(timer); controller.abort() }
}, [operating, refresh])
const nativeText = (item: ConnectorDto, field: 'name' | 'description' | 'example' | 'requirements') => {
const key = `connectors.${item.id}.${field}` as TranslationKey
const translated = t(key)
if (translated !== key) return translated
if (item.id === 'feishu' || item.id === 'dingtalk' || item.id === 'wecom') return t(`connectors.${item.id}.${field}`)
if (field === 'name') return item.displayName || item.packageName || item.id
if (field === 'description') return item.description || item.capabilities?.join(' · ') || ''
if (field === 'example') return item.example || t('connectors.remoteExample', { name: item.displayName || item.id })
return item.requirements || t('connectors.remoteRequirements')
}
const name = (item: ConnectorDto) => nativeText(item, 'name')
const isTool = (item: ConnectorDto) => item.collection === 'tools' || item.transport === 'skills'
const statusLabel = (item: ConnectorDto) => isTool(item) && ['ready', 'configured', 'needs-auth', 'not-installed'].includes(item.status)
? t(item.enabled && (item.status === 'ready' || item.status === 'configured') ? 'connectors.skillsLoaded' : 'connectors.skillsPending')
: item.transport === 'mcp' && item.status === 'needs-auth' ? t('connectors.needsService') : t(`connectors.status.${item.status}`)
const phaseLabel = (phase: string) => t(PHASE_KEYS[phase] || 'connectors.waiting')
const detail = items.find(item => item.id === selected)
const detailCapabilities = (detail?.capabilities || []).filter(capability => capability !== detail?.description)
const showDetail = (item: ConnectorDto) => {
if (selected !== item.id) { setConfiguration({}); setInvalidFields([]); setLocalError(null) }
setSelected(item.id)
}
const closeDetail = () => { setSelected(null); setConfiguration({}); setInvalidFields([]); setLocalError(null) }
const requestAction = (item: ConnectorDto, action: ConnectorAction, fromDetail = false) => {
if (!(management && action === 'remove' && !fromDetail)) showDetail(item)
const fields = item.setupFields || []
const configAction = action === 'prepare' || action === 'authenticate'
if (fields.length && configAction && !fromDetail) return
const values = fromDetail && configAction ? configuration : {}
if (fields.length && configAction && action === 'authenticate') {
// Any typed field selects the "replace the stored credential" path, so
// every setup field must be complete. An entirely blank form is only
// valid when a credential is already stored; otherwise the missing
// fields are reported instead of submitting an empty configuration.
const entered = Object.values(values).some(value => value.trim())
const missing = entered || !hasStoredCredentials(item) ? fields.filter(field => !values[field.key]?.trim()).map(field => field.key) : []
setInvalidFields(missing)
if (missing.length) return
}
const nonemptyValues = Object.fromEntries(Object.entries(values).filter(([, value]) => value.trim()))
const options: ConnectorActionOptions = Object.keys(nonemptyValues).length ? { configuration: nonemptyValues } : {}
if ((!isTool(item) && item.credentialMode === 'shared' && (action === 'authenticate' || action === 'check')) || action === 'remove') {
setConfirmation({ item, action, options })
} else {
void act(item.id, action, options)
setConfiguration({})
}
}
const openUrl = async (url: string) => {
const safe = safeConnectorUrl(url)
if (!safe) { setLocalError(t('connectors.invalidUrl')); return }
try { await getDesktopHost().shell.open(safe) } catch { setLocalError(t('connectors.openFailed')) }
}
const start = async (item: ConnectorDto) => {
setStarting(true)
try {
const id = await useSessionStore.getState().createSession()
useChatStore.getState().setComposerDraft(id, { input: nativeText(item, 'example'), attachments: [] })
useSessionStore.getState().setActiveSession(id)
useTabStore.getState().openTab(id, t('sidebar.newSession'))
} catch { setLocalError(t('connectors.startFailed')) } finally { setStarting(false) }
}
const actionLabel = (item: ConnectorDto, action: ReturnType<typeof primaryAction>) => isTool(item) && action === 'check' ? t('connectors.verifySkills') : action === 'prepare' && item.installed
? t(item.updateAvailable ? 'connectors.update' : 'connectors.retry')
: action === 'authenticate' && item.transport === 'mcp' ? t('connectors.connectService')
: action ? t(`connectors.action.${action}`) : t('connectors.details')
const actions = (item: ConnectorDto) => {
const action = primaryAction(item)
const canConfigure = !isTool(item) && item.installed && !!item.setupFields?.length && !item.operation
const hasNewConfiguration = Object.values(configuration).some(value => value.trim())
return <div className="flex flex-wrap gap-2">
{action && !(action === 'authenticate' && canConfigure && hasNewConfiguration) && <Button size="sm" variant="secondary" aria-describedby={error || localError ? 'connector-error' : undefined} disabled={pending[item.id]} onClick={() => requestAction(item, action, true)}>{actionLabel(item, action)}</Button>}
{canConfigure && (action !== 'authenticate' || hasNewConfiguration) && <Button size="sm" variant="secondary" disabled={!hasNewConfiguration || pending[item.id] || !item.supported} onClick={() => requestAction(item, 'authenticate', true)}>{t('connectors.saveAndVerify')}</Button>}
{(item.status === 'ready' || item.status === 'configured') && <Button size="sm" variant="secondary" disabled={starting || !item.supported} onClick={() => void start(item)}>{t('connectors.start')}</Button>}
{item.operation && <Button size="sm" variant="secondary" disabled={pending[item.id]} onClick={() => void act(item.id, 'cancel')}>{t('common.cancel')}</Button>}
{item.installed && !item.operation && <>
{item.enabled && <Button size="sm" variant="ghost" disabled={pending[item.id]} onClick={() => void act(item.id, 'deactivate')}>{t('connectors.action.deactivate')}</Button>}
<Button size="sm" variant="ghost" disabled={pending[item.id]} onClick={() => requestAction(item, 'remove', true)}>{t('connectors.action.remove')}</Button>
</>}
</div>
}
const inMode = (item: ConnectorDto) => mode === 'skills' ? isTool(item) && !TOOL_PLUGIN_IDS.has(item.id) : !isTool(item) || TOOL_PLUGIN_IDS.has(item.id)
const availableCategories = CATEGORIES.filter(value => items.some(item => inMode(item) && (item.category || 'office') === value))
const visible = items.filter(item => {
const matchesQuery = `${name(item)} ${nativeText(item, 'description')} ${item.packageName} ${(item.capabilities || []).join(' ')}`.toLowerCase().includes((externalQuery ?? query).toLowerCase())
if (management) return matchesQuery && (item.installed || !!item.operation || !!pending[item.id])
return matchesQuery && inMode(item)
&& (mode === 'skills' || region === 'all' || !isTool(item) && (item.region || (['feishu', 'dingtalk', 'wecom'].includes(item.id) ? 'china' : 'global')) === region)
&& (filter === 'all' || item.installed)
&& (installedFilter === 'all' || (installedFilter === 'installed' ? item.installed : !item.installed && item.supported))
&& (category === 'all' || (item.category || 'office') === category)
})
return <section className={`${embedded ? '' : 'h-full overflow-y-auto'} bg-[var(--color-surface)] text-[var(--color-text-primary)]`}>
<div className={embedded ? 'py-4' : 'mx-auto max-w-7xl px-5 py-6 lg:px-8'}>
<h1 className="sr-only">{t(mode === 'skills' ? 'extensions.skills' : 'extensions.plugins')}</h1>
<header className={embedded ? '' : 'mb-5'}>
{embedded && <h2 className="mb-3 text-sm font-semibold">{t('extensions.featuredSkills')}</h2>}
{!embedded && <>
<div className="flex items-center gap-3">
<SearchField size="md" value={query} onChange={setQuery} label={t(mode === 'skills' ? 'extensions.searchSkills' : 'extensions.searchPlugins')} placeholder={t(mode === 'skills' ? 'extensions.searchSkills' : 'extensions.searchPlugins')} clearLabel={t('connectors.clear')} containerClassName="min-w-0 flex-1" />
{!management && <div ref={filterRef} className="relative shrink-0">
<Button ref={filterButtonRef} size="sm" variant="secondary" aria-expanded={filtersOpen} aria-controls={filterPanelId} onClick={() => setFiltersOpen(value => !value)}>
<SlidersHorizontal aria-hidden="true" className="h-4 w-4" />
{t('extensions.filter')}{activeFilters.length > 0 && <span className="tabular-nums">{activeFilters.length}</span>}
</Button>
{filtersOpen && <div id={filterPanelId} role="region" aria-label={t('extensions.filter')} className="absolute right-0 top-full z-[var(--z-dropdown)] mt-2 w-64 max-w-[calc(100vw-40px)] space-y-4 rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface)] p-4 shadow-[var(--shadow-overlay)]">
<Checkbox label={t('connectors.added')} checked={filter === 'added'} onChange={event => setFilter(event.target.checked ? 'added' : 'all')} />
{mode === 'plugins' && <SelectField size="md" label={t('connectors.region')} value={region} onChange={setRegion} options={[{ value: 'all', label: t('connectors.region.all') }, { value: 'china', label: t('connectors.region.china') }, { value: 'global', label: t('connectors.region.global') }]} />}
<SelectField size="md" label={t('extensions.categories')} value={category} onChange={setCategory} options={[{ value: 'all', label: t('connectors.all') }, ...availableCategories.map(value => ({ value, label: t(`connectors.category.${value}`) }))]} />
{activeFilters.length > 0 && <Button size="sm" variant="ghost" onClick={resetFilters}>{t('connectors.resetFilters')}</Button>}
</div>}
</div>}
</div>
{!management && activeFilters.length > 0 && <p className="mt-2 text-xs text-[var(--color-text-secondary)]">{activeFilters.join(' · ')}</p>}
</>}
</header>
{(error || localError) && (!detail || confirmation) && <div id="connector-error" className="mb-4"><ErrorState size="sm" title={t('connectors.loadFailed')} detail={localError || error} onRetry={() => { setLocalError(null); void refresh() }} retryLabel={t('connectors.retry')} /></div>}
{loading ? <div role="status" aria-label={t('connectors.loading')} className="grid grid-cols-1 gap-3 lg:grid-cols-2">{Array.from({ length: 8 }, (_, index) => <div key={index} className="flex h-28 items-center gap-3 rounded-[var(--radius-lg)] border border-[var(--color-border)] px-4"><Skeleton shape="block" width="40px" height="40px" /><div className="flex-1 space-y-2"><Skeleton width="40%" /><Skeleton /><Skeleton width="72%" /></div></div>)}</div>
: visible.length ? <div className="grid grid-cols-1 gap-3 lg:grid-cols-2">{visible.map(item => <ConnectorRow key={item.id} id={item.id} name={name(item)} description={nativeText(item, 'description')} kind={isTool(item) && mode !== 'skills' ? t('connectors.toolKind') : undefined} status={!item.supported ? t('connectors.unsupported') : item.installed || item.operation || item.status === 'error' ? statusLabel(item) : undefined} added={item.installed || !!item.operation || management} actionLabel={management || item.installed || item.operation ? t('connectors.details') : actionLabel(item, primaryAction(item))} disabled={!management && !item.supported && !item.installed} action={management && item.installed && !item.operation ? <Button size="sm" variant="secondary" disabled={pending[item.id]} onClick={() => requestAction(item, 'remove')}>{t('market.uninstall.action')}</Button> : undefined} onDetails={() => showDetail(item)} onAction={() => management || item.installed || item.operation ? showDetail(item) : requestAction(item, primaryAction(item) || 'prepare')} />)}</div>
: <EmptyState variant="plain" size="md" title={t('extensions.empty')} description={t(management ? (externalQuery ?? query).trim() ? 'connectors.emptySearch' : 'connectors.emptyAdded' : filter === 'added' ? 'connectors.emptyAdded' : 'connectors.emptySearch')} action={embedded || management ? undefined : { label: t('connectors.resetFilters'), variant: 'secondary', onClick: () => { setFilter('all'); setCategory('all'); setRegion('all'); setQuery('') } }} />}
</div>
<Modal open={!!detail && !confirmation} onClose={closeDetail} title={detail ? name(detail) : ''} width={600} footer={detail ? actions(detail) : undefined}>
{detail && <div className="space-y-4 text-xs leading-5">
<p className="text-sm text-[var(--color-text-secondary)]">{nativeText(detail, 'description')}</p>
<div className="flex items-center gap-2"><Badge tone="neutral">{statusLabel(detail)}</Badge><span className="text-[var(--color-text-tertiary)]">{isTool(detail) ? t(mode === 'skills' ? 'extensions.skills' : 'connectors.toolKind') : detail.transport === 'mcp' ? 'MCP' : 'CLI'}</span></div>
<p className="text-[var(--color-text-secondary)]">{nativeText(detail, 'requirements')}</p>
{(error || localError) && <div id="connector-error"><ErrorState size="sm" title={t('connectors.loadFailed')} detail={localError || error} /></div>}
{!isTool(detail) && !!detail.setupFields?.length && <div className="space-y-3 rounded-[var(--radius-lg)] border border-[var(--color-border)] p-3">
<p className="font-medium">{t('connectors.configuration')}</p>
<p className="text-[var(--color-text-tertiary)]">{t('connectors.configurationHint')}</p>
{detail.setupFields.map(field => <Input key={field.key} size="md" label={field.label} type={field.secret ? 'password' : 'text'} autoComplete="off" spellCheck={false} placeholder={field.placeholder} value={configuration[field.key] || ''} error={invalidFields.includes(field.key) ? t('connectors.fieldRequired') : undefined} onChange={event => { setConfiguration(state => ({ ...state, [field.key]: event.target.value })); setInvalidFields(state => state.filter(key => key !== field.key)) }} />)}
</div>}
{!!detailCapabilities.length && <div><h3 className="mb-1 font-medium">{t('connectors.capabilities')}</h3><p className="text-[var(--color-text-secondary)]">{detailCapabilities.join(' · ')}</p></div>}
<dl className="grid grid-cols-[auto_1fr] gap-x-5 gap-y-2 border-y border-[var(--color-border)] py-3 [&>dt]:text-[var(--color-text-tertiary)] [&>dd]:min-w-0 [&>dd]:break-words">
{!isTool(detail) && <><dt>{t('connectors.account')}</dt><dd>{detail.accountLabel || t(detail.connection === 'connected' ? 'connectors.currentAccount' : 'connectors.noAccount')}</dd></>}
<dt>{t('connectors.platforms')}</dt><dd>{detail.platforms.map(platform => PLATFORM_LABELS[platform] || platform).join(', ')}</dd>
<dt>{t('connectors.source')}</dt><dd>{detail.transport === 'mcp' ? t('connectors.officialMcp') : detail.packageName}<Button size="xs" variant="ghost" onClick={() => void openUrl(detail.homepage)}>{t('connectors.homepage')}</Button></dd>
<dt>{t('connectors.version')}</dt><dd>{detail.installedVersion || detail.version}{detail.updateAvailable && <p>{t('connectors.updateAvailable')}: {detail.version}</p>}</dd>
<dt>{t('connectors.skills')}</dt><dd>{name(detail)} · {detail.pluginId}</dd>
</dl>
<p className="text-[var(--color-text-tertiary)]">{t(isTool(detail) ? 'connectors.skillsRuntimeInfo' : detail.credentialMode === 'shared' ? 'connectors.shared' : 'connectors.isolated')}</p>
{!isTool(detail) && detail.status === 'configured' && <p>{t('connectors.localVerification')}</p>}
<div><h3 className="mb-1 font-medium">{t('connectors.example')}</h3><p className="text-[var(--color-text-secondary)]">{nativeText(detail, 'example')}</p></div>
{detail.error && <ErrorState size="sm" title={detail.failedPhase ? `${t('connectors.phase.failed')}: ${phaseLabel(detail.failedPhase)}` : t('connectors.status.error')} detail={detail.error} />}
{detail.operation && <div role="status" className="space-y-2 border-t border-[var(--color-border)] pt-3"><p>{phaseLabel(detail.operation.phase)}</p>{!isTool(detail) && detail.operation.authUrl && <Button size="sm" variant="secondary" onClick={() => void openUrl(detail.operation!.authUrl!)}>{t('connectors.authorize')}</Button>}</div>}
</div>}
</Modal>
<ConfirmDialog open={!!confirmation} onClose={() => setConfirmation(null)} title={t('connectors.confirm')} body={confirmation?.action === 'remove' ? t(confirmation && isTool(confirmation.item) ? 'connectors.toolsRemoveInfo' : 'connectors.removeInfo') : t('connectors.shared')} confirmLabel={t('connectors.continue')} cancelLabel={t('common.cancel')} confirmVariant="primary" onConfirm={() => { if (confirmation) void act(confirmation.item.id, confirmation.action, { ...confirmation.options, acknowledgeSharedCredentials: true }); setConfiguration({}); setConfirmation(null) }} />
</section>
}
+92
View File
@@ -2,6 +2,9 @@ import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testi
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import '@testing-library/jest-dom'
const originalRangeGetClientRects = Object.getOwnPropertyDescriptor(Range.prototype, 'getClientRects')
const originalRangeGetBoundingClientRect = Object.getOwnPropertyDescriptor(Range.prototype, 'getBoundingClientRect')
const mocks = vi.hoisted(() => ({
createSession: vi.fn(),
listSessions: vi.fn(),
@@ -10,6 +13,7 @@ const mocks = vi.hoisted(() => ({
getSlashCommands: vi.fn(),
listSkills: vi.fn(),
listAgents: vi.fn(),
listReferences: vi.fn(),
search: vi.fn(),
browse: vi.fn(),
getTasksForList: vi.fn(),
@@ -43,6 +47,10 @@ vi.mock('../api/skills', () => ({
},
}))
vi.mock('../api/composerReferences', () => ({
composerReferencesApi: { list: mocks.listReferences },
}))
vi.mock('../api/agents', () => ({
agentsApi: {
list: mocks.listAgents,
@@ -235,6 +243,14 @@ describe('EmptySession', () => {
beforeEach(() => {
vi.clearAllMocks()
// ProseMirror selection scrolling needs geometry APIs absent in jsdom.
Object.defineProperties(Range.prototype, {
getClientRects: { configurable: true, value: () => [] },
getBoundingClientRect: { configurable: true, value: () => ({
x: 0, y: 0, top: 0, right: 0, bottom: 0, left: 0, width: 0, height: 0,
toJSON: () => ({}),
}) },
})
mocks.webviewDragHandlers.length = 0
mocks.isMobile = false
mocks.isTauriRuntime = false
@@ -267,12 +283,14 @@ describe('EmptySession', () => {
mocks.getSlashCommands.mockResolvedValue({ commands: [] })
mocks.listSkills.mockResolvedValue({ skills: [] })
mocks.listAgents.mockResolvedValue({ activeAgents: [], allAgents: [] })
mocks.listReferences.mockResolvedValue({ plugins: [], skills: [] })
mocks.search.mockResolvedValue({
currentPath: '/workspace/project',
parentPath: null,
query: '',
entries: [],
})
mocks.browse.mockResolvedValue({ currentPath: '/workspace/project', parentPath: null, entries: [] })
mocks.getTasksForList.mockResolvedValue({ tasks: [] })
mocks.resetTaskList.mockResolvedValue(undefined)
mocks.getProviderAuthStatus.mockResolvedValue({
@@ -283,6 +301,13 @@ describe('EmptySession', () => {
afterEach(() => {
cleanup()
for (const [name, descriptor] of [
['getClientRects', originalRangeGetClientRects],
['getBoundingClientRect', originalRangeGetBoundingClientRect],
] as const) {
if (descriptor) Object.defineProperty(Range.prototype, name, descriptor)
else Reflect.deleteProperty(Range.prototype, name)
}
Reflect.deleteProperty(window, 'desktopHost')
useSessionStore.setState(initialSessionState, true)
useChatStore.setState(initialChatState, true)
@@ -308,6 +333,53 @@ describe('EmptySession', () => {
expect(screen.getByTestId('empty-session-composer-panel')).toHaveClass('rounded-[var(--radius-2xl)]')
})
it('keeps user-only skills as slash text when no mention capability is available', async () => {
mocks.listSkills.mockResolvedValue({ skills: [{ name: 'manual-only', description: 'User invocation only', userInvocable: true, disableModelInvocation: true }] })
render(<EmptySession />)
setComposerText('/manual', 7)
fireEvent.click(await screen.findByText('manual-only'))
await waitFor(() => expect(getComposerText()).toBe('/manual-only '))
expect(document.querySelector('.composer-mention')).not.toBeInTheDocument()
expect(mocks.createSession).not.toHaveBeenCalled()
})
it('hides previous directory skills while the new directory is loading', async () => {
let resolveProject!: (result: { skills: { name: string, description: string, userInvocable: boolean }[] }) => void
mocks.listSkills.mockImplementation((cwd?: string) => cwd
? new Promise(resolve => { resolveProject = resolve })
: Promise.resolve({ skills: [{ name: 'old-directory-skill', description: 'Old scope', userInvocable: true }] }))
render(<EmptySession />)
setComposerText('/', 1)
expect(await screen.findByText('old-directory-skill')).toBeInTheDocument()
await pickProject()
setComposerText('/', 1)
expect(screen.queryByText('old-directory-skill')).not.toBeInTheDocument()
await act(async () => resolveProject({ skills: [{ name: 'new-directory-skill', description: 'New scope', userInvocable: true }] }))
expect(await screen.findByText('new-directory-skill')).toBeInTheDocument()
expect(screen.queryByText('old-directory-skill')).not.toBeInTheDocument()
})
it('ignores late mention candidates from a previous directory', async () => {
const staleResolvers: ((result: { plugins: unknown[], skills: unknown[] }) => void)[] = []
const candidate = (id: string) => ({
kind: 'plugin', id, name: id, displayName: id, description: 'Plugin',
source: 'plugin', modelText: `Use ${id}`,
})
mocks.listReferences.mockImplementation((cwd?: string) => cwd
? Promise.resolve({ plugins: [candidate('current-plugin')], skills: [] })
: new Promise(resolve => { staleResolvers.push(resolve) }))
render(<EmptySession />)
await waitFor(() => expect(staleResolvers.length).toBeGreaterThan(0))
await pickProject()
setComposerText('@', 1)
expect(await screen.findByText('current-plugin')).toBeInTheDocument()
await act(async () => {
staleResolvers.forEach(resolve => resolve({ plugins: [candidate('stale-plugin')], skills: [] }))
})
expect(screen.queryByText('stale-plugin')).not.toBeInTheDocument()
expect(screen.getByText('current-plugin')).toBeInTheDocument()
})
it('refreshes empty-session slash commands after plugin reloads', async () => {
mocks.listSkills
.mockResolvedValueOnce({ skills: [] })
@@ -946,6 +1018,26 @@ describe('EmptySession', () => {
})
})
it('selects a plugin on the landing composer and sends its runtime request only after Run', async () => {
const modelText = 'Use plugin "hyperframes@curated" for this request.'
mocks.listReferences.mockResolvedValue({ skills: [], plugins: [{
kind: 'plugin', id: 'hyperframes@curated', name: 'hyperframes', displayName: 'HyperFrames',
description: 'Create HTML videos', source: 'curated', modelText,
}] })
render(<EmptySession />)
setComposerText('@hyper', 6)
fireEvent.click(await screen.findByRole('option', { name: 'HyperFrames' }))
await waitFor(() => expect(document.querySelector('[data-mention-kind="plugin"]')).toBeInTheDocument())
expect(mocks.createSession).not.toHaveBeenCalled()
fireEvent.keyDown(document.querySelector('[data-mention-kind="plugin"]')!, { key: 'Enter' })
expect(await screen.findByRole('dialog', { name: 'HyperFrames' })).toHaveTextContent('Create HTML videos')
fireEvent.click(screen.getByRole('button', { name: 'Close dialog' }))
fireEvent.click(screen.getByRole('button', { name: /Run/i }))
await waitFor(() => expect(mocks.wsSend).toHaveBeenCalledWith('draft-session', {
type: 'user_message', content: modelText, attachments: [],
}))
})
it('sends a selected @ directory as an inline @"path" in the first draft message', async () => {
mocks.search.mockResolvedValueOnce({
currentPath: '/workspace/project',

Some files were not shown because too many files have changed in this diff Show More