mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-10-19 07:34:58 +00:00
feat(plugins): Yours and Browse tabs on the plugins screen
Yours lists connected plugins in a table, Plugin, Type and Status, each row opening the plugin's details in place of the list with Back; with nothing connected it offers Browse plugins and Add custom plugin. Browse lists SurfSense's plugins, then third-party ones with the switch that allows them. Add custom plugin sits beside the tabs and nowhere else. A hovered row is rounded like the rest of the app, and hides the lines on either side as the chats dialog does. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
ef5d189f4d
commit
4e14b49e93
26 changed files with 1237 additions and 238 deletions
|
|
@ -60,6 +60,7 @@ SurfSense, Studio, Markdown, llama.cpp, GGUF, Hugging Face, API, MCP, and every
|
|||
| retry | 再試行 | 다시 시도 | 重试 | फिर से कोशिश करें | Erneut versuchen | Reintentar | Réessayer | Tentar novamente | Повторить |
|
||||
| plugin (an MCP server whose tools the agent can use) | プラグイン | 플러그인 | 插件 | प्लगइन | Plugin | plugin | plugin | plugin | плагин |
|
||||
| third-party (a plugin SurfSense does not publish) | サードパーティ | 서드파티 | 第三方 | थर्ड-पार्टी | Drittanbieter- | de terceros | tiers | de terceiros | сторонний |
|
||||
| custom (a plugin added by its URL) | カスタム | 사용자 지정 | 自定义 | कस्टम | Eigenes | personalizado | personnalisé | personalizado | свой |
|
||||
| tool (one action a plugin offers the agent) | ツール | 도구 | 工具 | टूल | Tool | herramienta | outil | ferramenta | инструмент |
|
||||
| View (menu) | 表示 | 보기 | 视图 | देखें | Darstellung | Ver | Affichage | Exibir | Вид |
|
||||
| report an issue | 問題を報告 | 문제 신고 | 报告问题 | समस्या रिपोर्ट करें | Problem melden | informar de un problema | signaler un problème | relatar um problema | сообщить о проблеме |
|
||||
|
|
|
|||
|
|
@ -94,19 +94,18 @@ A tool switched off by a check, because it is new or its description or annotati
|
|||
|
||||
## Settings → Plugins
|
||||
|
||||
[`plugins-settings.tsx`](../../surfsense_local/frontend/src/features/plugins/plugins-settings.tsx), under System, says plugins work in Agentic threads and holds, in this order:
|
||||
[`plugins-settings.tsx`](../../surfsense_local/frontend/src/features/plugins/plugins-settings.tsx), under System, says plugins work in Agentic threads and has two tabs, **Yours** and **Browse**, with **Add custom plugin** beside them and nowhere else. It opens on Yours when a plugin is connected, otherwise on Browse, chosen once when the list first loads.
|
||||
|
||||
- **By SurfSense**, from the first-party list ([`first-party-plugins.tsx`](../../surfsense_local/frontend/src/features/plugins/first-party-plugins.tsx)): each plugin with its description, "License required" where `access` is `license`, and **Connect**, or "Connected". A refusal shows under it in the app's words.
|
||||
- **Allow third-party plugins**, off by default. Turning it on first asks, saying other people run those servers, what a tool is sent goes to its publisher, and SurfSense does not review changes to them. Turning it off asks nothing. While it is off, a custom plugin says "Not used until third-party plugins are allowed".
|
||||
- **Third-party**, shown once they are allowed, from the registry ([`registry-plugins.tsx`](../../surfsense_local/frontend/src/features/plugins/registry-plugins.tsx)), dated "Updated" with `fetched_at`. While `plugins.surfsense.com` is off it says the list comes from there, with **Allow**, which raises the egress prompt on the click rather than at load. Each entry shows its icon or its name's first letter ([`plugin-icon.tsx`](../../surfsense_local/frontend/src/features/plugins/plugin-icon.tsx)), name, description, "Verified" for a partner or "By author", an `external` entry's note, and **Connect**, or "Connected". A `token` entry's Connect opens a dialog for the key, labelled in its publisher's words ([`registry-key-dialog.tsx`](../../surfsense_local/frontend/src/features/plugins/registry-key-dialog.tsx)).
|
||||
- **Add MCP server**, under MCP servers, a dialog with Name, Server URL and an optional Key. Each refusal above is shown in the app's words by its `code` ([`refusal-message.ts`](../../surfsense_local/frontend/src/features/plugins/refusal-message.ts)); a host not allowed raises the egress prompt and retries once allowed.
|
||||
- **A sign-in** started by Connect, Add MCP server or Sign in again opens the provider's page in the browser and shows "Waiting for sign-in…" with Cancel, polling each second until it connects or fails, "The sign-in didn’t finish. Try again." ([`use-plugin-sign-in.ts`](../../surfsense_local/frontend/src/features/plugins/use-plugin-sign-in.ts), [`sign-in-progress.tsx`](../../surfsense_local/frontend/src/features/plugins/sign-in-progress.tsx)). Leaving the screen abandons it. Electron opens only a URL shaped as SurfSense's authorization request: `response_type=code`, S256 PKCE, SurfSense's `state`, and one of its two callbacks as `redirect_uri` ([`external-url.ts`](../../surfsense_local/electron/src/main/external-url.ts)).
|
||||
- **One card per plugin:** its name, "Third-party · host" or "By SurfSense · host", a switch that pauses it, and Disconnect, which asks first. Under them, the last check: "Connected", "Couldn’t connect", "Needs a new key", "Signed out" with **Sign in again** for an OAuth plugin ([`sign-in-again-button.tsx`](../../surfsense_local/frontend/src/features/plugins/sign-in-again-button.tsx)), or for a refused license "Needs a license", "License expired", "License revoked" or "License not recognized" with **License settings**, which opens Settings › License, and **Check now**, and on "Needs a new key" **Replace key**, a dialog for the new key that says when the server refuses it. A plugin removed from the registry says "No longer listed:" and the reason. When a check switched tools off, the card says some tools are new or changed and stay off until allowed, and each such tool is marked New or Changed. Its tools are split into Read-only and Can make changes, each with a switch and, where the rules leave a choice, Ask or Always allow; a tool that always asks says "Always asks", and a first-party read-only one "Doesn’t ask" ([`tool-kind.ts`](../../surfsense_local/frontend/src/features/plugins/tool-kind.ts) mirrors `needs_approval()`).
|
||||
- **Yours** ([`your-plugins.tsx`](../../surfsense_local/frontend/src/features/plugins/your-plugins.tsx)) is a table of connected plugins, Plugin, Type and Status ([`connected-plugins-table.tsx`](../../surfsense_local/frontend/src/features/plugins/connected-plugins-table.tsx)). Type is "By SurfSense", "Third-party" or "Custom" for a server added by its URL. Status is one state, the first that holds ([`plugin-status.ts`](../../surfsense_local/frontend/src/features/plugins/plugin-status.ts)): "No longer listed", a failed check ("Couldn’t connect", "Signed out", "Needs a new key", or a refused license), "Third-party plugins are off", "Paused", "New tools", then "On". Each row opens the plugin's details. With nothing connected it says so, with **Browse plugins** and **Add custom plugin** ([`no-plugins-connected.tsx`](../../surfsense_local/frontend/src/features/plugins/no-plugins-connected.tsx)).
|
||||
- **Browse** ([`browse-plugins.tsx`](../../surfsense_local/frontend/src/features/plugins/browse-plugins.tsx)) holds **By SurfSense**, from the first-party list ([`first-party-plugins.tsx`](../../surfsense_local/frontend/src/features/plugins/first-party-plugins.tsx)): each plugin with its description, "License required" where `access` is `license`, and **Connect**, or "Connected", with a refusal under it in the app's words. Then **Third-party** ([`third-party-plugins.tsx`](../../surfsense_local/frontend/src/features/plugins/third-party-plugins.tsx)), its header holding **Allow third-party plugins**, off by default. Turning it on first asks, saying other people run those servers, what a tool is sent goes to its publisher, and SurfSense does not review changes to them; turning it off asks nothing. While it is off the section is one sentence; while it is on it holds the registry's list once they are allowed ([`registry-plugins.tsx`](../../surfsense_local/frontend/src/features/plugins/registry-plugins.tsx)), dated "Updated" with `fetched_at`. While `plugins.surfsense.com` is off it says the list comes from there, with **Allow**, which raises the egress prompt on the click rather than at load. Each entry shows its icon or its name's first letter ([`plugin-icon.tsx`](../../surfsense_local/frontend/src/features/plugins/plugin-icon.tsx)), name, description, "Verified" for a partner or "By author", an `external` entry's note, and **Connect**, or "Connected". A `token` entry's Connect opens a dialog for the key, labelled in its publisher's words ([`registry-key-dialog.tsx`](../../surfsense_local/frontend/src/features/plugins/registry-key-dialog.tsx)).
|
||||
- **Add custom plugin** opens a dialog with Name, Server URL and an optional Key, and switches to Yours once the plugin is connected; connecting from Browse stays on Browse. Each refusal is shown in the app's words by its `code` ([`refusal-message.ts`](../../surfsense_local/frontend/src/features/plugins/refusal-message.ts)); a host not allowed raises the egress prompt and retries once allowed. While third-party plugins are off, a custom plugin's details say "Not used until third-party plugins are allowed".
|
||||
- **A sign-in** started by Connect, Add custom plugin or Sign in again opens the provider's page in the browser and shows "Waiting for sign-in…" with Cancel, polling each second until it connects or fails, "The sign-in didn’t finish. Try again." ([`use-plugin-sign-in.ts`](../../surfsense_local/frontend/src/features/plugins/use-plugin-sign-in.ts), [`sign-in-progress.tsx`](../../surfsense_local/frontend/src/features/plugins/sign-in-progress.tsx)). Leaving the screen abandons it. Electron opens only a URL shaped as SurfSense's authorization request: `response_type=code`, S256 PKCE, SurfSense's `state`, and one of its two callbacks as `redirect_uri` ([`external-url.ts`](../../surfsense_local/electron/src/main/external-url.ts)).
|
||||
- **A plugin's details**, opened from its row in place of the list, with **Back** ([`plugin-details.tsx`](../../surfsense_local/frontend/src/features/plugins/plugin-details.tsx), [`plugin-row.tsx`](../../surfsense_local/frontend/src/features/plugins/plugin-row.tsx)): its icon and name, "By SurfSense · host", "Third-party · host" or "Custom · host", a switch that pauses it, and Disconnect, which asks first; disconnecting returns to the list. Under them, the last check: "Connected", "Couldn’t connect", "Needs a new key", "Signed out" with **Sign in again** for an OAuth plugin ([`sign-in-again-button.tsx`](../../surfsense_local/frontend/src/features/plugins/sign-in-again-button.tsx)), or for a refused license "Needs a license", "License expired", "License revoked" or "License not recognized" with **License settings**, which opens Settings › License, and **Check now**, and on "Needs a new key" **Replace key**, a dialog for the new key that says when the server refuses it. A plugin removed from the registry says "No longer listed:" and the reason. When a check switched tools off, the card says some tools are new or changed and stay off until allowed, and each such tool is marked New or Changed. Its tools are split into Read-only and Can make changes, each with a switch and, where the rules leave a choice, Ask or Always allow; a tool that always asks says "Always asks", and a first-party read-only one "Doesn’t ask" ([`tool-kind.ts`](../../surfsense_local/frontend/src/features/plugins/tool-kind.ts) mirrors `needs_approval()`).
|
||||
|
||||
## Tests
|
||||
|
||||
- **Backend:** [`tests/integration/plugins/routes/`](../../surfsense_local/backend/tests/integration/plugins/routes/) connect real MCP servers on loopback ([`tests/mcp_test_server.py`](../../surfsense_local/backend/tests/mcp_test_server.py)) through the routes: each refusal, a key that reaches the server and never comes back out, the switches, disconnecting, the third-party switch, checks: a server gone down, a refused key, and tools marked added or changed until allowed, replacing a key: one accepted keeps the tool settings, one refused is not saved, and SurfSense's own plugins against a stand-in plugin host ([`test_first_party_plugins.py`](../../surfsense_local/backend/tests/integration/plugins/routes/test_first_party_plugins.py)): the list with no network, the license key on every request, a missing or expired license connecting with its reason, a license imported later used at the next check, no key once the plugin is off the plugin host, and a custom plugin kept off a first-party id. [`test_oauth_sign_in.py`](../../surfsense_local/backend/tests/integration/plugins/routes/test_oauth_sign_in.py) signs in against a stand-in authorization server and an MCP server that answers only its tokens ([`tests/oauth_stand_in.py`](../../surfsense_local/backend/tests/oauth_stand_in.py)): a custom server and a registry plugin, a client registered in advance with its scopes, a state the app did not issue, a provider's refusal, a sign-in abandoned or cancelled leaving no plugin, the authorization server's host asked about first, a packaged app signing in as SurfSense's client, a refused token refreshed once, a refused refresh marking the plugin, signing in again keeping tool settings, and no token in a log line or a response; [`test_sign_in_client.py`](../../surfsense_local/backend/tests/unit/plugins/remote/test_sign_in_client.py) holds which client each build signs in as. The registry's tests are in [plugin registry](plugin-registry.md#tests). [`tests/unit/plugins/lists/`](../../surfsense_local/backend/tests/unit/plugins/lists/) checks the packaged list and each rule an entry breaks. [`tests/integration/plugins/gateway/`](../../surfsense_local/backend/tests/integration/plugins/gateway/) holds readiness and tools added later, and [`tests/unit/plugins/`](../../surfsense_local/backend/tests/unit/plugins/) the MCP client, the approval rules and URL spelling. The agent's side is in [agent](agent.md#tests).
|
||||
- **Frontend:** [`plugins-settings.test.tsx`](../../surfsense_local/frontend/src/features/plugins/plugins-settings.test.tsx) drives the screen against a stand-in API: listing, adding with a key, a refusal, the tool and plugin switches, approval, disconnecting, the third-party consent, the check's status with Check now, replacing a refused key, tools marked New or Changed, SurfSense's plugins listed and connected, each license status with License settings, the sections' order, the registry fetched only once allowed and only with its host allowed, its entries listed and connected with a key or a sign-in, Cancel and a failed sign-in, a server by URL that signs in, Sign in again, and a removed plugin's reason. [`external-url.test.ts`](../../surfsense_local/electron/src/main/external-url.test.ts) holds which sign-in pages Electron opens. [`plugin-step-line.test.tsx`](../../surfsense_local/frontend/src/features/plugins/plugin-step-line.test.tsx) holds a plugin step's name, its whole result and Save to Sources.
|
||||
- **Frontend:** [`plugins-settings.test.tsx`](../../surfsense_local/frontend/src/features/plugins/plugins-settings.test.tsx) drives the screen against a stand-in API: listing, adding with a key, a refusal, the tool and plugin switches, approval, disconnecting, the third-party consent, the check's status with Check now, replacing a refused key, tools marked New or Changed, SurfSense's plugins listed and connected, each license status with License settings, the tab it opens on, each row's type and status, a plugin's details and Back, the empty state, one Add custom plugin button, Browse's order with the switch in Third-party, the registry fetched only once allowed and only with its host allowed, its entries listed and connected with a key or a sign-in, Cancel and a failed sign-in, a server by URL that signs in, Sign in again, and a removed plugin's reason. [`external-url.test.ts`](../../surfsense_local/electron/src/main/external-url.test.ts) holds which sign-in pages Electron opens. [`plugin-step-line.test.tsx`](../../surfsense_local/frontend/src/features/plugins/plugin-step-line.test.tsx) holds a plugin step's name, its whole result and Save to Sources.
|
||||
|
||||
## Known gaps
|
||||
|
||||
|
|
|
|||
113
surfsense_local/frontend/src/components/ui/table.tsx
Normal file
113
surfsense_local/frontend/src/components/ui/table.tsx
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
import * as React from "react"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Table({ className, ...props }: React.ComponentProps<"table">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="table-container"
|
||||
className="relative w-full overflow-x-auto"
|
||||
>
|
||||
<table
|
||||
data-slot="table"
|
||||
className={cn("w-full caption-bottom text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
|
||||
return (
|
||||
<thead
|
||||
data-slot="table-header"
|
||||
className={cn("[&_tr]:border-b", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
|
||||
return (
|
||||
<tbody
|
||||
data-slot="table-body"
|
||||
className={cn("[&_tr:last-child]:border-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
|
||||
return (
|
||||
<tfoot
|
||||
data-slot="table-footer"
|
||||
className={cn(
|
||||
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
|
||||
return (
|
||||
<tr
|
||||
data-slot="table-row"
|
||||
className={cn(
|
||||
"border-b transition-colors hover:bg-muted/50 has-aria-expanded:bg-muted/50 data-[state=selected]:bg-muted",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
|
||||
return (
|
||||
<th
|
||||
data-slot="table-head"
|
||||
className={cn(
|
||||
"h-10 px-2 text-left align-middle font-medium whitespace-nowrap text-foreground [&:has([role=checkbox])]:pr-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
|
||||
return (
|
||||
<td
|
||||
data-slot="table-cell"
|
||||
className={cn(
|
||||
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableCaption({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"caption">) {
|
||||
return (
|
||||
<caption
|
||||
data-slot="table-caption"
|
||||
className={cn("mt-4 text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Table,
|
||||
TableHeader,
|
||||
TableBody,
|
||||
TableFooter,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TableCell,
|
||||
TableCaption,
|
||||
}
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
import { FirstPartyPlugins } from "./first-party-plugins"
|
||||
import { ThirdPartyPlugins } from "./third-party-plugins"
|
||||
|
||||
/** Plugins to connect: SurfSense's own first, then the registry's. */
|
||||
export function BrowsePlugins() {
|
||||
return (
|
||||
<>
|
||||
<FirstPartyPlugins />
|
||||
<ThirdPartyPlugins />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,121 @@
|
|||
import { useState } from "react"
|
||||
|
||||
import { ChevronRightIcon } from "@/components/ui/icons"
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table"
|
||||
import { intl } from "@/i18n/intl"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
import type { Plugin } from "./api"
|
||||
import { PluginIcon } from "./plugin-icon"
|
||||
import { pluginStatus } from "./plugin-status"
|
||||
import { pluginTypeLabel } from "./plugin-type-label"
|
||||
|
||||
const TONE = {
|
||||
failure: "text-destructive",
|
||||
idle: "text-muted-foreground",
|
||||
ok: "text-foreground",
|
||||
} as const
|
||||
|
||||
/** One row per connected plugin; the name is a button that opens its details, stretched over the row. */
|
||||
|
||||
// A table row cannot be rounded, so its cells carry the hover and the first and last round it.
|
||||
const CELL =
|
||||
"py-2.5 transition-colors group-hover/row:bg-muted/50 first:rounded-l-lg last:rounded-r-lg"
|
||||
export function ConnectedPluginsTable({
|
||||
plugins,
|
||||
thirdPartyAllowed,
|
||||
onOpen,
|
||||
}: {
|
||||
plugins: Plugin[]
|
||||
thirdPartyAllowed: boolean
|
||||
onOpen: (id: string) => void
|
||||
}) {
|
||||
// As in the chats dialog: the line between two rows shows only while neither is hovered.
|
||||
const [hoveredId, setHoveredId] = useState<string | null>(null)
|
||||
const line = (above: Plugin | undefined, below: Plugin | undefined) =>
|
||||
above?.id !== hoveredId && below?.id !== hoveredId
|
||||
? "border-border/60"
|
||||
: "border-transparent"
|
||||
const leave = (id: string) =>
|
||||
setHoveredId((current) => (current === id ? null : current))
|
||||
|
||||
return (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow
|
||||
className={cn("hover:bg-transparent", line(undefined, plugins[0]))}
|
||||
>
|
||||
<TableHead className="text-muted-foreground">
|
||||
{intl.formatMessage({
|
||||
id: "plugins_list_plugin_label",
|
||||
defaultMessage: "Plugin",
|
||||
})}
|
||||
</TableHead>
|
||||
<TableHead className="text-muted-foreground">
|
||||
{intl.formatMessage({
|
||||
id: "plugins_list_type_label",
|
||||
defaultMessage: "Type",
|
||||
})}
|
||||
</TableHead>
|
||||
<TableHead className="text-muted-foreground">
|
||||
{intl.formatMessage({
|
||||
id: "plugins_list_status_label",
|
||||
defaultMessage: "Status",
|
||||
})}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{plugins.map((plugin, index) => {
|
||||
const status = pluginStatus(plugin, thirdPartyAllowed)
|
||||
return (
|
||||
<TableRow
|
||||
key={plugin.id}
|
||||
className={cn(
|
||||
"group/row relative hover:bg-transparent",
|
||||
// The row's own bottom line; TableBody drops the last row's.
|
||||
line(plugin, plugins[index + 1])
|
||||
)}
|
||||
onMouseEnter={() => setHoveredId(plugin.id)}
|
||||
onMouseLeave={() => leave(plugin.id)}
|
||||
>
|
||||
<TableCell className={CELL}>
|
||||
<button
|
||||
type="button"
|
||||
className="flex max-w-full min-w-0 items-center gap-3 text-left font-medium outline-none after:absolute after:inset-0 after:rounded-lg focus-visible:after:ring-2 focus-visible:after:ring-ring"
|
||||
onClick={() => onOpen(plugin.id)}
|
||||
>
|
||||
<PluginIcon
|
||||
id={plugin.id}
|
||||
name={plugin.name}
|
||||
hasIcon={plugin.source === "registry"}
|
||||
/>
|
||||
<span className="truncate">{plugin.name}</span>
|
||||
</button>
|
||||
</TableCell>
|
||||
<TableCell className={cn(CELL, "text-muted-foreground")}>
|
||||
{pluginTypeLabel(plugin.source)}
|
||||
</TableCell>
|
||||
<TableCell className={cn(CELL, TONE[status.tone])}>
|
||||
<span className="flex items-center justify-between gap-2">
|
||||
{status.label}
|
||||
<ChevronRightIcon
|
||||
aria-hidden
|
||||
className="size-4 shrink-0 text-muted-foreground"
|
||||
/>
|
||||
</span>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Empty,
|
||||
EmptyContent,
|
||||
EmptyDescription,
|
||||
EmptyHeader,
|
||||
EmptyTitle,
|
||||
} from "@/components/ui/empty"
|
||||
import { PlusIcon } from "@/components/ui/icons"
|
||||
import { intl } from "@/i18n/intl"
|
||||
|
||||
export function NoPluginsConnected({
|
||||
onBrowse,
|
||||
onAdd,
|
||||
}: {
|
||||
onBrowse: () => void
|
||||
onAdd: () => void
|
||||
}) {
|
||||
return (
|
||||
<Empty className="border py-10">
|
||||
<EmptyHeader>
|
||||
<EmptyTitle>
|
||||
{intl.formatMessage({
|
||||
id: "plugins_empty_title",
|
||||
defaultMessage: "No plugins connected",
|
||||
})}
|
||||
</EmptyTitle>
|
||||
<EmptyDescription>
|
||||
{intl.formatMessage({
|
||||
id: "plugins_empty_body",
|
||||
defaultMessage:
|
||||
"Give the agent tools: browse plugins, or add your own server.",
|
||||
})}
|
||||
</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
<EmptyContent className="flex-row justify-center">
|
||||
<Button type="button" size="sm" onClick={onBrowse}>
|
||||
{intl.formatMessage({
|
||||
id: "plugins_empty_browse_button",
|
||||
defaultMessage: "Browse plugins",
|
||||
})}
|
||||
</Button>
|
||||
<Button type="button" size="sm" variant="outline" onClick={onAdd}>
|
||||
<PlusIcon data-icon="inline-start" />
|
||||
{intl.formatMessage({
|
||||
id: "plugins_empty_add_button",
|
||||
defaultMessage: "Add custom plugin",
|
||||
})}
|
||||
</Button>
|
||||
</EmptyContent>
|
||||
</Empty>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,54 @@
|
|||
import { Button } from "@/components/ui/button"
|
||||
import { ArrowLeftIcon } from "@/components/ui/icons"
|
||||
import { intl } from "@/i18n/intl"
|
||||
|
||||
import type { Plugin } from "./api"
|
||||
import { PluginRow } from "./plugin-row"
|
||||
import { usePluginChanges } from "./use-plugin-changes"
|
||||
|
||||
/** One connected plugin, shown in place of the list until Back. */
|
||||
export function PluginDetails({
|
||||
plugin,
|
||||
thirdPartyAllowed,
|
||||
onBack,
|
||||
onOpenLicense,
|
||||
}: {
|
||||
plugin: Plugin
|
||||
thirdPartyAllowed: boolean
|
||||
onBack: () => void
|
||||
onOpenLicense?: () => void
|
||||
}) {
|
||||
const changes = usePluginChanges()
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="-ml-2 self-start text-muted-foreground"
|
||||
onClick={onBack}
|
||||
>
|
||||
<ArrowLeftIcon data-icon="inline-start" />
|
||||
{intl.formatMessage({
|
||||
id: "plugins_details_back_button",
|
||||
defaultMessage: "Back",
|
||||
})}
|
||||
</Button>
|
||||
<PluginRow
|
||||
plugin={plugin}
|
||||
thirdPartyAllowed={thirdPartyAllowed}
|
||||
disconnecting={changes.disconnect.isPending}
|
||||
checking={changes.check.isPending}
|
||||
onCheck={() => changes.check.mutate(plugin.id)}
|
||||
onEnabled={(enabled) =>
|
||||
changes.plugin.mutate({ plugin: plugin.id, enabled })
|
||||
}
|
||||
onTool={(tool, change) =>
|
||||
changes.tool.mutate({ plugin: plugin.id, tool, change })
|
||||
}
|
||||
onDisconnect={() => changes.disconnect.mutate(plugin.id)}
|
||||
onOpenLicense={onOpenLicense}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -7,6 +7,7 @@ import { intl } from "@/i18n/intl"
|
|||
|
||||
import type { Plugin, PluginTool, ToolApproval } from "./api"
|
||||
import { DisconnectPluginButton } from "./disconnect-plugin-button"
|
||||
import { PluginIcon } from "./plugin-icon"
|
||||
import { PluginToolRow } from "./plugin-tool-row"
|
||||
import { ReplaceKeyButton } from "./replace-key-button"
|
||||
import { SignInAgainButton } from "./sign-in-again-button"
|
||||
|
|
@ -29,7 +30,7 @@ function ToolGroup({
|
|||
const labelId = useId()
|
||||
if (tools.length === 0) return null
|
||||
return (
|
||||
<div role="group" aria-labelledby={labelId} className="mt-3">
|
||||
<div role="group" aria-labelledby={labelId} className="mt-5">
|
||||
<p id={labelId} className="text-xs font-medium text-muted-foreground">
|
||||
{label}
|
||||
</p>
|
||||
|
|
@ -97,6 +98,32 @@ function checkLabel(plugin: Plugin) {
|
|||
})
|
||||
}
|
||||
|
||||
function typeAndHost(source: Plugin["source"], host: string) {
|
||||
switch (source) {
|
||||
case "first_party":
|
||||
return intl.formatMessage(
|
||||
{
|
||||
id: "plugins_row_first_party_label",
|
||||
defaultMessage: "By SurfSense · {host}",
|
||||
},
|
||||
{ host }
|
||||
)
|
||||
case "registry":
|
||||
return intl.formatMessage(
|
||||
{
|
||||
id: "plugins_row_third_party_label",
|
||||
defaultMessage: "Third-party · {host}",
|
||||
},
|
||||
{ host }
|
||||
)
|
||||
case "custom":
|
||||
return intl.formatMessage(
|
||||
{ id: "plugins_row_custom_label", defaultMessage: "Custom · {host}" },
|
||||
{ host }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const LICENSE_FAILURES = new Set<Plugin["check_failure"]>([
|
||||
"needs_license",
|
||||
"license_expired",
|
||||
|
|
@ -104,7 +131,7 @@ const LICENSE_FAILURES = new Set<Plugin["check_failure"]>([
|
|||
"license_invalid",
|
||||
])
|
||||
|
||||
/** A connected plugin: its switch, its tools, and the way to disconnect it. */
|
||||
/** A connected plugin's details: its switch, its tools, and the way to disconnect it. */
|
||||
export function PluginRow({
|
||||
plugin,
|
||||
thirdPartyAllowed,
|
||||
|
|
@ -133,29 +160,22 @@ export function PluginRow({
|
|||
const host = plugin.hosts[0] ?? plugin.url
|
||||
const unused = plugin.source !== "first_party" && !thirdPartyAllowed
|
||||
return (
|
||||
<section aria-labelledby={nameId} className="rounded-xl border px-4 py-3">
|
||||
<section aria-labelledby={nameId}>
|
||||
<div className="flex items-center justify-between gap-6">
|
||||
<div className="min-w-0">
|
||||
<h3 id={nameId} className="truncate text-sm font-medium">
|
||||
{plugin.name}
|
||||
</h3>
|
||||
<p className="truncate text-xs text-muted-foreground">
|
||||
{plugin.source === "first_party"
|
||||
? intl.formatMessage(
|
||||
{
|
||||
id: "plugins_row_first_party_label",
|
||||
defaultMessage: "By SurfSense · {host}",
|
||||
},
|
||||
{ host }
|
||||
)
|
||||
: intl.formatMessage(
|
||||
{
|
||||
id: "plugins_row_third_party_label",
|
||||
defaultMessage: "Third-party · {host}",
|
||||
},
|
||||
{ host }
|
||||
)}
|
||||
</p>
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<PluginIcon
|
||||
id={plugin.id}
|
||||
name={plugin.name}
|
||||
hasIcon={plugin.source === "registry"}
|
||||
/>
|
||||
<div className="min-w-0">
|
||||
<h3 id={nameId} className="truncate text-base font-medium">
|
||||
{plugin.name}
|
||||
</h3>
|
||||
<p className="truncate text-xs text-muted-foreground">
|
||||
{typeAndHost(plugin.source, host)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<DisconnectPluginButton
|
||||
|
|
@ -173,7 +193,7 @@ export function PluginRow({
|
|||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-1 flex items-center gap-2 text-xs">
|
||||
<div className="mt-4 flex items-center gap-2 text-xs">
|
||||
<span
|
||||
className={
|
||||
plugin.check_failure ? "text-destructive" : "text-muted-foreground"
|
||||
|
|
|
|||
110
surfsense_local/frontend/src/features/plugins/plugin-status.ts
Normal file
110
surfsense_local/frontend/src/features/plugins/plugin-status.ts
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
import { intl } from "@/i18n/intl"
|
||||
|
||||
import type { Plugin } from "./api"
|
||||
|
||||
export type PluginStatus = {
|
||||
label: string
|
||||
/** `failure` needs the user; `idle` is not in use; `ok` is working. */
|
||||
tone: "failure" | "idle" | "ok"
|
||||
}
|
||||
|
||||
/** One short state per plugin: what needs fixing first, then what keeps it unused, then what waits on the user. */
|
||||
export function pluginStatus(
|
||||
plugin: Plugin,
|
||||
thirdPartyAllowed: boolean
|
||||
): PluginStatus {
|
||||
const failure = (label: string): PluginStatus => ({ label, tone: "failure" })
|
||||
if (plugin.removed_reason) {
|
||||
return failure(
|
||||
intl.formatMessage({
|
||||
id: "plugins_list_removed_status",
|
||||
defaultMessage: "No longer listed",
|
||||
})
|
||||
)
|
||||
}
|
||||
switch (plugin.check_failure) {
|
||||
case "unreachable":
|
||||
return failure(
|
||||
intl.formatMessage({
|
||||
id: "plugins_list_unreachable_status",
|
||||
defaultMessage: "Couldn’t connect",
|
||||
})
|
||||
)
|
||||
case "sign_in":
|
||||
return failure(
|
||||
plugin.auth_type === "oauth"
|
||||
? intl.formatMessage({
|
||||
id: "plugins_list_signed_out_status",
|
||||
defaultMessage: "Signed out",
|
||||
})
|
||||
: intl.formatMessage({
|
||||
id: "plugins_list_sign_in_status",
|
||||
defaultMessage: "Needs a new key",
|
||||
})
|
||||
)
|
||||
case "needs_license":
|
||||
return failure(
|
||||
intl.formatMessage({
|
||||
id: "plugins_list_needs_license_status",
|
||||
defaultMessage: "Needs a license",
|
||||
})
|
||||
)
|
||||
case "license_expired":
|
||||
return failure(
|
||||
intl.formatMessage({
|
||||
id: "plugins_list_license_expired_status",
|
||||
defaultMessage: "License expired",
|
||||
})
|
||||
)
|
||||
case "license_revoked":
|
||||
return failure(
|
||||
intl.formatMessage({
|
||||
id: "plugins_list_license_revoked_status",
|
||||
defaultMessage: "License revoked",
|
||||
})
|
||||
)
|
||||
case "license_invalid":
|
||||
return failure(
|
||||
intl.formatMessage({
|
||||
id: "plugins_list_license_invalid_status",
|
||||
defaultMessage: "License not recognized",
|
||||
})
|
||||
)
|
||||
case null:
|
||||
break
|
||||
}
|
||||
if (plugin.source !== "first_party" && !thirdPartyAllowed) {
|
||||
return {
|
||||
label: intl.formatMessage({
|
||||
id: "plugins_list_third_party_off_status",
|
||||
defaultMessage: "Third-party plugins are off",
|
||||
}),
|
||||
tone: "idle",
|
||||
}
|
||||
}
|
||||
if (!plugin.enabled) {
|
||||
return {
|
||||
label: intl.formatMessage({
|
||||
id: "plugins_list_paused_status",
|
||||
defaultMessage: "Paused",
|
||||
}),
|
||||
tone: "idle",
|
||||
}
|
||||
}
|
||||
if (plugin.tools.some((tool) => tool.change)) {
|
||||
return {
|
||||
label: intl.formatMessage({
|
||||
id: "plugins_list_new_tools_status",
|
||||
defaultMessage: "New tools",
|
||||
}),
|
||||
tone: "ok",
|
||||
}
|
||||
}
|
||||
return {
|
||||
label: intl.formatMessage({
|
||||
id: "plugins_list_on_status",
|
||||
defaultMessage: "On",
|
||||
}),
|
||||
tone: "ok",
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
import { intl } from "@/i18n/intl"
|
||||
|
||||
import type { Plugin } from "./api"
|
||||
|
||||
/** Who publishes the plugin, as the list of connected plugins names it. */
|
||||
export function pluginTypeLabel(source: Plugin["source"]): string {
|
||||
switch (source) {
|
||||
case "first_party":
|
||||
return intl.formatMessage({
|
||||
id: "plugins_list_first_party_label",
|
||||
defaultMessage: "By SurfSense",
|
||||
})
|
||||
case "registry":
|
||||
return intl.formatMessage({
|
||||
id: "plugins_list_third_party_label",
|
||||
defaultMessage: "Third-party",
|
||||
})
|
||||
case "custom":
|
||||
return intl.formatMessage({
|
||||
id: "plugins_list_custom_label",
|
||||
defaultMessage: "Custom",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -301,6 +301,22 @@ function browser() {
|
|||
// A sign-in is polled each second; its end shows on the second poll.
|
||||
const AFTER_POLLS = { timeout: 4000 }
|
||||
|
||||
type User = ReturnType<typeof userEvent.setup>
|
||||
|
||||
/** Opens a connected plugin's details from the Yours list. */
|
||||
async function details(user: User, name: string) {
|
||||
await user.click(await screen.findByRole("button", { name }))
|
||||
return screen.getByRole("region", { name })
|
||||
}
|
||||
|
||||
async function browse(user: User) {
|
||||
await user.click(await screen.findByRole("tab", { name: "Browse" }))
|
||||
}
|
||||
|
||||
async function yours(user: User) {
|
||||
await user.click(screen.getByRole("tab", { name: "Yours" }))
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.unstubAllGlobals()
|
||||
|
|
@ -308,11 +324,185 @@ afterEach(() => {
|
|||
})
|
||||
|
||||
describe("plugins settings", () => {
|
||||
it("lists each plugin's tools, the read-only ones apart", async () => {
|
||||
api({ allowed: true })
|
||||
it("opens on Yours when a plugin is connected", async () => {
|
||||
api()
|
||||
render(<PluginsSettings />)
|
||||
|
||||
const plugin = await screen.findByRole("region", { name: "Notes" })
|
||||
expect(await screen.findByRole("row", { name: /Notes/ })).toBeTruthy()
|
||||
expect(
|
||||
screen.getByRole("tab", { name: "Yours" }).getAttribute("aria-selected")
|
||||
).toBe("true")
|
||||
})
|
||||
|
||||
it("shows each connected plugin's type and status, failures first", async () => {
|
||||
const base = notes()
|
||||
api({
|
||||
allowed: true,
|
||||
plugins: [
|
||||
notes(),
|
||||
notes({
|
||||
id: "reddit",
|
||||
name: "Reddit",
|
||||
source: "first_party",
|
||||
check_failure: "needs_license",
|
||||
enabled: false,
|
||||
}),
|
||||
notes({ id: "wiki", name: "Wiki", source: "registry", enabled: false }),
|
||||
notes({
|
||||
id: "acme",
|
||||
name: "Acme",
|
||||
source: "registry",
|
||||
removed_reason: "Gone.",
|
||||
check_failure: "unreachable",
|
||||
}),
|
||||
notes({
|
||||
id: "docs",
|
||||
name: "Docs",
|
||||
tools: [{ ...base.tools[0], enabled: false, change: "added" }],
|
||||
}),
|
||||
],
|
||||
})
|
||||
render(<PluginsSettings />)
|
||||
|
||||
const table = await screen.findByRole("table")
|
||||
expect(
|
||||
within(table)
|
||||
.getAllByRole("columnheader")
|
||||
.map((header) => header.textContent)
|
||||
).toEqual(["Plugin", "Type", "Status"])
|
||||
for (const name of [
|
||||
"Notes Custom On",
|
||||
"Reddit By SurfSense Needs a license",
|
||||
"Wiki Third-party Paused",
|
||||
"Acme Third-party No longer listed",
|
||||
"Docs Custom New tools",
|
||||
]) {
|
||||
expect(within(table).getByRole("row", { name })).toBeTruthy()
|
||||
}
|
||||
})
|
||||
|
||||
it("opens a plugin's details in place of the list, and goes back", async () => {
|
||||
api({ allowed: true })
|
||||
const user = userEvent.setup()
|
||||
render(<PluginsSettings />)
|
||||
|
||||
await user.click(await screen.findByRole("button", { name: "Notes" }))
|
||||
const plugin = screen.getByRole("region", { name: "Notes" })
|
||||
expect(within(plugin).getByText("Custom · notes.example")).toBeTruthy()
|
||||
expect(screen.queryByRole("table")).toBeNull()
|
||||
await user.click(screen.getByRole("button", { name: "Back" }))
|
||||
|
||||
expect(screen.getByRole("row", { name: "Notes Custom On" })).toBeTruthy()
|
||||
expect(screen.queryByRole("region", { name: "Notes" })).toBeNull()
|
||||
})
|
||||
|
||||
it("offers to browse or add a plugin when none is connected", async () => {
|
||||
api({ plugins: [] })
|
||||
const user = userEvent.setup()
|
||||
render(<PluginsSettings />)
|
||||
|
||||
await screen.findByRole("region", { name: "By SurfSense" })
|
||||
await user.click(screen.getByRole("tab", { name: "Yours" }))
|
||||
expect(screen.getByText("No plugins connected")).toBeTruthy()
|
||||
const panel = screen.getByRole("tabpanel", { name: "Yours" })
|
||||
await user.click(
|
||||
within(panel).getByRole("button", { name: "Add custom plugin" })
|
||||
)
|
||||
expect(await screen.findByRole("dialog")).toBeTruthy()
|
||||
await user.keyboard("{Escape}")
|
||||
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull())
|
||||
|
||||
await user.click(
|
||||
within(panel).getByRole("button", { name: "Browse plugins" })
|
||||
)
|
||||
expect(
|
||||
screen.getByRole("tab", { name: "Browse" }).getAttribute("aria-selected")
|
||||
).toBe("true")
|
||||
})
|
||||
|
||||
it("has one Add custom plugin button, beside the tabs on both", async () => {
|
||||
api({ allowed: true })
|
||||
const user = userEvent.setup()
|
||||
render(<PluginsSettings />)
|
||||
|
||||
await user.click(await screen.findByRole("button", { name: "Notes" }))
|
||||
expect(
|
||||
screen.getAllByRole("button", { name: "Add custom plugin" })
|
||||
).toHaveLength(1)
|
||||
await user.click(screen.getByRole("tab", { name: "Browse" }))
|
||||
await screen.findByRole("region", { name: "By SurfSense" })
|
||||
expect(
|
||||
screen.getAllByRole("button", { name: "Add custom plugin" })
|
||||
).toHaveLength(1)
|
||||
expect(screen.queryByRole("button", { name: "Add MCP server" })).toBeNull()
|
||||
})
|
||||
|
||||
it("browses SurfSense’s plugins first, then third-party ones under their switch", async () => {
|
||||
api({ allowed: false, registry: [listed()] })
|
||||
const user = userEvent.setup()
|
||||
render(<PluginsSettings />)
|
||||
|
||||
await user.click(await screen.findByRole("tab", { name: "Browse" }))
|
||||
const surfsense = await screen.findByRole("region", {
|
||||
name: "By SurfSense",
|
||||
})
|
||||
const thirdParty = screen.getByRole("region", { name: "Third-party" })
|
||||
expect(
|
||||
surfsense.compareDocumentPosition(thirdParty) &
|
||||
Node.DOCUMENT_POSITION_FOLLOWING
|
||||
).toBeTruthy()
|
||||
expect(
|
||||
within(thirdParty).getByText(
|
||||
"Plugins from other publishers, and servers you add by URL. Off until you allow them."
|
||||
)
|
||||
).toBeTruthy()
|
||||
expect(within(thirdParty).queryByRole("list")).toBeNull()
|
||||
|
||||
await user.click(
|
||||
within(thirdParty).getByRole("switch", {
|
||||
name: "Allow third-party plugins",
|
||||
})
|
||||
)
|
||||
await user.click(
|
||||
within(await screen.findByRole("alertdialog")).getByRole("button", {
|
||||
name: "Allow",
|
||||
})
|
||||
)
|
||||
expect(
|
||||
await within(thirdParty).findByRole("listitem", { name: "Wiki" })
|
||||
).toBeTruthy()
|
||||
})
|
||||
|
||||
it("says a third-party plugin is unused while third-party plugins are off", async () => {
|
||||
api({ plugins: [notes({ enabled: false })] })
|
||||
render(<PluginsSettings />)
|
||||
|
||||
const row = await screen.findByRole("row", { name: /Notes/ })
|
||||
expect(within(row).getByText("Third-party plugins are off")).toBeTruthy()
|
||||
})
|
||||
|
||||
it("opens on Browse when nothing is connected", async () => {
|
||||
api({ plugins: [] })
|
||||
render(<PluginsSettings />)
|
||||
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen
|
||||
.getByRole("tab", { name: "Browse" })
|
||||
.getAttribute("aria-selected")
|
||||
).toBe("true")
|
||||
)
|
||||
expect(
|
||||
await screen.findByRole("region", { name: "By SurfSense" })
|
||||
).toBeTruthy()
|
||||
})
|
||||
|
||||
it("lists each plugin's tools, the read-only ones apart", async () => {
|
||||
api({ allowed: true })
|
||||
const user = userEvent.setup()
|
||||
render(<PluginsSettings />)
|
||||
|
||||
const plugin = await details(user, "Notes")
|
||||
const readOnly = within(plugin).getByRole("group", { name: "Read-only" })
|
||||
const others = within(plugin).getByRole("group", {
|
||||
name: "Can make changes",
|
||||
|
|
@ -328,7 +518,7 @@ describe("plugins settings", () => {
|
|||
render(<PluginsSettings />)
|
||||
|
||||
await user.click(
|
||||
await screen.findByRole("button", { name: "Add MCP server" })
|
||||
await screen.findByRole("button", { name: "Add custom plugin" })
|
||||
)
|
||||
const dialog = await screen.findByRole("dialog")
|
||||
await user.type(within(dialog).getByLabelText("Name"), "Notes")
|
||||
|
|
@ -339,7 +529,9 @@ describe("plugins settings", () => {
|
|||
await user.type(within(dialog).getByLabelText("Key"), "k-1")
|
||||
await user.click(within(dialog).getByRole("button", { name: "Connect" }))
|
||||
|
||||
expect(await screen.findByRole("region", { name: "Notes" })).toBeTruthy()
|
||||
expect(
|
||||
await screen.findByRole("row", { name: "Notes Custom On" })
|
||||
).toBeTruthy()
|
||||
expect(changes(calls)).toEqual([
|
||||
{
|
||||
method: "POST",
|
||||
|
|
@ -363,7 +555,7 @@ describe("plugins settings", () => {
|
|||
render(<PluginsSettings />)
|
||||
|
||||
await user.click(
|
||||
await screen.findByRole("button", { name: "Add MCP server" })
|
||||
await screen.findByRole("button", { name: "Add custom plugin" })
|
||||
)
|
||||
const dialog = await screen.findByRole("dialog")
|
||||
await user.type(within(dialog).getByLabelText("Name"), "Notes")
|
||||
|
|
@ -383,7 +575,7 @@ describe("plugins settings", () => {
|
|||
const user = userEvent.setup()
|
||||
render(<PluginsSettings />)
|
||||
|
||||
const plugin = await screen.findByRole("region", { name: "Notes" })
|
||||
const plugin = await details(user, "Notes")
|
||||
await user.click(within(plugin).getByRole("switch", { name: "Use count" }))
|
||||
await user.click(
|
||||
within(plugin).getByRole("radio", { name: "Always allow Search notes" })
|
||||
|
|
@ -414,7 +606,7 @@ describe("plugins settings", () => {
|
|||
const user = userEvent.setup()
|
||||
render(<PluginsSettings />)
|
||||
|
||||
const plugin = await screen.findByRole("region", { name: "Notes" })
|
||||
const plugin = await details(user, "Notes")
|
||||
await user.click(within(plugin).getByRole("switch", { name: "Use Notes" }))
|
||||
|
||||
await waitFor(() =>
|
||||
|
|
@ -429,7 +621,7 @@ describe("plugins settings", () => {
|
|||
const user = userEvent.setup()
|
||||
render(<PluginsSettings />)
|
||||
|
||||
const plugin = await screen.findByRole("region", { name: "Notes" })
|
||||
const plugin = await details(user, "Notes")
|
||||
await user.click(
|
||||
within(plugin).getByRole("button", { name: "Disconnect Notes" })
|
||||
)
|
||||
|
|
@ -439,9 +631,8 @@ describe("plugins settings", () => {
|
|||
within(confirm).getByRole("button", { name: "Disconnect" })
|
||||
)
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByRole("region", { name: "Notes" })).toBeNull()
|
||||
)
|
||||
expect(await screen.findByText("No plugins connected")).toBeTruthy()
|
||||
expect(screen.queryByRole("region", { name: "Notes" })).toBeNull()
|
||||
expect(changes(calls)).toEqual([
|
||||
{ method: "DELETE", path: "/plugins/notes", body: null },
|
||||
])
|
||||
|
|
@ -452,12 +643,13 @@ describe("plugins settings", () => {
|
|||
const user = userEvent.setup()
|
||||
render(<PluginsSettings />)
|
||||
|
||||
const plugin = await screen.findByRole("region", { name: "Notes" })
|
||||
const plugin = await details(user, "Notes")
|
||||
expect(
|
||||
within(plugin).getByText("Not used until third-party plugins are allowed")
|
||||
).toBeTruthy()
|
||||
await browse(user)
|
||||
await user.click(
|
||||
screen.getByRole("switch", { name: "Allow third-party plugins" })
|
||||
await screen.findByRole("switch", { name: "Allow third-party plugins" })
|
||||
)
|
||||
const consent = await screen.findByRole("alertdialog")
|
||||
expect(changes(calls)).toEqual([])
|
||||
|
|
@ -472,13 +664,10 @@ describe("plugins settings", () => {
|
|||
},
|
||||
])
|
||||
)
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
within(plugin).queryByText(
|
||||
"Not used until third-party plugins are allowed"
|
||||
)
|
||||
).toBeNull()
|
||||
)
|
||||
await yours(user)
|
||||
expect(
|
||||
await screen.findByRole("row", { name: "Notes Custom On" })
|
||||
).toBeTruthy()
|
||||
})
|
||||
|
||||
it("says a server could not be reached, and checks it again on request", async () => {
|
||||
|
|
@ -489,7 +678,7 @@ describe("plugins settings", () => {
|
|||
const user = userEvent.setup()
|
||||
render(<PluginsSettings />)
|
||||
|
||||
const plugin = await screen.findByRole("region", { name: "Notes" })
|
||||
const plugin = await details(user, "Notes")
|
||||
expect(within(plugin).getByText("Couldn’t connect")).toBeTruthy()
|
||||
await user.click(within(plugin).getByRole("button", { name: "Check now" }))
|
||||
|
||||
|
|
@ -507,7 +696,7 @@ describe("plugins settings", () => {
|
|||
const user = userEvent.setup()
|
||||
render(<PluginsSettings />)
|
||||
|
||||
const plugin = await screen.findByRole("region", { name: "Notes" })
|
||||
const plugin = await details(user, "Notes")
|
||||
expect(within(plugin).getByText("Needs a new key")).toBeTruthy()
|
||||
await user.click(
|
||||
within(plugin).getByRole("button", { name: "Replace key" })
|
||||
|
|
@ -536,7 +725,7 @@ describe("plugins settings", () => {
|
|||
const user = userEvent.setup()
|
||||
render(<PluginsSettings />)
|
||||
|
||||
const plugin = await screen.findByRole("region", { name: "Notes" })
|
||||
const plugin = await details(user, "Notes")
|
||||
await user.click(
|
||||
within(plugin).getByRole("button", { name: "Replace key" })
|
||||
)
|
||||
|
|
@ -562,9 +751,10 @@ describe("plugins settings", () => {
|
|||
],
|
||||
allowed: true,
|
||||
})
|
||||
const user = userEvent.setup()
|
||||
render(<PluginsSettings />)
|
||||
|
||||
const plugin = await screen.findByRole("region", { name: "Notes" })
|
||||
const plugin = await details(user, "Notes")
|
||||
expect(within(plugin).getByText("Changed")).toBeTruthy()
|
||||
expect(within(plugin).getByText("New")).toBeTruthy()
|
||||
expect(
|
||||
|
|
@ -586,8 +776,13 @@ describe("plugins settings", () => {
|
|||
expect(within(surfsense).getByText("License required")).toBeTruthy()
|
||||
await user.click(within(surfsense).getByRole("button", { name: "Connect" }))
|
||||
|
||||
expect(await screen.findByRole("region", { name: "Reddit" })).toBeTruthy()
|
||||
expect(await within(surfsense).findByText("Connected")).toBeTruthy()
|
||||
await yours(user)
|
||||
expect(
|
||||
await screen.findByRole("row", {
|
||||
name: "Reddit By SurfSense Needs a license",
|
||||
})
|
||||
).toBeTruthy()
|
||||
expect(changes(calls)).toEqual([
|
||||
{ method: "POST", path: "/plugins/first-party/reddit", body: null },
|
||||
])
|
||||
|
|
@ -605,9 +800,10 @@ describe("plugins settings", () => {
|
|||
const user = userEvent.setup()
|
||||
render(<PluginsSettings onOpenLicense={onOpenLicense} />)
|
||||
|
||||
const expired = await screen.findByRole("region", { name: "Notes" })
|
||||
const expired = await details(user, "Notes")
|
||||
expect(within(expired).getByText("License expired")).toBeTruthy()
|
||||
const missing = screen.getByRole("region", { name: "Wiki" })
|
||||
await user.click(screen.getByRole("button", { name: "Back" }))
|
||||
const missing = await details(user, "Wiki")
|
||||
expect(within(missing).getByText("Needs a license")).toBeTruthy()
|
||||
await user.click(
|
||||
within(missing).getByRole("button", { name: "License settings" })
|
||||
|
|
@ -618,13 +814,16 @@ describe("plugins settings", () => {
|
|||
|
||||
it("fetches nothing from the registry while third-party plugins are off", async () => {
|
||||
const calls = api({ registry: [listed()] })
|
||||
const user = userEvent.setup()
|
||||
render(<PluginsSettings />)
|
||||
|
||||
await screen.findByRole("region", { name: "Notes" })
|
||||
await browse(user)
|
||||
const section = await screen.findByRole("region", { name: "Third-party" })
|
||||
await screen.findByRole("region", { name: "By SurfSense" })
|
||||
expect(within(section).queryByRole("listitem")).toBeNull()
|
||||
expect(
|
||||
calls.some((call) => call.path.startsWith("/plugins/registry"))
|
||||
).toBe(false)
|
||||
expect(screen.queryByRole("region", { name: "Third-party" })).toBeNull()
|
||||
})
|
||||
|
||||
it("lists the registry's plugins, verified partners and community authors, dated", async () => {
|
||||
|
|
@ -696,7 +895,10 @@ describe("plugins settings", () => {
|
|||
body: { token: "k-1" },
|
||||
},
|
||||
])
|
||||
expect(await screen.findByRole("region", { name: "Acme" })).toBeTruthy()
|
||||
await yours(user)
|
||||
expect(
|
||||
await screen.findByRole("row", { name: "Acme Third-party On" })
|
||||
).toBeTruthy()
|
||||
})
|
||||
|
||||
it("connects a registry plugin once the user signs in in the browser", async () => {
|
||||
|
|
@ -717,7 +919,10 @@ describe("plugins settings", () => {
|
|||
expect(
|
||||
await within(wiki).findByText("Connected", {}, AFTER_POLLS)
|
||||
).toBeTruthy()
|
||||
expect(await screen.findByRole("region", { name: "Wiki" })).toBeTruthy()
|
||||
await yours(user)
|
||||
expect(
|
||||
await screen.findByRole("row", { name: "Wiki Third-party On" })
|
||||
).toBeTruthy()
|
||||
expect(changes(calls)).toEqual([
|
||||
{ method: "POST", path: "/plugins/registry/wiki", body: null },
|
||||
])
|
||||
|
|
@ -781,7 +986,7 @@ describe("plugins settings", () => {
|
|||
render(<PluginsSettings />)
|
||||
|
||||
await user.click(
|
||||
await screen.findByRole("button", { name: "Add MCP server" })
|
||||
await screen.findByRole("button", { name: "Add custom plugin" })
|
||||
)
|
||||
const dialog = await screen.findByRole("dialog")
|
||||
await user.type(within(dialog).getByLabelText("Name"), "Notes")
|
||||
|
|
@ -794,7 +999,7 @@ describe("plugins settings", () => {
|
|||
expect(await within(dialog).findByText("Waiting for sign-in…")).toBeTruthy()
|
||||
expect(openExternal).toHaveBeenCalledWith(SIGN_IN_PAGE)
|
||||
expect(
|
||||
await screen.findByRole("region", { name: "Notes" }, AFTER_POLLS)
|
||||
await screen.findByRole("row", { name: "Notes Custom On" }, AFTER_POLLS)
|
||||
).toBeTruthy()
|
||||
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull())
|
||||
})
|
||||
|
|
@ -808,7 +1013,7 @@ describe("plugins settings", () => {
|
|||
const user = userEvent.setup()
|
||||
render(<PluginsSettings />)
|
||||
|
||||
const plugin = await screen.findByRole("region", { name: "Notes" })
|
||||
const plugin = await details(user, "Notes")
|
||||
expect(within(plugin).getByText("Signed out")).toBeTruthy()
|
||||
expect(
|
||||
within(plugin).queryByRole("button", { name: "Replace key" })
|
||||
|
|
@ -826,26 +1031,6 @@ describe("plugins settings", () => {
|
|||
])
|
||||
})
|
||||
|
||||
it("lists SurfSense’s plugins first, then third-party ones, then MCP servers", async () => {
|
||||
api({ allowed: true, registry: [listed()] })
|
||||
render(<PluginsSettings />)
|
||||
|
||||
const surfsense = await screen.findByRole("region", {
|
||||
name: "By SurfSense",
|
||||
})
|
||||
const thirdParty = screen.getByRole("switch", {
|
||||
name: "Allow third-party plugins",
|
||||
})
|
||||
const registry = await screen.findByRole("region", { name: "Third-party" })
|
||||
const servers = screen.getByRole("heading", { name: "MCP servers" })
|
||||
|
||||
const follows = (a: Element, b: Element) =>
|
||||
Boolean(a.compareDocumentPosition(b) & Node.DOCUMENT_POSITION_FOLLOWING)
|
||||
expect(follows(surfsense, thirdParty)).toBe(true)
|
||||
expect(follows(thirdParty, registry)).toBe(true)
|
||||
expect(follows(registry, servers)).toBe(true)
|
||||
})
|
||||
|
||||
it("asks for SurfSense’s plugin host before listing the registry", async () => {
|
||||
const calls = api({
|
||||
plugins: [],
|
||||
|
|
@ -889,9 +1074,10 @@ describe("plugins settings", () => {
|
|||
],
|
||||
allowed: true,
|
||||
})
|
||||
const user = userEvent.setup()
|
||||
render(<PluginsSettings />)
|
||||
|
||||
const plugin = await screen.findByRole("region", { name: "Notes" })
|
||||
const plugin = await details(user, "Notes")
|
||||
expect(
|
||||
within(plugin).getByText("No longer listed: The service shut down.")
|
||||
).toBeTruthy()
|
||||
|
|
|
|||
|
|
@ -3,22 +3,16 @@ import { useState } from "react"
|
|||
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { PlusIcon } from "@/components/ui/icons"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||
import { SettingsSection } from "@/features/settings/settings-section"
|
||||
import { intl } from "@/i18n/intl"
|
||||
|
||||
import {
|
||||
listPlugins,
|
||||
pluginsQueryKey,
|
||||
thirdPartyAllowed,
|
||||
thirdPartyQueryKey,
|
||||
} from "./api"
|
||||
import { listPlugins, pluginsQueryKey } from "./api"
|
||||
import { AddPluginDialog } from "./add-plugin-dialog"
|
||||
import { FirstPartyPlugins } from "./first-party-plugins"
|
||||
import { PluginRow } from "./plugin-row"
|
||||
import { RegistryPlugins } from "./registry-plugins"
|
||||
import { ThirdPartySwitch } from "./third-party-switch"
|
||||
import { usePluginChanges } from "./use-plugin-changes"
|
||||
import { BrowsePlugins } from "./browse-plugins"
|
||||
import { YourPlugins } from "./your-plugins"
|
||||
|
||||
type Tab = "yours" | "browse"
|
||||
|
||||
export function PluginsSettings({
|
||||
onOpenLicense,
|
||||
|
|
@ -32,11 +26,13 @@ export function PluginsSettings({
|
|||
queryKey: pluginsQueryKey,
|
||||
queryFn: ({ signal }) => listPlugins(signal),
|
||||
})
|
||||
const thirdParty = useQuery({
|
||||
queryKey: thirdPartyQueryKey,
|
||||
queryFn: ({ signal }) => thirdPartyAllowed(signal),
|
||||
})
|
||||
const changes = usePluginChanges()
|
||||
// Chosen once the list first answers, so connecting from Browse keeps the user there.
|
||||
const [tab, setTab] = useState<Tab | null>(null)
|
||||
if (tab === null && plugins.data) {
|
||||
setTab(plugins.data.length ? "yours" : "browse")
|
||||
} else if (tab === null && plugins.error) {
|
||||
setTab("yours")
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsSection
|
||||
|
|
@ -50,80 +46,57 @@ export function PluginsSettings({
|
|||
"Tools from MCP servers that the agent can use. Plugins work in Agentic threads.",
|
||||
})}
|
||||
>
|
||||
<FirstPartyPlugins />
|
||||
<ThirdPartySwitch
|
||||
allowed={thirdParty.data ?? false}
|
||||
onChange={(allowed) => changes.thirdParty.mutate(allowed)}
|
||||
/>
|
||||
<RegistryPlugins allowed={thirdParty.data ?? false} />
|
||||
<div className="mt-8 mb-3 flex items-center justify-between gap-4">
|
||||
<h3 className="text-sm font-medium">
|
||||
{intl.formatMessage({
|
||||
id: "plugins_settings_servers_title",
|
||||
defaultMessage: "MCP servers",
|
||||
})}
|
||||
</h3>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setAdding(true)}
|
||||
>
|
||||
<PlusIcon data-icon="inline-start" />
|
||||
{intl.formatMessage({
|
||||
id: "plugins_settings_add_button",
|
||||
defaultMessage: "Add MCP server",
|
||||
})}
|
||||
</Button>
|
||||
</div>
|
||||
{plugins.isLoading ? (
|
||||
<Skeleton className="h-24 w-full" />
|
||||
) : plugins.error ? (
|
||||
<p role="alert" className="text-sm text-destructive">
|
||||
{intl.formatMessage({
|
||||
id: "plugins_settings_load_error",
|
||||
defaultMessage: "Couldn’t load plugins.",
|
||||
})}
|
||||
</p>
|
||||
) : plugins.data?.length ? (
|
||||
<div className="flex flex-col gap-3">
|
||||
{plugins.data.map((plugin) => (
|
||||
<PluginRow
|
||||
key={plugin.id}
|
||||
plugin={plugin}
|
||||
thirdPartyAllowed={thirdParty.data ?? false}
|
||||
disconnecting={
|
||||
changes.disconnect.isPending &&
|
||||
changes.disconnect.variables === plugin.id
|
||||
}
|
||||
checking={
|
||||
changes.check.isPending && changes.check.variables === plugin.id
|
||||
}
|
||||
onCheck={() => changes.check.mutate(plugin.id)}
|
||||
onEnabled={(enabled) =>
|
||||
changes.plugin.mutate({ plugin: plugin.id, enabled })
|
||||
}
|
||||
onTool={(tool, change) =>
|
||||
changes.tool.mutate({ plugin: plugin.id, tool, change })
|
||||
}
|
||||
onDisconnect={() => changes.disconnect.mutate(plugin.id)}
|
||||
onOpenLicense={onOpenLicense}
|
||||
/>
|
||||
))}
|
||||
<Tabs
|
||||
value={tab ?? "yours"}
|
||||
onValueChange={(value: Tab) => setTab(value)}
|
||||
className="gap-5"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<TabsList activateOnFocus>
|
||||
<TabsTrigger value="yours" className="px-3">
|
||||
{intl.formatMessage({
|
||||
id: "plugins_settings_yours_label",
|
||||
defaultMessage: "Yours",
|
||||
})}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="browse" className="px-3">
|
||||
{intl.formatMessage({
|
||||
id: "plugins_settings_browse_label",
|
||||
defaultMessage: "Browse",
|
||||
})}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setAdding(true)}
|
||||
>
|
||||
<PlusIcon data-icon="inline-start" />
|
||||
{intl.formatMessage({
|
||||
id: "plugins_settings_add_custom_button",
|
||||
defaultMessage: "Add custom plugin",
|
||||
})}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{intl.formatMessage({
|
||||
id: "plugins_settings_empty",
|
||||
defaultMessage: "No MCP servers yet. Add one by its URL.",
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
<TabsContent value="yours">
|
||||
<YourPlugins
|
||||
plugins={plugins}
|
||||
onBrowse={() => setTab("browse")}
|
||||
onAdd={() => setAdding(true)}
|
||||
onOpenLicense={onOpenLicense}
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent value="browse">
|
||||
<BrowsePlugins />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
<AddPluginDialog
|
||||
open={adding}
|
||||
onOpenChange={setAdding}
|
||||
onAdded={() => {
|
||||
setAdding(false)
|
||||
setTab("yours")
|
||||
void queryClient.invalidateQueries({ queryKey: pluginsQueryKey })
|
||||
}}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -182,7 +182,6 @@ function AllowRegistryHost({
|
|||
|
||||
/** Partners' and community plugins from the signed registry; nothing is fetched while third-party plugins are off. */
|
||||
export function RegistryPlugins({ allowed }: { allowed: boolean }) {
|
||||
const titleId = useId()
|
||||
const registry = useQuery({
|
||||
queryKey: registryQueryKey,
|
||||
queryFn: ({ signal }) => listRegistry(signal),
|
||||
|
|
@ -197,26 +196,18 @@ export function RegistryPlugins({ allowed }: { allowed: boolean }) {
|
|||
: null
|
||||
const fetchedAt = registry.data?.fetched_at
|
||||
return (
|
||||
<section aria-labelledby={titleId} className="mt-8">
|
||||
<div className="flex items-baseline justify-between gap-4">
|
||||
<h3 id={titleId} className="text-sm font-medium">
|
||||
{intl.formatMessage({
|
||||
id: "plugins_registry_title",
|
||||
defaultMessage: "Third-party",
|
||||
})}
|
||||
</h3>
|
||||
{fetchedAt ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{intl.formatMessage(
|
||||
{
|
||||
id: "plugins_registry_updated_label",
|
||||
defaultMessage: "Updated {date, date, ::yyyyMMMd}",
|
||||
},
|
||||
{ date: new Date(fetchedAt) }
|
||||
)}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<>
|
||||
{fetchedAt ? (
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{intl.formatMessage(
|
||||
{
|
||||
id: "plugins_registry_updated_label",
|
||||
defaultMessage: "Updated {date, date, ::yyyyMMMd}",
|
||||
},
|
||||
{ date: new Date(fetchedAt) }
|
||||
)}
|
||||
</p>
|
||||
) : null}
|
||||
{registry.isLoading ? (
|
||||
<Skeleton className="mt-3 h-16 w-full" />
|
||||
) : refusal ? (
|
||||
|
|
@ -245,6 +236,6 @@ export function RegistryPlugins({ allowed }: { allowed: boolean }) {
|
|||
})}
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,48 @@
|
|||
import { useQuery } from "@tanstack/react-query"
|
||||
import { useId } from "react"
|
||||
|
||||
import { intl } from "@/i18n/intl"
|
||||
|
||||
import { thirdPartyAllowed, thirdPartyQueryKey } from "./api"
|
||||
import { RegistryPlugins } from "./registry-plugins"
|
||||
import { ThirdPartySwitch } from "./third-party-switch"
|
||||
import { usePluginChanges } from "./use-plugin-changes"
|
||||
|
||||
/** The registry's plugins, behind the switch that allows third-party plugins at all. */
|
||||
export function ThirdPartyPlugins() {
|
||||
const titleId = useId()
|
||||
const thirdParty = useQuery({
|
||||
queryKey: thirdPartyQueryKey,
|
||||
queryFn: ({ signal }) => thirdPartyAllowed(signal),
|
||||
})
|
||||
const changes = usePluginChanges()
|
||||
const allowed = thirdParty.data ?? false
|
||||
return (
|
||||
<section aria-labelledby={titleId}>
|
||||
<div className="flex items-start justify-between gap-8">
|
||||
<div className="flex min-w-0 flex-col gap-1">
|
||||
<h3 id={titleId} className="text-sm font-medium">
|
||||
{intl.formatMessage({
|
||||
id: "plugins_registry_title",
|
||||
defaultMessage: "Third-party",
|
||||
})}
|
||||
</h3>
|
||||
{allowed ? null : (
|
||||
<p className="text-sm text-pretty text-muted-foreground">
|
||||
{intl.formatMessage({
|
||||
id: "plugins_third_party_body",
|
||||
defaultMessage:
|
||||
"Plugins from other publishers, and servers you add by URL. Off until you allow them.",
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<ThirdPartySwitch
|
||||
allowed={allowed}
|
||||
onChange={(next) => changes.thirdParty.mutate(next)}
|
||||
/>
|
||||
</div>
|
||||
<RegistryPlugins allowed={allowed} />
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
|
@ -30,17 +30,7 @@ export function ThirdPartySwitch({
|
|||
defaultMessage: "Allow third-party plugins",
|
||||
})
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-8">
|
||||
<div className="flex flex-col gap-1">
|
||||
<h3 className="text-sm font-medium">{label}</h3>
|
||||
<p className="text-sm text-pretty text-muted-foreground">
|
||||
{intl.formatMessage({
|
||||
id: "plugins_third_party_body",
|
||||
defaultMessage:
|
||||
"Plugins from other publishers, and servers you add by URL. Off until you allow them.",
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
<>
|
||||
<Switch
|
||||
checked={allowed}
|
||||
aria-label={label}
|
||||
|
|
@ -81,6 +71,6 @@ export function ThirdPartySwitch({
|
|||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,64 @@
|
|||
import { useQuery, type UseQueryResult } from "@tanstack/react-query"
|
||||
import { useState } from "react"
|
||||
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { intl } from "@/i18n/intl"
|
||||
|
||||
import { thirdPartyAllowed, thirdPartyQueryKey, type Plugin } from "./api"
|
||||
import { ConnectedPluginsTable } from "./connected-plugins-table"
|
||||
import { NoPluginsConnected } from "./no-plugins-connected"
|
||||
import { PluginDetails } from "./plugin-details"
|
||||
|
||||
/** The plugins the user connected: a list, or one plugin's details. */
|
||||
export function YourPlugins({
|
||||
plugins,
|
||||
onBrowse,
|
||||
onAdd,
|
||||
onOpenLicense,
|
||||
}: {
|
||||
plugins: UseQueryResult<Plugin[]>
|
||||
onBrowse: () => void
|
||||
onAdd: () => void
|
||||
onOpenLicense?: () => void
|
||||
}) {
|
||||
const [openId, setOpenId] = useState<string | null>(null)
|
||||
const thirdParty = useQuery({
|
||||
queryKey: thirdPartyQueryKey,
|
||||
queryFn: ({ signal }) => thirdPartyAllowed(signal),
|
||||
})
|
||||
if (plugins.isLoading) return <Skeleton className="h-24 w-full" />
|
||||
if (plugins.error) {
|
||||
return (
|
||||
<p role="alert" className="text-sm text-destructive">
|
||||
{intl.formatMessage({
|
||||
id: "plugins_settings_load_error",
|
||||
defaultMessage: "Couldn’t load plugins.",
|
||||
})}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
const allowed = thirdParty.data ?? false
|
||||
// A plugin disconnected while open is gone from the list, which shows again.
|
||||
const open = plugins.data?.find((plugin) => plugin.id === openId)
|
||||
if (openId !== null && plugins.data && !open) setOpenId(null)
|
||||
if (open) {
|
||||
return (
|
||||
<PluginDetails
|
||||
plugin={open}
|
||||
thirdPartyAllowed={allowed}
|
||||
onBack={() => setOpenId(null)}
|
||||
onOpenLicense={onOpenLicense}
|
||||
/>
|
||||
)
|
||||
}
|
||||
if (!plugins.data?.length) {
|
||||
return <NoPluginsConnected onBrowse={onBrowse} onAdd={onAdd} />
|
||||
}
|
||||
return (
|
||||
<ConnectedPluginsTable
|
||||
plugins={plugins.data}
|
||||
thirdPartyAllowed={allowed}
|
||||
onOpen={setOpenId}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
@ -745,12 +745,17 @@
|
|||
"plugins_add_name_placeholder": "Team-Wiki",
|
||||
"plugins_add_title": "MCP-Server hinzufügen",
|
||||
"plugins_add_url_label": "Server-URL",
|
||||
"plugins_details_back_button": "Zurück",
|
||||
"plugins_disconnect_dialog_body": "Der Agent verwendet es nicht mehr, und seine Tool-Einstellungen werden entfernt. Um es nur vorübergehend anzuhalten, schalte es stattdessen aus.",
|
||||
"plugins_disconnect_dialog_cancel_button": "Abbrechen",
|
||||
"plugins_disconnect_dialog_confirm_button": "Trennen",
|
||||
"plugins_disconnect_dialog_title": "{plugin} trennen?",
|
||||
"plugins_disconnect_trigger_aria": "{plugin} trennen",
|
||||
"plugins_disconnect_trigger_button": "Trennen",
|
||||
"plugins_empty_add_button": "Eigenes Plugin hinzufügen",
|
||||
"plugins_empty_body": "Gib dem Agenten Tools: Entdecke Plugins oder füge deinen eigenen Server hinzu.",
|
||||
"plugins_empty_browse_button": "Plugins entdecken",
|
||||
"plugins_empty_title": "Keine Plugins verbunden",
|
||||
"plugins_error_egress_disabled": "SurfSense darf diesen Server nicht kontaktieren. Erlaube seinen Host, um ihn zu verbinden.",
|
||||
"plugins_error_plugin_already_connected": "Dieser Server ist bereits als {name} verbunden.",
|
||||
"plugins_error_plugin_name_taken": "Ein Plugin namens {name} ist bereits verbunden. Wähle einen anderen Namen.",
|
||||
|
|
@ -769,6 +774,24 @@
|
|||
"plugins_key_replace_button": "Schlüssel ersetzen",
|
||||
"plugins_key_save_button": "Speichern",
|
||||
"plugins_key_title": "Schlüssel für {plugin} ersetzen",
|
||||
"plugins_list_custom_label": "Eigenes",
|
||||
"plugins_list_first_party_label": "Von SurfSense",
|
||||
"plugins_list_license_expired_status": "Lizenz abgelaufen",
|
||||
"plugins_list_license_invalid_status": "Lizenz nicht erkannt",
|
||||
"plugins_list_license_revoked_status": "Lizenz widerrufen",
|
||||
"plugins_list_needs_license_status": "Braucht eine Lizenz",
|
||||
"plugins_list_new_tools_status": "Neue Tools",
|
||||
"plugins_list_on_status": "An",
|
||||
"plugins_list_paused_status": "Pausiert",
|
||||
"plugins_list_plugin_label": "Plugin",
|
||||
"plugins_list_removed_status": "Nicht mehr gelistet",
|
||||
"plugins_list_sign_in_status": "Braucht einen neuen Schlüssel",
|
||||
"plugins_list_signed_out_status": "Abgemeldet",
|
||||
"plugins_list_status_label": "Status",
|
||||
"plugins_list_third_party_label": "Drittanbieter",
|
||||
"plugins_list_third_party_off_status": "Drittanbieter-Plugins sind aus",
|
||||
"plugins_list_type_label": "Typ",
|
||||
"plugins_list_unreachable_status": "Keine Verbindung",
|
||||
"plugins_mentions_all_off_empty": "Deine Plugins sind unter Einstellungen › Plugins ausgeschaltet.",
|
||||
"plugins_mentions_aria": "Plugins",
|
||||
"plugins_mentions_empty": "Keine passenden Plugins",
|
||||
|
|
@ -794,6 +817,7 @@
|
|||
"plugins_row_changed_tools_body": "Einige Tools sind neu oder geändert. Sie bleiben aus, bis du sie erlaubst.",
|
||||
"plugins_row_check_button": "Jetzt prüfen",
|
||||
"plugins_row_connected_status": "Verbunden",
|
||||
"plugins_row_custom_label": "Eigenes · {host}",
|
||||
"plugins_row_first_party_label": "Von SurfSense · {host}",
|
||||
"plugins_row_license_expired_status": "Lizenz abgelaufen",
|
||||
"plugins_row_license_invalid_status": "Lizenz nicht erkannt",
|
||||
|
|
@ -811,12 +835,12 @@
|
|||
"plugins_row_third_party_off_body": "Wird erst verwendet, wenn Drittanbieter-Plugins erlaubt sind",
|
||||
"plugins_row_unreachable_status": "Keine Verbindung",
|
||||
"plugins_row_use_aria": "{plugin} verwenden",
|
||||
"plugins_settings_add_button": "MCP-Server hinzufügen",
|
||||
"plugins_settings_add_custom_button": "Eigenes Plugin hinzufügen",
|
||||
"plugins_settings_body": "Tools von MCP-Servern, die der Agent verwenden kann. Plugins funktionieren in Unterhaltungen im agentischen Modus.",
|
||||
"plugins_settings_empty": "Noch keine MCP-Server. Füge einen über seine URL hinzu.",
|
||||
"plugins_settings_browse_label": "Entdecken",
|
||||
"plugins_settings_load_error": "Plugins konnten nicht geladen werden.",
|
||||
"plugins_settings_servers_title": "MCP-Server",
|
||||
"plugins_settings_title": "Plugins",
|
||||
"plugins_settings_yours_label": "Deine Plugins",
|
||||
"plugins_sign_in_cancel_button": "Abbrechen",
|
||||
"plugins_sign_in_failed_error": "Die Anmeldung wurde nicht abgeschlossen. Versuche es erneut.",
|
||||
"plugins_sign_in_waiting_status": "Warte auf die Anmeldung …",
|
||||
|
|
|
|||
|
|
@ -745,12 +745,17 @@
|
|||
"plugins_add_name_placeholder": "Team wiki",
|
||||
"plugins_add_title": "Add an MCP server",
|
||||
"plugins_add_url_label": "Server URL",
|
||||
"plugins_details_back_button": "Back",
|
||||
"plugins_disconnect_dialog_body": "The agent stops using it, and its tool settings are removed. To stop it for a while instead, switch it off.",
|
||||
"plugins_disconnect_dialog_cancel_button": "Cancel",
|
||||
"plugins_disconnect_dialog_confirm_button": "Disconnect",
|
||||
"plugins_disconnect_dialog_title": "Disconnect {plugin}?",
|
||||
"plugins_disconnect_trigger_aria": "Disconnect {plugin}",
|
||||
"plugins_disconnect_trigger_button": "Disconnect",
|
||||
"plugins_empty_add_button": "Add custom plugin",
|
||||
"plugins_empty_body": "Give the agent tools: browse plugins, or add your own server.",
|
||||
"plugins_empty_browse_button": "Browse plugins",
|
||||
"plugins_empty_title": "No plugins connected",
|
||||
"plugins_error_egress_disabled": "SurfSense isn’t allowed to contact this server. Allow its host to connect it.",
|
||||
"plugins_error_plugin_already_connected": "This server is already connected as {name}.",
|
||||
"plugins_error_plugin_name_taken": "A plugin named {name} is already connected. Choose another name.",
|
||||
|
|
@ -769,6 +774,24 @@
|
|||
"plugins_key_replace_button": "Replace key",
|
||||
"plugins_key_save_button": "Save",
|
||||
"plugins_key_title": "Replace the key for {plugin}",
|
||||
"plugins_list_custom_label": "Custom",
|
||||
"plugins_list_first_party_label": "By SurfSense",
|
||||
"plugins_list_license_expired_status": "License expired",
|
||||
"plugins_list_license_invalid_status": "License not recognized",
|
||||
"plugins_list_license_revoked_status": "License revoked",
|
||||
"plugins_list_needs_license_status": "Needs a license",
|
||||
"plugins_list_new_tools_status": "New tools",
|
||||
"plugins_list_on_status": "On",
|
||||
"plugins_list_paused_status": "Paused",
|
||||
"plugins_list_plugin_label": "Plugin",
|
||||
"plugins_list_removed_status": "No longer listed",
|
||||
"plugins_list_sign_in_status": "Needs a new key",
|
||||
"plugins_list_signed_out_status": "Signed out",
|
||||
"plugins_list_status_label": "Status",
|
||||
"plugins_list_third_party_label": "Third-party",
|
||||
"plugins_list_third_party_off_status": "Third-party plugins are off",
|
||||
"plugins_list_type_label": "Type",
|
||||
"plugins_list_unreachable_status": "Couldn’t connect",
|
||||
"plugins_mentions_all_off_empty": "Your plugins are switched off in Settings › Plugins.",
|
||||
"plugins_mentions_aria": "Plugins",
|
||||
"plugins_mentions_empty": "No plugins match",
|
||||
|
|
@ -794,6 +817,7 @@
|
|||
"plugins_row_changed_tools_body": "Some tools are new or changed. They stay off until you allow them.",
|
||||
"plugins_row_check_button": "Check now",
|
||||
"plugins_row_connected_status": "Connected",
|
||||
"plugins_row_custom_label": "Custom · {host}",
|
||||
"plugins_row_first_party_label": "By SurfSense · {host}",
|
||||
"plugins_row_license_expired_status": "License expired",
|
||||
"plugins_row_license_invalid_status": "License not recognized",
|
||||
|
|
@ -811,12 +835,12 @@
|
|||
"plugins_row_third_party_off_body": "Not used until third-party plugins are allowed",
|
||||
"plugins_row_unreachable_status": "Couldn’t connect",
|
||||
"plugins_row_use_aria": "Use {plugin}",
|
||||
"plugins_settings_add_button": "Add MCP server",
|
||||
"plugins_settings_add_custom_button": "Add custom plugin",
|
||||
"plugins_settings_body": "Tools from MCP servers that the agent can use. Plugins work in Agentic threads.",
|
||||
"plugins_settings_empty": "No MCP servers yet. Add one by its URL.",
|
||||
"plugins_settings_browse_label": "Browse",
|
||||
"plugins_settings_load_error": "Couldn’t load plugins.",
|
||||
"plugins_settings_servers_title": "MCP servers",
|
||||
"plugins_settings_title": "Plugins",
|
||||
"plugins_settings_yours_label": "Yours",
|
||||
"plugins_sign_in_cancel_button": "Cancel",
|
||||
"plugins_sign_in_failed_error": "The sign-in didn’t finish. Try again.",
|
||||
"plugins_sign_in_waiting_status": "Waiting for sign-in…",
|
||||
|
|
|
|||
|
|
@ -745,12 +745,17 @@
|
|||
"plugins_add_name_placeholder": "Wiki del equipo",
|
||||
"plugins_add_title": "Añadir un servidor MCP",
|
||||
"plugins_add_url_label": "URL del servidor",
|
||||
"plugins_details_back_button": "Atrás",
|
||||
"plugins_disconnect_dialog_body": "El agente deja de usarlo y se eliminan los ajustes de sus herramientas. Para pausarlo solo un tiempo, desactívalo.",
|
||||
"plugins_disconnect_dialog_cancel_button": "Cancelar",
|
||||
"plugins_disconnect_dialog_confirm_button": "Desconectar",
|
||||
"plugins_disconnect_dialog_title": "¿Desconectar {plugin}?",
|
||||
"plugins_disconnect_trigger_aria": "Desconectar {plugin}",
|
||||
"plugins_disconnect_trigger_button": "Desconectar",
|
||||
"plugins_empty_add_button": "Añadir plugin personalizado",
|
||||
"plugins_empty_body": "Dale herramientas al agente: explora plugins o añade tu propio servidor.",
|
||||
"plugins_empty_browse_button": "Explorar plugins",
|
||||
"plugins_empty_title": "No hay plugins conectados",
|
||||
"plugins_error_egress_disabled": "SurfSense no tiene permiso para contactar este servidor. Permite su host para conectarlo.",
|
||||
"plugins_error_plugin_already_connected": "Este servidor ya está conectado como {name}.",
|
||||
"plugins_error_plugin_name_taken": "Ya hay un plugin conectado llamado {name}. Elige otro nombre.",
|
||||
|
|
@ -769,6 +774,24 @@
|
|||
"plugins_key_replace_button": "Cambiar clave",
|
||||
"plugins_key_save_button": "Guardar",
|
||||
"plugins_key_title": "Cambiar la clave de {plugin}",
|
||||
"plugins_list_custom_label": "Personalizado",
|
||||
"plugins_list_first_party_label": "De SurfSense",
|
||||
"plugins_list_license_expired_status": "Licencia vencida",
|
||||
"plugins_list_license_invalid_status": "Licencia no reconocida",
|
||||
"plugins_list_license_revoked_status": "Licencia revocada",
|
||||
"plugins_list_needs_license_status": "Necesita una licencia",
|
||||
"plugins_list_new_tools_status": "Herramientas nuevas",
|
||||
"plugins_list_on_status": "Activado",
|
||||
"plugins_list_paused_status": "En pausa",
|
||||
"plugins_list_plugin_label": "Plugin",
|
||||
"plugins_list_removed_status": "Ya no está en la lista",
|
||||
"plugins_list_sign_in_status": "Necesita una clave nueva",
|
||||
"plugins_list_signed_out_status": "Sesión cerrada",
|
||||
"plugins_list_status_label": "Estado",
|
||||
"plugins_list_third_party_label": "De terceros",
|
||||
"plugins_list_third_party_off_status": "Plugins de terceros desactivados",
|
||||
"plugins_list_type_label": "Tipo",
|
||||
"plugins_list_unreachable_status": "No se pudo conectar",
|
||||
"plugins_mentions_all_off_empty": "Tus plugins están desactivados en Ajustes › Plugins.",
|
||||
"plugins_mentions_aria": "Plugins",
|
||||
"plugins_mentions_empty": "Ningún plugin coincide",
|
||||
|
|
@ -794,6 +817,7 @@
|
|||
"plugins_row_changed_tools_body": "Algunas herramientas son nuevas o cambiaron. Siguen desactivadas hasta que las permitas.",
|
||||
"plugins_row_check_button": "Comprobar ahora",
|
||||
"plugins_row_connected_status": "Conectado",
|
||||
"plugins_row_custom_label": "Personalizado · {host}",
|
||||
"plugins_row_first_party_label": "De SurfSense · {host}",
|
||||
"plugins_row_license_expired_status": "Licencia vencida",
|
||||
"plugins_row_license_invalid_status": "Licencia no reconocida",
|
||||
|
|
@ -811,12 +835,12 @@
|
|||
"plugins_row_third_party_off_body": "No se usa hasta que permitas los plugins de terceros",
|
||||
"plugins_row_unreachable_status": "No se pudo conectar",
|
||||
"plugins_row_use_aria": "Usar {plugin}",
|
||||
"plugins_settings_add_button": "Añadir servidor MCP",
|
||||
"plugins_settings_add_custom_button": "Añadir plugin personalizado",
|
||||
"plugins_settings_body": "Herramientas de servidores MCP que el agente puede usar. Los plugins funcionan en conversaciones en modo agéntico.",
|
||||
"plugins_settings_empty": "Aún no hay servidores MCP. Añade uno con su URL.",
|
||||
"plugins_settings_browse_label": "Explorar",
|
||||
"plugins_settings_load_error": "No se pudieron cargar los plugins.",
|
||||
"plugins_settings_servers_title": "Servidores MCP",
|
||||
"plugins_settings_title": "Plugins",
|
||||
"plugins_settings_yours_label": "Tus plugins",
|
||||
"plugins_sign_in_cancel_button": "Cancelar",
|
||||
"plugins_sign_in_failed_error": "El inicio de sesión no terminó. Inténtalo de nuevo.",
|
||||
"plugins_sign_in_waiting_status": "Esperando el inicio de sesión…",
|
||||
|
|
|
|||
|
|
@ -745,12 +745,17 @@
|
|||
"plugins_add_name_placeholder": "Wiki de l’équipe",
|
||||
"plugins_add_title": "Ajouter un serveur MCP",
|
||||
"plugins_add_url_label": "URL du serveur",
|
||||
"plugins_details_back_button": "Retour",
|
||||
"plugins_disconnect_dialog_body": "L’agent cesse de l’utiliser et les réglages de ses outils sont supprimés. Pour le suspendre un temps, désactivez-le plutôt.",
|
||||
"plugins_disconnect_dialog_cancel_button": "Annuler",
|
||||
"plugins_disconnect_dialog_confirm_button": "Déconnecter",
|
||||
"plugins_disconnect_dialog_title": "Déconnecter {plugin} ?",
|
||||
"plugins_disconnect_trigger_aria": "Déconnecter {plugin}",
|
||||
"plugins_disconnect_trigger_button": "Déconnecter",
|
||||
"plugins_empty_add_button": "Ajouter un plugin personnalisé",
|
||||
"plugins_empty_body": "Donnez des outils à l’agent : parcourez les plugins ou ajoutez votre propre serveur.",
|
||||
"plugins_empty_browse_button": "Parcourir les plugins",
|
||||
"plugins_empty_title": "Aucun plugin connecté",
|
||||
"plugins_error_egress_disabled": "SurfSense n’est pas autorisé à contacter ce serveur. Autorisez son hôte pour le connecter.",
|
||||
"plugins_error_plugin_already_connected": "Ce serveur est déjà connecté sous le nom {name}.",
|
||||
"plugins_error_plugin_name_taken": "Un plugin nommé {name} est déjà connecté. Choisissez un autre nom.",
|
||||
|
|
@ -769,6 +774,24 @@
|
|||
"plugins_key_replace_button": "Remplacer la clé",
|
||||
"plugins_key_save_button": "Enregistrer",
|
||||
"plugins_key_title": "Remplacer la clé de {plugin}",
|
||||
"plugins_list_custom_label": "Personnalisé",
|
||||
"plugins_list_first_party_label": "Par SurfSense",
|
||||
"plugins_list_license_expired_status": "Licence expirée",
|
||||
"plugins_list_license_invalid_status": "Licence non reconnue",
|
||||
"plugins_list_license_revoked_status": "Licence révoquée",
|
||||
"plugins_list_needs_license_status": "Licence requise",
|
||||
"plugins_list_new_tools_status": "Nouveaux outils",
|
||||
"plugins_list_on_status": "Activé",
|
||||
"plugins_list_paused_status": "En pause",
|
||||
"plugins_list_plugin_label": "Plugin",
|
||||
"plugins_list_removed_status": "Retiré de la liste",
|
||||
"plugins_list_sign_in_status": "Nouvelle clé requise",
|
||||
"plugins_list_signed_out_status": "Déconnecté",
|
||||
"plugins_list_status_label": "Statut",
|
||||
"plugins_list_third_party_label": "Tiers",
|
||||
"plugins_list_third_party_off_status": "Plugins tiers désactivés",
|
||||
"plugins_list_type_label": "Type",
|
||||
"plugins_list_unreachable_status": "Connexion impossible",
|
||||
"plugins_mentions_all_off_empty": "Vos plugins sont désactivés dans Paramètres › Plugins.",
|
||||
"plugins_mentions_aria": "Plugins",
|
||||
"plugins_mentions_empty": "Aucun plugin ne correspond",
|
||||
|
|
@ -794,6 +817,7 @@
|
|||
"plugins_row_changed_tools_body": "Certains outils sont nouveaux ou ont changé. Ils restent désactivés tant que vous ne les autorisez pas.",
|
||||
"plugins_row_check_button": "Vérifier",
|
||||
"plugins_row_connected_status": "Connecté",
|
||||
"plugins_row_custom_label": "Personnalisé · {host}",
|
||||
"plugins_row_first_party_label": "Par SurfSense · {host}",
|
||||
"plugins_row_license_expired_status": "Licence expirée",
|
||||
"plugins_row_license_invalid_status": "Licence non reconnue",
|
||||
|
|
@ -811,12 +835,12 @@
|
|||
"plugins_row_third_party_off_body": "Inutilisé tant que les plugins tiers ne sont pas autorisés",
|
||||
"plugins_row_unreachable_status": "Connexion impossible",
|
||||
"plugins_row_use_aria": "Utiliser {plugin}",
|
||||
"plugins_settings_add_button": "Ajouter un serveur MCP",
|
||||
"plugins_settings_add_custom_button": "Ajouter un plugin personnalisé",
|
||||
"plugins_settings_body": "Des outils de serveurs MCP que l’agent peut utiliser. Les plugins fonctionnent dans les conversations en mode agentique.",
|
||||
"plugins_settings_empty": "Aucun serveur MCP pour l’instant. Ajoutez-en un avec son URL.",
|
||||
"plugins_settings_browse_label": "Parcourir",
|
||||
"plugins_settings_load_error": "Impossible de charger les plugins.",
|
||||
"plugins_settings_servers_title": "Serveurs MCP",
|
||||
"plugins_settings_title": "Plugins",
|
||||
"plugins_settings_yours_label": "Vos plugins",
|
||||
"plugins_sign_in_cancel_button": "Annuler",
|
||||
"plugins_sign_in_failed_error": "La connexion n’a pas abouti. Réessayez.",
|
||||
"plugins_sign_in_waiting_status": "En attente de la connexion…",
|
||||
|
|
|
|||
|
|
@ -745,12 +745,17 @@
|
|||
"plugins_add_name_placeholder": "टीम विकी",
|
||||
"plugins_add_title": "MCP सर्वर जोड़ें",
|
||||
"plugins_add_url_label": "सर्वर URL",
|
||||
"plugins_details_back_button": "वापस",
|
||||
"plugins_disconnect_dialog_body": "एजेंट इसका इस्तेमाल बंद कर देगा, और इसकी टूल सेटिंग्स हटा दी जाएँगी। इसे कुछ समय के लिए रोकना हो, तो इसे बंद कर दें।",
|
||||
"plugins_disconnect_dialog_cancel_button": "रद्द करें",
|
||||
"plugins_disconnect_dialog_confirm_button": "डिस्कनेक्ट करें",
|
||||
"plugins_disconnect_dialog_title": "{plugin} को डिस्कनेक्ट करें?",
|
||||
"plugins_disconnect_trigger_aria": "{plugin} को डिस्कनेक्ट करें",
|
||||
"plugins_disconnect_trigger_button": "डिस्कनेक्ट करें",
|
||||
"plugins_empty_add_button": "कस्टम प्लगइन जोड़ें",
|
||||
"plugins_empty_body": "एजेंट को टूल दें: प्लगइन ब्राउज़ करें या अपना सर्वर जोड़ें।",
|
||||
"plugins_empty_browse_button": "प्लगइन ब्राउज़ करें",
|
||||
"plugins_empty_title": "कोई प्लगइन कनेक्ट नहीं है",
|
||||
"plugins_error_egress_disabled": "SurfSense को इस सर्वर से संपर्क करने की अनुमति नहीं है। इसे कनेक्ट करने के लिए इसके होस्ट को अनुमति दें।",
|
||||
"plugins_error_plugin_already_connected": "यह सर्वर पहले से {name} नाम से कनेक्ट है।",
|
||||
"plugins_error_plugin_name_taken": "{name} नाम का प्लगइन पहले से कनेक्ट है। कोई दूसरा नाम चुनें।",
|
||||
|
|
@ -769,6 +774,24 @@
|
|||
"plugins_key_replace_button": "कुंजी बदलें",
|
||||
"plugins_key_save_button": "सहेजें",
|
||||
"plugins_key_title": "{plugin} की कुंजी बदलें",
|
||||
"plugins_list_custom_label": "कस्टम",
|
||||
"plugins_list_first_party_label": "SurfSense की ओर से",
|
||||
"plugins_list_license_expired_status": "लाइसेंस की अवधि समाप्त",
|
||||
"plugins_list_license_invalid_status": "लाइसेंस पहचाना नहीं गया",
|
||||
"plugins_list_license_revoked_status": "लाइसेंस रद्द किया गया",
|
||||
"plugins_list_needs_license_status": "लाइसेंस चाहिए",
|
||||
"plugins_list_new_tools_status": "नए टूल",
|
||||
"plugins_list_on_status": "चालू",
|
||||
"plugins_list_paused_status": "रुका हुआ",
|
||||
"plugins_list_plugin_label": "प्लगइन",
|
||||
"plugins_list_removed_status": "अब सूची में नहीं",
|
||||
"plugins_list_sign_in_status": "नई कुंजी चाहिए",
|
||||
"plugins_list_signed_out_status": "साइन आउट है",
|
||||
"plugins_list_status_label": "स्थिति",
|
||||
"plugins_list_third_party_label": "थर्ड-पार्टी",
|
||||
"plugins_list_third_party_off_status": "थर्ड-पार्टी प्लगइन बंद हैं",
|
||||
"plugins_list_type_label": "प्रकार",
|
||||
"plugins_list_unreachable_status": "कनेक्ट नहीं हो सका",
|
||||
"plugins_mentions_all_off_empty": "आपके प्लगइन सेटिंग्स › प्लगइन में बंद हैं।",
|
||||
"plugins_mentions_aria": "प्लगइन",
|
||||
"plugins_mentions_empty": "कोई मेल खाता प्लगइन नहीं",
|
||||
|
|
@ -794,6 +817,7 @@
|
|||
"plugins_row_changed_tools_body": "कुछ टूल नए हैं या बदल गए हैं। आपकी अनुमति तक वे बंद रहेंगे।",
|
||||
"plugins_row_check_button": "अभी जाँचें",
|
||||
"plugins_row_connected_status": "कनेक्ट है",
|
||||
"plugins_row_custom_label": "कस्टम · {host}",
|
||||
"plugins_row_first_party_label": "SurfSense की ओर से · {host}",
|
||||
"plugins_row_license_expired_status": "लाइसेंस की अवधि समाप्त",
|
||||
"plugins_row_license_invalid_status": "लाइसेंस पहचाना नहीं गया",
|
||||
|
|
@ -811,12 +835,12 @@
|
|||
"plugins_row_third_party_off_body": "थर्ड-पार्टी प्लगइन की अनुमति मिलने तक इस्तेमाल नहीं होगा",
|
||||
"plugins_row_unreachable_status": "कनेक्ट नहीं हो सका",
|
||||
"plugins_row_use_aria": "{plugin} इस्तेमाल करें",
|
||||
"plugins_settings_add_button": "MCP सर्वर जोड़ें",
|
||||
"plugins_settings_add_custom_button": "कस्टम प्लगइन जोड़ें",
|
||||
"plugins_settings_body": "MCP सर्वर के टूल, जिन्हें एजेंट इस्तेमाल कर सकता है। प्लगइन एजेंटिक मोड वाले थ्रेड में काम करते हैं।",
|
||||
"plugins_settings_empty": "अभी कोई MCP सर्वर नहीं है। उसके URL से एक जोड़ें।",
|
||||
"plugins_settings_browse_label": "ब्राउज़ करें",
|
||||
"plugins_settings_load_error": "प्लगइन लोड नहीं हो सके।",
|
||||
"plugins_settings_servers_title": "MCP सर्वर",
|
||||
"plugins_settings_title": "प्लगइन",
|
||||
"plugins_settings_yours_label": "आपके प्लगइन",
|
||||
"plugins_sign_in_cancel_button": "रद्द करें",
|
||||
"plugins_sign_in_failed_error": "साइन इन पूरा नहीं हुआ। फिर से कोशिश करें।",
|
||||
"plugins_sign_in_waiting_status": "साइन इन की प्रतीक्षा है…",
|
||||
|
|
|
|||
|
|
@ -745,12 +745,17 @@
|
|||
"plugins_add_name_placeholder": "チームWiki",
|
||||
"plugins_add_title": "MCPサーバーを追加",
|
||||
"plugins_add_url_label": "サーバーURL",
|
||||
"plugins_details_back_button": "戻る",
|
||||
"plugins_disconnect_dialog_body": "エージェントはこのプラグインを使わなくなり、ツールの設定は削除されます。一時的に止めるだけなら、オフにしてください。",
|
||||
"plugins_disconnect_dialog_cancel_button": "キャンセル",
|
||||
"plugins_disconnect_dialog_confirm_button": "接続解除",
|
||||
"plugins_disconnect_dialog_title": "{plugin}の接続を解除しますか?",
|
||||
"plugins_disconnect_trigger_aria": "{plugin}の接続を解除",
|
||||
"plugins_disconnect_trigger_button": "接続解除",
|
||||
"plugins_empty_add_button": "カスタムプラグインを追加",
|
||||
"plugins_empty_body": "エージェントにツールを追加しましょう。プラグインを探すか、独自のサーバーを追加してください。",
|
||||
"plugins_empty_browse_button": "プラグインを探す",
|
||||
"plugins_empty_title": "接続済みのプラグインはありません",
|
||||
"plugins_error_egress_disabled": "SurfSenseはこのサーバーへの接続を許可されていません。接続するにはホストを許可してください。",
|
||||
"plugins_error_plugin_already_connected": "このサーバーは{name}としてすでに接続されています。",
|
||||
"plugins_error_plugin_name_taken": "{name}という名前のプラグインはすでに接続されています。別の名前を選んでください。",
|
||||
|
|
@ -769,6 +774,24 @@
|
|||
"plugins_key_replace_button": "キーを置き換え",
|
||||
"plugins_key_save_button": "保存",
|
||||
"plugins_key_title": "{plugin}のキーを置き換え",
|
||||
"plugins_list_custom_label": "カスタム",
|
||||
"plugins_list_first_party_label": "SurfSense提供",
|
||||
"plugins_list_license_expired_status": "ライセンスの有効期限切れ",
|
||||
"plugins_list_license_invalid_status": "ライセンスを認識できません",
|
||||
"plugins_list_license_revoked_status": "ライセンスが取り消されました",
|
||||
"plugins_list_needs_license_status": "ライセンスが必要です",
|
||||
"plugins_list_new_tools_status": "新しいツール",
|
||||
"plugins_list_on_status": "オン",
|
||||
"plugins_list_paused_status": "一時停止中",
|
||||
"plugins_list_plugin_label": "プラグイン",
|
||||
"plugins_list_removed_status": "掲載終了",
|
||||
"plugins_list_sign_in_status": "新しいキーが必要です",
|
||||
"plugins_list_signed_out_status": "サインアウト済み",
|
||||
"plugins_list_status_label": "状態",
|
||||
"plugins_list_third_party_label": "サードパーティ",
|
||||
"plugins_list_third_party_off_status": "サードパーティのプラグインはオフです",
|
||||
"plugins_list_type_label": "種類",
|
||||
"plugins_list_unreachable_status": "接続できません",
|
||||
"plugins_mentions_all_off_empty": "プラグインは設定 › プラグインでオフになっています。",
|
||||
"plugins_mentions_aria": "プラグイン",
|
||||
"plugins_mentions_empty": "一致するプラグインはありません",
|
||||
|
|
@ -794,6 +817,7 @@
|
|||
"plugins_row_changed_tools_body": "新しいツールまたは変更されたツールがあります。許可するまでオフのままです。",
|
||||
"plugins_row_check_button": "今すぐ確認",
|
||||
"plugins_row_connected_status": "接続済み",
|
||||
"plugins_row_custom_label": "カスタム · {host}",
|
||||
"plugins_row_first_party_label": "SurfSense提供 · {host}",
|
||||
"plugins_row_license_expired_status": "ライセンスの有効期限切れ",
|
||||
"plugins_row_license_invalid_status": "ライセンスを認識できません",
|
||||
|
|
@ -811,12 +835,12 @@
|
|||
"plugins_row_third_party_off_body": "サードパーティのプラグインを許可するまで使用されません",
|
||||
"plugins_row_unreachable_status": "接続できません",
|
||||
"plugins_row_use_aria": "{plugin}を使用",
|
||||
"plugins_settings_add_button": "MCPサーバーを追加",
|
||||
"plugins_settings_add_custom_button": "カスタムプラグインを追加",
|
||||
"plugins_settings_body": "エージェントが使えるMCPサーバーのツールです。プラグインはエージェントモードのスレッドで動作します。",
|
||||
"plugins_settings_empty": "MCPサーバーはまだありません。URLで追加してください。",
|
||||
"plugins_settings_browse_label": "探す",
|
||||
"plugins_settings_load_error": "プラグインを読み込めませんでした。",
|
||||
"plugins_settings_servers_title": "MCPサーバー",
|
||||
"plugins_settings_title": "プラグイン",
|
||||
"plugins_settings_yours_label": "マイプラグイン",
|
||||
"plugins_sign_in_cancel_button": "キャンセル",
|
||||
"plugins_sign_in_failed_error": "サインインが完了しませんでした。再試行してください。",
|
||||
"plugins_sign_in_waiting_status": "サインインを待っています…",
|
||||
|
|
|
|||
|
|
@ -745,12 +745,17 @@
|
|||
"plugins_add_name_placeholder": "팀 위키",
|
||||
"plugins_add_title": "MCP 서버 추가",
|
||||
"plugins_add_url_label": "서버 URL",
|
||||
"plugins_details_back_button": "뒤로",
|
||||
"plugins_disconnect_dialog_body": "에이전트가 더 이상 이 플러그인을 사용하지 않으며 도구 설정이 삭제됩니다. 잠시 멈추려면 대신 끄세요.",
|
||||
"plugins_disconnect_dialog_cancel_button": "취소",
|
||||
"plugins_disconnect_dialog_confirm_button": "연결 해제",
|
||||
"plugins_disconnect_dialog_title": "{plugin} 연결을 해제할까요?",
|
||||
"plugins_disconnect_trigger_aria": "{plugin} 연결 해제",
|
||||
"plugins_disconnect_trigger_button": "연결 해제",
|
||||
"plugins_empty_add_button": "사용자 지정 플러그인 추가",
|
||||
"plugins_empty_body": "에이전트에 도구를 추가하세요. 플러그인을 둘러보거나 직접 서버를 추가하세요.",
|
||||
"plugins_empty_browse_button": "플러그인 둘러보기",
|
||||
"plugins_empty_title": "연결된 플러그인이 없습니다",
|
||||
"plugins_error_egress_disabled": "SurfSense가 이 서버에 접속하도록 허용되지 않았습니다. 연결하려면 호스트를 허용하세요.",
|
||||
"plugins_error_plugin_already_connected": "이 서버는 이미 {name}(으)로 연결되어 있습니다.",
|
||||
"plugins_error_plugin_name_taken": "이름이 {name}인 플러그인이 이미 연결되어 있습니다. 다른 이름을 선택하세요.",
|
||||
|
|
@ -769,6 +774,24 @@
|
|||
"plugins_key_replace_button": "키 교체",
|
||||
"plugins_key_save_button": "저장",
|
||||
"plugins_key_title": "{plugin}의 키 교체",
|
||||
"plugins_list_custom_label": "사용자 지정",
|
||||
"plugins_list_first_party_label": "SurfSense 제공",
|
||||
"plugins_list_license_expired_status": "라이선스 만료됨",
|
||||
"plugins_list_license_invalid_status": "라이선스를 인식할 수 없음",
|
||||
"plugins_list_license_revoked_status": "라이선스 취소됨",
|
||||
"plugins_list_needs_license_status": "라이선스가 필요합니다",
|
||||
"plugins_list_new_tools_status": "새 도구",
|
||||
"plugins_list_on_status": "켜짐",
|
||||
"plugins_list_paused_status": "일시 중지됨",
|
||||
"plugins_list_plugin_label": "플러그인",
|
||||
"plugins_list_removed_status": "더 이상 등록되지 않음",
|
||||
"plugins_list_sign_in_status": "새 키가 필요합니다",
|
||||
"plugins_list_signed_out_status": "로그아웃됨",
|
||||
"plugins_list_status_label": "상태",
|
||||
"plugins_list_third_party_label": "서드파티",
|
||||
"plugins_list_third_party_off_status": "서드파티 플러그인이 꺼져 있음",
|
||||
"plugins_list_type_label": "유형",
|
||||
"plugins_list_unreachable_status": "연결할 수 없음",
|
||||
"plugins_mentions_all_off_empty": "플러그인이 설정 › 플러그인에서 꺼져 있습니다.",
|
||||
"plugins_mentions_aria": "플러그인",
|
||||
"plugins_mentions_empty": "일치하는 플러그인이 없습니다",
|
||||
|
|
@ -794,6 +817,7 @@
|
|||
"plugins_row_changed_tools_body": "새로 추가되었거나 변경된 도구가 있습니다. 허용하기 전까지 꺼져 있습니다.",
|
||||
"plugins_row_check_button": "지금 확인",
|
||||
"plugins_row_connected_status": "연결됨",
|
||||
"plugins_row_custom_label": "사용자 지정 · {host}",
|
||||
"plugins_row_first_party_label": "SurfSense 제공 · {host}",
|
||||
"plugins_row_license_expired_status": "라이선스 만료됨",
|
||||
"plugins_row_license_invalid_status": "라이선스를 인식할 수 없음",
|
||||
|
|
@ -811,12 +835,12 @@
|
|||
"plugins_row_third_party_off_body": "서드파티 플러그인을 허용할 때까지 사용되지 않습니다",
|
||||
"plugins_row_unreachable_status": "연결할 수 없음",
|
||||
"plugins_row_use_aria": "{plugin} 사용",
|
||||
"plugins_settings_add_button": "MCP 서버 추가",
|
||||
"plugins_settings_add_custom_button": "사용자 지정 플러그인 추가",
|
||||
"plugins_settings_body": "에이전트가 사용할 수 있는 MCP 서버의 도구입니다. 플러그인은 에이전트 모드 스레드에서 작동합니다.",
|
||||
"plugins_settings_empty": "아직 MCP 서버가 없습니다. URL로 추가하세요.",
|
||||
"plugins_settings_browse_label": "둘러보기",
|
||||
"plugins_settings_load_error": "플러그인을 불러올 수 없습니다.",
|
||||
"plugins_settings_servers_title": "MCP 서버",
|
||||
"plugins_settings_title": "플러그인",
|
||||
"plugins_settings_yours_label": "내 플러그인",
|
||||
"plugins_sign_in_cancel_button": "취소",
|
||||
"plugins_sign_in_failed_error": "로그인이 완료되지 않았습니다. 다시 시도하세요.",
|
||||
"plugins_sign_in_waiting_status": "로그인 대기 중…",
|
||||
|
|
|
|||
|
|
@ -745,12 +745,17 @@
|
|||
"plugins_add_name_placeholder": "Wiki da equipe",
|
||||
"plugins_add_title": "Adicionar um servidor MCP",
|
||||
"plugins_add_url_label": "URL do servidor",
|
||||
"plugins_details_back_button": "Voltar",
|
||||
"plugins_disconnect_dialog_body": "O agente deixa de usá-lo, e as configurações das ferramentas dele são removidas. Para pausá-lo por um tempo, desative-o.",
|
||||
"plugins_disconnect_dialog_cancel_button": "Cancelar",
|
||||
"plugins_disconnect_dialog_confirm_button": "Desconectar",
|
||||
"plugins_disconnect_dialog_title": "Desconectar {plugin}?",
|
||||
"plugins_disconnect_trigger_aria": "Desconectar {plugin}",
|
||||
"plugins_disconnect_trigger_button": "Desconectar",
|
||||
"plugins_empty_add_button": "Adicionar plugin personalizado",
|
||||
"plugins_empty_body": "Dê ferramentas ao agente: explore plugins ou adicione seu próprio servidor.",
|
||||
"plugins_empty_browse_button": "Explorar plugins",
|
||||
"plugins_empty_title": "Nenhum plugin conectado",
|
||||
"plugins_error_egress_disabled": "O SurfSense não tem permissão para acessar este servidor. Permita o host dele para conectá-lo.",
|
||||
"plugins_error_plugin_already_connected": "Este servidor já está conectado como {name}.",
|
||||
"plugins_error_plugin_name_taken": "Já existe um plugin conectado chamado {name}. Escolha outro nome.",
|
||||
|
|
@ -769,6 +774,24 @@
|
|||
"plugins_key_replace_button": "Trocar chave",
|
||||
"plugins_key_save_button": "Salvar",
|
||||
"plugins_key_title": "Trocar a chave de {plugin}",
|
||||
"plugins_list_custom_label": "Personalizado",
|
||||
"plugins_list_first_party_label": "Da SurfSense",
|
||||
"plugins_list_license_expired_status": "Licença expirada",
|
||||
"plugins_list_license_invalid_status": "Licença não reconhecida",
|
||||
"plugins_list_license_revoked_status": "Licença revogada",
|
||||
"plugins_list_needs_license_status": "Precisa de uma licença",
|
||||
"plugins_list_new_tools_status": "Ferramentas novas",
|
||||
"plugins_list_on_status": "Ativado",
|
||||
"plugins_list_paused_status": "Pausado",
|
||||
"plugins_list_plugin_label": "Plugin",
|
||||
"plugins_list_removed_status": "Não está mais na lista",
|
||||
"plugins_list_sign_in_status": "Precisa de uma chave nova",
|
||||
"plugins_list_signed_out_status": "Sessão encerrada",
|
||||
"plugins_list_status_label": "Status",
|
||||
"plugins_list_third_party_label": "De terceiros",
|
||||
"plugins_list_third_party_off_status": "Plugins de terceiros desativados",
|
||||
"plugins_list_type_label": "Tipo",
|
||||
"plugins_list_unreachable_status": "Não foi possível conectar",
|
||||
"plugins_mentions_all_off_empty": "Seus plugins estão desativados em Configurações › Plugins.",
|
||||
"plugins_mentions_aria": "Plugins",
|
||||
"plugins_mentions_empty": "Nenhum plugin corresponde",
|
||||
|
|
@ -794,6 +817,7 @@
|
|||
"plugins_row_changed_tools_body": "Algumas ferramentas são novas ou mudaram. Elas ficam desativadas até você permitir.",
|
||||
"plugins_row_check_button": "Verificar agora",
|
||||
"plugins_row_connected_status": "Conectado",
|
||||
"plugins_row_custom_label": "Personalizado · {host}",
|
||||
"plugins_row_first_party_label": "Da SurfSense · {host}",
|
||||
"plugins_row_license_expired_status": "Licença expirada",
|
||||
"plugins_row_license_invalid_status": "Licença não reconhecida",
|
||||
|
|
@ -811,12 +835,12 @@
|
|||
"plugins_row_third_party_off_body": "Não é usado até que plugins de terceiros sejam permitidos",
|
||||
"plugins_row_unreachable_status": "Não foi possível conectar",
|
||||
"plugins_row_use_aria": "Usar {plugin}",
|
||||
"plugins_settings_add_button": "Adicionar servidor MCP",
|
||||
"plugins_settings_add_custom_button": "Adicionar plugin personalizado",
|
||||
"plugins_settings_body": "Ferramentas de servidores MCP que o agente pode usar. Plugins funcionam em conversas no modo agêntico.",
|
||||
"plugins_settings_empty": "Nenhum servidor MCP ainda. Adicione um pela URL.",
|
||||
"plugins_settings_browse_label": "Explorar",
|
||||
"plugins_settings_load_error": "Não foi possível carregar os plugins.",
|
||||
"plugins_settings_servers_title": "Servidores MCP",
|
||||
"plugins_settings_title": "Plugins",
|
||||
"plugins_settings_yours_label": "Seus plugins",
|
||||
"plugins_sign_in_cancel_button": "Cancelar",
|
||||
"plugins_sign_in_failed_error": "O login não foi concluído. Tente novamente.",
|
||||
"plugins_sign_in_waiting_status": "Aguardando o login…",
|
||||
|
|
|
|||
|
|
@ -745,12 +745,17 @@
|
|||
"plugins_add_name_placeholder": "Вики команды",
|
||||
"plugins_add_title": "Добавить MCP-сервер",
|
||||
"plugins_add_url_label": "URL сервера",
|
||||
"plugins_details_back_button": "Назад",
|
||||
"plugins_disconnect_dialog_body": "Агент перестанет его использовать, а настройки инструментов будут удалены. Чтобы приостановить его на время, просто выключите его.",
|
||||
"plugins_disconnect_dialog_cancel_button": "Отмена",
|
||||
"plugins_disconnect_dialog_confirm_button": "Отключить",
|
||||
"plugins_disconnect_dialog_title": "Отключить {plugin}?",
|
||||
"plugins_disconnect_trigger_aria": "Отключить {plugin}",
|
||||
"plugins_disconnect_trigger_button": "Отключить",
|
||||
"plugins_empty_add_button": "Добавить свой плагин",
|
||||
"plugins_empty_body": "Дайте агенту инструменты: найдите плагины или добавьте свой сервер.",
|
||||
"plugins_empty_browse_button": "Посмотреть плагины",
|
||||
"plugins_empty_title": "Нет подключённых плагинов",
|
||||
"plugins_error_egress_disabled": "SurfSense не может обращаться к этому серверу. Разрешите его хост, чтобы подключить.",
|
||||
"plugins_error_plugin_already_connected": "Этот сервер уже подключён как {name}.",
|
||||
"plugins_error_plugin_name_taken": "Плагин с именем {name} уже подключён. Выберите другое имя.",
|
||||
|
|
@ -769,6 +774,24 @@
|
|||
"plugins_key_replace_button": "Заменить ключ",
|
||||
"plugins_key_save_button": "Сохранить",
|
||||
"plugins_key_title": "Заменить ключ для {plugin}",
|
||||
"plugins_list_custom_label": "Свой",
|
||||
"plugins_list_first_party_label": "От SurfSense",
|
||||
"plugins_list_license_expired_status": "Срок лицензии истёк",
|
||||
"plugins_list_license_invalid_status": "Лицензия не распознана",
|
||||
"plugins_list_license_revoked_status": "Лицензия отозвана",
|
||||
"plugins_list_needs_license_status": "Нужна лицензия",
|
||||
"plugins_list_new_tools_status": "Новые инструменты",
|
||||
"plugins_list_on_status": "Включён",
|
||||
"plugins_list_paused_status": "Приостановлен",
|
||||
"plugins_list_plugin_label": "Плагин",
|
||||
"plugins_list_removed_status": "Больше нет в списке",
|
||||
"plugins_list_sign_in_status": "Нужен новый ключ",
|
||||
"plugins_list_signed_out_status": "Выполнен выход",
|
||||
"plugins_list_status_label": "Статус",
|
||||
"plugins_list_third_party_label": "Сторонний",
|
||||
"plugins_list_third_party_off_status": "Сторонние плагины выключены",
|
||||
"plugins_list_type_label": "Тип",
|
||||
"plugins_list_unreachable_status": "Не удалось подключиться",
|
||||
"plugins_mentions_all_off_empty": "Ваши плагины отключены в разделе Настройки › Плагины.",
|
||||
"plugins_mentions_aria": "Плагины",
|
||||
"plugins_mentions_empty": "Нет подходящих плагинов",
|
||||
|
|
@ -794,6 +817,7 @@
|
|||
"plugins_row_changed_tools_body": "Некоторые инструменты новые или изменились. Они отключены, пока вы их не разрешите.",
|
||||
"plugins_row_check_button": "Проверить",
|
||||
"plugins_row_connected_status": "Подключено",
|
||||
"plugins_row_custom_label": "Свой · {host}",
|
||||
"plugins_row_first_party_label": "От SurfSense · {host}",
|
||||
"plugins_row_license_expired_status": "Срок лицензии истёк",
|
||||
"plugins_row_license_invalid_status": "Лицензия не распознана",
|
||||
|
|
@ -811,12 +835,12 @@
|
|||
"plugins_row_third_party_off_body": "Не используется, пока сторонние плагины не разрешены",
|
||||
"plugins_row_unreachable_status": "Не удалось подключиться",
|
||||
"plugins_row_use_aria": "Использовать {plugin}",
|
||||
"plugins_settings_add_button": "Добавить MCP-сервер",
|
||||
"plugins_settings_add_custom_button": "Добавить свой плагин",
|
||||
"plugins_settings_body": "Инструменты MCP-серверов, которые может использовать агент. Плагины работают в ветках в агентном режиме.",
|
||||
"plugins_settings_empty": "MCP-серверов пока нет. Добавьте сервер по его URL.",
|
||||
"plugins_settings_browse_label": "Обзор",
|
||||
"plugins_settings_load_error": "Не удалось загрузить плагины.",
|
||||
"plugins_settings_servers_title": "MCP-серверы",
|
||||
"plugins_settings_title": "Плагины",
|
||||
"plugins_settings_yours_label": "Ваши плагины",
|
||||
"plugins_sign_in_cancel_button": "Отмена",
|
||||
"plugins_sign_in_failed_error": "Вход не был завершён. Попробуйте снова.",
|
||||
"plugins_sign_in_waiting_status": "Ожидание входа…",
|
||||
|
|
|
|||
|
|
@ -745,12 +745,17 @@
|
|||
"plugins_add_name_placeholder": "团队 Wiki",
|
||||
"plugins_add_title": "添加 MCP 服务器",
|
||||
"plugins_add_url_label": "服务器网址",
|
||||
"plugins_details_back_button": "返回",
|
||||
"plugins_disconnect_dialog_body": "智能体将不再使用它,其工具设置也会被删除。如果只想暂时停用,请将它关闭。",
|
||||
"plugins_disconnect_dialog_cancel_button": "取消",
|
||||
"plugins_disconnect_dialog_confirm_button": "断开连接",
|
||||
"plugins_disconnect_dialog_title": "断开 {plugin} 的连接?",
|
||||
"plugins_disconnect_trigger_aria": "断开 {plugin} 的连接",
|
||||
"plugins_disconnect_trigger_button": "断开连接",
|
||||
"plugins_empty_add_button": "添加自定义插件",
|
||||
"plugins_empty_body": "为智能体添加工具:浏览插件,或添加你自己的服务器。",
|
||||
"plugins_empty_browse_button": "浏览插件",
|
||||
"plugins_empty_title": "没有已连接的插件",
|
||||
"plugins_error_egress_disabled": "SurfSense 未获准访问此服务器。允许它的主机后即可连接。",
|
||||
"plugins_error_plugin_already_connected": "此服务器已作为 {name} 连接。",
|
||||
"plugins_error_plugin_name_taken": "名为 {name} 的插件已连接。请换一个名称。",
|
||||
|
|
@ -769,6 +774,24 @@
|
|||
"plugins_key_replace_button": "替换密钥",
|
||||
"plugins_key_save_button": "保存",
|
||||
"plugins_key_title": "替换 {plugin} 的密钥",
|
||||
"plugins_list_custom_label": "自定义",
|
||||
"plugins_list_first_party_label": "SurfSense 提供",
|
||||
"plugins_list_license_expired_status": "许可证已过期",
|
||||
"plugins_list_license_invalid_status": "无法识别许可证",
|
||||
"plugins_list_license_revoked_status": "许可证已被吊销",
|
||||
"plugins_list_needs_license_status": "需要许可证",
|
||||
"plugins_list_new_tools_status": "新工具",
|
||||
"plugins_list_on_status": "已开启",
|
||||
"plugins_list_paused_status": "已暂停",
|
||||
"plugins_list_plugin_label": "插件",
|
||||
"plugins_list_removed_status": "已下架",
|
||||
"plugins_list_sign_in_status": "需要新的密钥",
|
||||
"plugins_list_signed_out_status": "已退出登录",
|
||||
"plugins_list_status_label": "状态",
|
||||
"plugins_list_third_party_label": "第三方",
|
||||
"plugins_list_third_party_off_status": "第三方插件已关闭",
|
||||
"plugins_list_type_label": "类型",
|
||||
"plugins_list_unreachable_status": "无法连接",
|
||||
"plugins_mentions_all_off_empty": "你的插件已在“设置 › 插件”中关闭。",
|
||||
"plugins_mentions_aria": "插件",
|
||||
"plugins_mentions_empty": "没有匹配的插件",
|
||||
|
|
@ -794,6 +817,7 @@
|
|||
"plugins_row_changed_tools_body": "部分工具是新增的或已变更,在你允许之前保持关闭。",
|
||||
"plugins_row_check_button": "立即检查",
|
||||
"plugins_row_connected_status": "已连接",
|
||||
"plugins_row_custom_label": "自定义 · {host}",
|
||||
"plugins_row_first_party_label": "SurfSense 提供 · {host}",
|
||||
"plugins_row_license_expired_status": "许可证已过期",
|
||||
"plugins_row_license_invalid_status": "无法识别许可证",
|
||||
|
|
@ -811,12 +835,12 @@
|
|||
"plugins_row_third_party_off_body": "允许第三方插件之前不会使用",
|
||||
"plugins_row_unreachable_status": "无法连接",
|
||||
"plugins_row_use_aria": "使用 {plugin}",
|
||||
"plugins_settings_add_button": "添加 MCP 服务器",
|
||||
"plugins_settings_add_custom_button": "添加自定义插件",
|
||||
"plugins_settings_body": "智能体可以使用的 MCP 服务器工具。插件在智能体模式的对话中可用。",
|
||||
"plugins_settings_empty": "还没有 MCP 服务器。通过网址添加一个。",
|
||||
"plugins_settings_browse_label": "浏览",
|
||||
"plugins_settings_load_error": "无法加载插件。",
|
||||
"plugins_settings_servers_title": "MCP 服务器",
|
||||
"plugins_settings_title": "插件",
|
||||
"plugins_settings_yours_label": "我的插件",
|
||||
"plugins_sign_in_cancel_button": "取消",
|
||||
"plugins_sign_in_failed_error": "登录未完成。请重试。",
|
||||
"plugins_sign_in_waiting_status": "正在等待登录…",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue