mirror of
https://github.com/dartdavros/chatballs.git
synced 2026-10-06 01:24:59 +03:00
✨ feat(integrations): список серверов и общий каркас настроек
This commit is contained in:
1 parent
56cc8ede5d
commit
20b93e70ce
16 files changed
+186
-118
No files matched your search
@@ -1,4 +1,5 @@
|
||||
import { Dropdown } from "antd";
|
||||
import { useState } from "react";
|
||||
import { t } from "../../i18n";
|
||||
import { Icon } from "../../shared/icons";
|
||||
import { Button } from "../../shared/ui-controls";
|
||||
@@ -6,16 +7,19 @@ import type { ServerKind } from "./external/types";
|
||||
import "./add-integration.css";
|
||||
|
||||
export function AddIntegrationButton({ onEntry, onServer }: { onEntry: () => void; onServer: (kind: ServerKind) => void }) {
|
||||
const option = (icon: "message" | "server" | "swap", title: string, hint: string, action: () => void) =>
|
||||
<button type="button" onClick={action}><i><Icon name={icon} size={15} /></i><span><strong>{title}</strong><small>{hint}</small></span></button>;
|
||||
return <Dropdown trigger={["click"]} overlayClassName="app-dropdown is-server-add" menu={{ items: [
|
||||
const [open, setOpen] = useState(false);
|
||||
const option = (icon: "message" | "server" | "swap", title: string, hint: string) =>
|
||||
<button type="button"><i><Icon name={icon} size={15} /></i><span><strong>{title}</strong><small>{hint}</small></span></button>;
|
||||
return <Dropdown open={open} onOpenChange={setOpen} trigger={["click"]} overlayClassName="app-dropdown is-server-add" menu={{
|
||||
onClick: ({ key }) => { setOpen(false); if (key === "entry") onEntry(); else onServer(key as ServerKind); },
|
||||
items: [
|
||||
{ type: "group", label: t("servers.entry"), children: [
|
||||
{ key: "entry", label: option("message", t("servers.entry_title"), t("servers.entry_hint"), onEntry) },
|
||||
{ key: "entry", label: option("message", t("servers.entry_title"), t("servers.entry_hint")) },
|
||||
] },
|
||||
{ type: "divider" },
|
||||
{ type: "group", label: t("servers.external_group"), children: [
|
||||
{ key: "mcp", label: option("server", t("servers.new_mcp"), t("servers.mcp_hint"), () => onServer("mcp")) },
|
||||
{ key: "http", label: option("swap", t("servers.new_http"), t("servers.http_hint"), () => onServer("http")) },
|
||||
{ key: "mcp", label: option("server", t("servers.new_mcp"), t("servers.mcp_hint")) },
|
||||
{ key: "http", label: option("swap", t("servers.new_http"), t("servers.http_hint")) },
|
||||
] },
|
||||
] }}><Button variant="primary" icon="plus">{t("servers.add")}<Icon name="chevron" size={14} /></Button></Dropdown>;
|
||||
] }}><Button className="row-menu-button integration-add-button" variant="primary" icon="plus" aria-expanded={open}>{t("servers.add")}<Icon name="chevron" size={14} /></Button></Dropdown>;
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { PROVIDERS, type Integration } from "./model";
|
||||
import { ConnectionIcon, RowActions, StatusCell } from "./rows";
|
||||
import { t, tn } from "../../i18n";
|
||||
import { serverHost } from "./external/model";
|
||||
import "./connections-table.css";
|
||||
|
||||
// Подстрока подключения (SPEC-CHATBALLS-0025 §2.4): провайдер + идентификатор.
|
||||
function subtitle(integration: Integration): string {
|
||||
@@ -28,44 +29,47 @@ type ConnectionsTableProps = {
|
||||
|
||||
export function ConnectionsTable({ items, testingId, onTest, onEdit, onToggleActive, onDelete }: ConnectionsTableProps) {
|
||||
return (
|
||||
<div className="table-card">
|
||||
<table className="baseline-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t("settings.title")}</th>
|
||||
<th>{t("servers.agent_tools")}</th>
|
||||
<th>{t("common.status_2")}</th>
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map((item) => (
|
||||
<tr key={item.id}>
|
||||
<td>
|
||||
<div className="product-cell">
|
||||
<ConnectionIcon provider={item.provider} />
|
||||
<span>{item.provider === "WEB" || item.kind === "EXTERNAL_SERVER"
|
||||
? <button className="link is-strong" type="button" onClick={() => onEdit(item)}>{item.name}</button>
|
||||
: <strong>{item.name}</strong>}<small>{subtitle(item)}</small></span>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
{item.externalServer ? <span className="integration-tools-count"><Icon name="wrench" size={14} />{tn("servers.count", item.externalServer.type === "http" ? 1 : item.externalServer.tools?.length ?? 0)}</span> : item.channel
|
||||
? (
|
||||
<span className="integration-agent" style={{ color: agentColorOf(item.channel.id) }}>
|
||||
<Icon name="robot" size={14} />{item.channel.name}
|
||||
</span>
|
||||
)
|
||||
: <span className="product-empty-value">—</span>}
|
||||
</td>
|
||||
<td><StatusCell integration={item} /></td>
|
||||
<td className="row-actions">
|
||||
<RowActions integration={item} testing={testingId === item.id} onTest={onTest} onEdit={onEdit} onToggleActive={onToggleActive} onDelete={onDelete} />
|
||||
</td>
|
||||
<>
|
||||
<div className="table-card">
|
||||
<table className="baseline-table integrations-connections-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t("settings.title")}</th>
|
||||
<th>{t("servers.agent_tools")}</th>
|
||||
<th>{t("common.status_2")}</th>
|
||||
<th />
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map((item) => (
|
||||
<tr key={item.id}>
|
||||
<td>
|
||||
<div className="product-cell">
|
||||
<ConnectionIcon provider={item.provider} />
|
||||
<span>{item.provider === "WEB" || item.kind === "EXTERNAL_SERVER"
|
||||
? <button className="link is-strong" type="button" onClick={() => onEdit(item)}>{item.name}</button>
|
||||
: <strong>{item.name}</strong>}<small>{subtitle(item)}</small></span>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
{item.externalServer ? <span className="integration-tools-count"><Icon name="wrench" size={14} />{tn("servers.count", item.externalServer.type === "http" ? 1 : item.externalServer.tools?.length ?? 0)}</span> : item.channel
|
||||
? (
|
||||
<span className="integration-agent" style={{ color: agentColorOf(item.channel.id) }}>
|
||||
<Icon name="robot" size={14} />{item.channel.name}
|
||||
</span>
|
||||
)
|
||||
: <span className="product-empty-value">—</span>}
|
||||
</td>
|
||||
<td><StatusCell integration={item} /></td>
|
||||
<td className="row-actions">
|
||||
<RowActions integration={item} testing={testingId === item.id} onTest={onTest} onEdit={onEdit} onToggleActive={onToggleActive} onDelete={onDelete} />
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p className="integrations-list-note">{t("servers.list_note")}</p>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { SectionMenu, type SectionMenuItem } from "../../shared/SectionMenu";
|
||||
import "../support-portals/styles-detail.css";
|
||||
import "../support-portals/styles-settings.css";
|
||||
import "./web-integration.css";
|
||||
|
||||
export function IntegrationPageLayout<Key extends string>({ header, items, activeKey, note, onSelect, children, className = "" }: {
|
||||
header: ReactNode;
|
||||
items: readonly SectionMenuItem<Key>[];
|
||||
activeKey: Key;
|
||||
note: string;
|
||||
onSelect: (key: Key) => void;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return <section className={`web-integration-page ${className}`.trim()}>
|
||||
{header}
|
||||
<div className="portal-settings-layout">
|
||||
<SectionMenu items={items} activeKey={activeKey} note={note} onSelect={onSelect} />
|
||||
<div className="portal-settings-content">{children}</div>
|
||||
</div>
|
||||
</section>;
|
||||
}
|
||||
@@ -1,12 +1,9 @@
|
||||
import { t } from "../../i18n";
|
||||
import { ContentState } from "../../shared/ui";
|
||||
import { Icon } from "../../shared/icons";
|
||||
import { Button } from "../../shared/ui-controls";
|
||||
import { useIntegrations } from "../settings/useIntegrations";
|
||||
import { WebIntegrationPage } from "./WebIntegrationPage";
|
||||
import { ExternalServerPage } from "./external/ExternalServerPage";
|
||||
import type { ServerKind } from "./external/types";
|
||||
import { ServerPageLoading } from "./external/ServerPageLoading";
|
||||
import { ServerPageState } from "./external/ServerPageState";
|
||||
|
||||
export function IntegrationSettingsPage({ integrationId, createKind, onCreated, onOpenSettings, onBack }: {
|
||||
integrationId?: number; createKind?: ServerKind; onCreated: (id: number) => void; onOpenSettings: () => void; onBack: () => void;
|
||||
@@ -14,15 +11,9 @@ export function IntegrationSettingsPage({ integrationId, createKind, onCreated,
|
||||
const list = useIntegrations(true);
|
||||
const integration = list.items.find((i) => i.id === integrationId);
|
||||
if (list.loading) return <ServerPageLoading />;
|
||||
if (list.failed || (!createKind && !integration)) return <ContentState
|
||||
className={`server-page-state${list.failed ? " is-error" : ""}`} icon={<Icon name={list.failed ? "warning" : "server"} size={18} />}
|
||||
title={t(list.failed ? "servers.load_failed" : "servers.not_found")}
|
||||
text={t(list.failed ? "servers.load_failed_hint" : "servers.not_found_hint")}
|
||||
action={list.failed ? <Button variant="secondary" onClick={list.reload}>{t("common.try_again")}</Button>
|
||||
: <button className="link" type="button" onClick={onBack}>{t("servers.back")}</button>} />;
|
||||
if (list.failed || (!createKind && !integration)) return <ServerPageState failed={list.failed} onRetry={list.reload} onBack={onBack} />;
|
||||
if (integration?.provider === "WEB") return <WebIntegrationPage integrationId={integration.id} onOpenSettings={onOpenSettings} onBack={onBack} />;
|
||||
if (createKind || integration?.externalServer) return <ExternalServerPage key={integration?.id ?? createKind} initial={integration ?? null}
|
||||
kind={createKind ?? integration!.externalServer!.type} integrations={list.items} onCreated={onCreated} onOpenSettings={onOpenSettings} onBack={onBack} />;
|
||||
return <ContentState icon={<Icon name="server" size={28} />} title={t("servers.not_found")} text={t("servers.not_found_hint")}
|
||||
action={<Button variant="secondary" onClick={onBack}>{t("servers.back")}</Button>} />;
|
||||
return <ServerPageState onRetry={list.reload} onBack={onBack} />;
|
||||
}
|
||||
@@ -1,9 +1,8 @@
|
||||
import { useState } from "react";
|
||||
import "../support-portals/styles-detail.css";
|
||||
import "../support-portals/styles-settings.css";
|
||||
|
||||
import { fmt, t } from "../../i18n";
|
||||
import { SectionMenu, type SectionMenuItem } from "../../shared/SectionMenu";
|
||||
import type { SectionMenuItem } from "../../shared/SectionMenu";
|
||||
import { IntegrationPageLayout } from "./IntegrationPageLayout";
|
||||
import { EmptyState, LoadingState } from "../../shared/ui";
|
||||
import { DeleteIntegrationDialog } from "./DeleteIntegrationDialog";
|
||||
import { WebIntegrationBasics } from "./WebIntegrationBasics";
|
||||
@@ -39,36 +38,33 @@ export function WebIntegrationPage({ integrationId, onOpenSettings, onBack }: {
|
||||
if (!integration) return <EmptyState title={t("settings.web_integration_not_found")} />;
|
||||
|
||||
return (
|
||||
<section className="web-integration-page">
|
||||
<WebIntegrationHeader integration={integration} onOpenSettings={onOpenSettings} onBack={onBack} />
|
||||
<div className="portal-settings-layout">
|
||||
<SectionMenu
|
||||
items={sections.map((item) => {
|
||||
if (item.key === "fields") return { ...item, hint: { text: fmt.number(fieldCount ?? integration.config.fields?.length ?? 0) } };
|
||||
if (item.key === "form" && (formEnabled ?? integration.config.preChat?.enabled)) return { ...item, hint: { text: t("pre_chat.on") } };
|
||||
return item;
|
||||
})}
|
||||
activeKey={section}
|
||||
note={t("settings.web_changes_apply_after_save")}
|
||||
onSelect={(next) => { if (next === "danger") setConfirmingDelete(true); else setSection(next); }}
|
||||
/>
|
||||
<div className="portal-settings-content">
|
||||
<div className="portal-settings-inner" hidden={section !== "basics"}>
|
||||
<div className="portal-settings-heading">
|
||||
<h3>{t("portals.basics")}</h3>
|
||||
</div>
|
||||
<WebIntegrationBasics key={integration.id} integration={integration} onSaved={setIntegration} />
|
||||
<>
|
||||
<IntegrationPageLayout
|
||||
header={<WebIntegrationHeader integration={integration} onOpenSettings={onOpenSettings} onBack={onBack} />}
|
||||
items={sections.map((item) => {
|
||||
if (item.key === "fields") return { ...item, hint: { text: fmt.number(fieldCount ?? integration.config.fields?.length ?? 0) } };
|
||||
if (item.key === "form" && (formEnabled ?? integration.config.preChat?.enabled)) return { ...item, hint: { text: t("pre_chat.on") } };
|
||||
return item;
|
||||
})}
|
||||
activeKey={section}
|
||||
note={t("settings.web_changes_apply_after_save")}
|
||||
onSelect={(next) => { if (next === "danger") setConfirmingDelete(true); else setSection(next); }}
|
||||
>
|
||||
<div className="portal-settings-inner" hidden={section !== "basics"}>
|
||||
<div className="portal-settings-heading">
|
||||
<h3>{t("portals.basics")}</h3>
|
||||
</div>
|
||||
<div hidden={section !== "fields"}>
|
||||
<SiteFieldsSection key={integration.id} integration={integration} onSaved={setIntegration} onCount={setFieldCount} />
|
||||
</div>
|
||||
<div hidden={section !== "form"}>
|
||||
<PreChatSection key={integration.id} integration={integration} onSaved={setIntegration} onEnabled={setFormEnabled} />
|
||||
</div>
|
||||
{section === "look" && <WebIntegrationAppearance key={integration.id} integration={integration} onSaved={setIntegration} />}
|
||||
<WebIntegrationBasics key={integration.id} integration={integration} onSaved={setIntegration} />
|
||||
</div>
|
||||
</div>
|
||||
<div hidden={section !== "fields"}>
|
||||
<SiteFieldsSection key={integration.id} integration={integration} onSaved={setIntegration} onCount={setFieldCount} />
|
||||
</div>
|
||||
<div hidden={section !== "form"}>
|
||||
<PreChatSection key={integration.id} integration={integration} onSaved={setIntegration} onEnabled={setFormEnabled} />
|
||||
</div>
|
||||
{section === "look" && <WebIntegrationAppearance key={integration.id} integration={integration} onSaved={setIntegration} />}
|
||||
</IntegrationPageLayout>
|
||||
{confirmingDelete && <DeleteIntegrationDialog integration={integration} onClose={() => setConfirmingDelete(false)} onDeleted={onBack} />}
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -2,4 +2,8 @@
|
||||
.app-dropdown.is-server-add .ant-dropdown-menu-item-group-title { padding: 8px 10px 4px; font-size: 11px; font-weight: 600; letter-spacing: .04em; text-transform: uppercase; color: var(--n-4); }
|
||||
.app-dropdown.is-server-add .ant-dropdown-menu-item button > i { display: grid; place-items: center; flex: none; width: 28px; height: 28px; border-radius: 8px; background: var(--n-9); color: var(--n-3); }
|
||||
.app-dropdown.is-server-add .ant-dropdown-menu-item strong { font-size: 13px; font-weight: 600; color: var(--n-1); }
|
||||
.app-dropdown.is-server-add .ant-dropdown-menu-item button small { font-size: 11.5px; font-weight: 400; line-height: 1.4; }
|
||||
.app-dropdown.is-server-add .ant-dropdown-menu-item button { align-items: flex-start; gap: 10px; white-space: normal; }
|
||||
.app-dropdown.is-server-add .ant-dropdown-menu-item button > span { flex: 1; min-width: 0; }
|
||||
.app-dropdown.is-server-add .ant-dropdown-menu-item button small { color: var(--n-4); font-size: 11.5px; font-weight: 400; line-height: 1.4; }
|
||||
.integration-add-button.primary-button.row-menu-button { width: auto; min-height: 34px; height: 34px; padding: 0 12px 0 14px; gap: 7px; border-radius: 8px; font-size: 13px; color: var(--on-primary); background: var(--primary); }
|
||||
.integration-add-button.primary-button.row-menu-button:hover, .integration-add-button.primary-button.row-menu-button[aria-expanded="true"] { color: var(--on-primary); background: var(--primary-hover); }
|
||||
@@ -0,0 +1,15 @@
|
||||
.integrations-list-note { margin: 12px 4px 0; color: var(--n-4); font-size: 12.5px; line-height: 1.5; }
|
||||
.integration-tools-count { font-size: 13px; font-weight: 500; }
|
||||
.integrations-connections-table, .integrations-connections-table thead, .integrations-connections-table tbody { display: block; }
|
||||
.integrations-connections-table tr { display: grid; grid-template-columns: minmax(0,1.8fr) minmax(0,1.1fr) minmax(0,1.3fr) 150px; gap: 16px; align-items: center; padding: 10px 20px; border-bottom: 1px solid var(--n-9); }
|
||||
.integrations-connections-table tbody tr { min-height: 62px; }
|
||||
.integrations-connections-table thead tr { background: var(--surface-sidebar); border-bottom-color: var(--n-8); }
|
||||
.integrations-connections-table tbody tr:last-child { border-bottom: 0; }
|
||||
.settings-content .integrations-connections-table tr > :is(th,td), .settings-content .integrations-connections-table tr > :is(th,td):first-child, .settings-content .integrations-connections-table tr > :is(th,td):last-child { min-width: 0; height: auto; padding: 0; border: 0; background: transparent; }
|
||||
.integrations-connections-table .product-cell > span:last-child { min-width: 0; flex: 1; }
|
||||
.integrations-connections-table .product-cell .link.is-strong { max-width: 100%; }
|
||||
.settings-content .integrations-connections-table .product-cell strong, .settings-content .integrations-connections-table .product-cell .link.is-strong { display: block; font-size: 14px; font-weight: 700; }
|
||||
.integrations-connections-table .product-cell strong, .integrations-connections-table .product-cell .link, .integrations-connections-table .product-cell small, .integrations-connections-table .integration-status > small { overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
|
||||
.settings-content .integrations-connections-table .integration-status { gap: 4px; }
|
||||
.settings-content .integrations-connections-table .integration-status > small { font-size: 12px; }
|
||||
.integrations-connections-table .integration-agent { max-width: 100%; overflow: hidden; white-space: nowrap; }
|
||||
+13
-17
@@ -1,6 +1,7 @@
|
||||
import { useState } from "react";
|
||||
import { fmt, t } from "../../../i18n";
|
||||
import { SectionMenu, type SectionMenuItem } from "../../../shared/SectionMenu";
|
||||
import type { SectionMenuItem } from "../../../shared/SectionMenu";
|
||||
import { IntegrationPageLayout } from "../IntegrationPageLayout";
|
||||
import type { Integration } from "../model";
|
||||
import { DeleteIntegrationDialog } from "../DeleteIntegrationDialog";
|
||||
import { HttpParameters } from "./HttpParameters";
|
||||
@@ -10,9 +11,6 @@ import { ServerConnection } from "./ServerConnection";
|
||||
import { ServerHeader } from "./ServerHeader";
|
||||
import { useServerEditor } from "./useServerEditor";
|
||||
import type { ServerKind } from "./types";
|
||||
import "../../support-portals/styles-detail.css";
|
||||
import "../../support-portals/styles-settings.css";
|
||||
import "../web-integration.css";
|
||||
import "./styles.css";
|
||||
import "./tools.css";
|
||||
import "./parameters.css";
|
||||
@@ -28,22 +26,20 @@ export function ExternalServerPage({ initial, kind, integrations, onCreated, onB
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const mcp = kind === "mcp";
|
||||
const items: SectionMenuItem<Section>[] = [
|
||||
{ key: "main", label: t(mcp ? "servers.connection" : "servers.tool"), icon: mcp ? "plug" : "wrench" },
|
||||
mcp ? { key: "tools", label: t("ai.tools"), icon: "wrench", hint: { text: fmt.number(editor.integration?.externalServer?.tools?.length ?? 0) } }
|
||||
: { key: "parameters", label: t("servers.parameters"), icon: "list", hint: { text: fmt.number(editor.draft.externalServer.parameters?.length ?? 0) } },
|
||||
{ key: "delete", label: t("servers.delete"), icon: "trash", danger: true, disabled: !editor.integration, divider: true },
|
||||
{ key: "main", label: t(mcp ? "servers.connection" : "servers.tool"), icon: "settings" },
|
||||
mcp ? { key: "tools", label: t("ai.tools"), icon: "wrench", hint: { text: fmt.number(editor.integration?.externalServer?.tools?.length ?? 0) }, divider: true }
|
||||
: { key: "parameters", label: t("servers.parameters"), icon: "list", hint: { text: fmt.number(editor.draft.externalServer.parameters?.length ?? 0) }, divider: true },
|
||||
{ key: "delete", label: t(mcp ? "servers.delete" : "servers.delete_tool"), icon: "trash", danger: true, disabled: !editor.integration },
|
||||
];
|
||||
return <section className="web-integration-page external-server-page">
|
||||
<ServerHeader editor={editor} onBack={onBack} onOpenSettings={onOpenSettings} />
|
||||
<div className="portal-settings-layout">
|
||||
<SectionMenu items={items} activeKey={section} note={t("servers.changes_note")}
|
||||
onSelect={(key) => key === "delete" ? setDeleting(true) : setSection(key)} />
|
||||
<div className="portal-settings-content">
|
||||
return <>
|
||||
<IntegrationPageLayout className="external-server-page"
|
||||
header={<ServerHeader editor={editor} onBack={onBack} onOpenSettings={onOpenSettings} />}
|
||||
items={items} activeKey={section} note={t("servers.changes_note")}
|
||||
onSelect={(key) => key === "delete" ? setDeleting(true) : setSection(key)}>
|
||||
{section === "main" && (mcp ? <ServerConnection editor={editor} /> : <HttpToolForm editor={editor} />)}
|
||||
{section === "tools" && <McpTools editor={editor} openConnection={() => setSection("main")} />}
|
||||
{section === "parameters" && <HttpParameters editor={editor} integrations={integrations} />}
|
||||
</div>
|
||||
</div>
|
||||
</IntegrationPageLayout>
|
||||
{deleting && editor.integration && <DeleteIntegrationDialog integration={editor.integration} onClose={() => setDeleting(false)} onDeleted={onBack} />}
|
||||
</section>;
|
||||
</>;
|
||||
}
|
||||
@@ -1,14 +1,17 @@
|
||||
import { t, tn } from "../../../i18n";
|
||||
import { Icon } from "../../../shared/icons";
|
||||
import { CopyButton } from "../../../shared/ui-controls";
|
||||
import { StatusPill } from "../../../shared/ui";
|
||||
import { CopyButton, ToneBadge } from "../../../shared/ui-controls";
|
||||
import { STATUS_META } from "../model";
|
||||
import type { ServerEditor } from "./useServerEditor";
|
||||
|
||||
export function ServerHeader({ editor, onOpenSettings, onBack }: { editor: ServerEditor; onOpenSettings: () => void; onBack: () => void }) {
|
||||
const server = editor.integration?.externalServer ?? editor.draft.externalServer;
|
||||
const mcp = server.type === "mcp";
|
||||
const name = editor.integration?.name || t(mcp ? "servers.new_mcp" : "servers.new_http");
|
||||
const status = !editor.integration ? "draft" : !editor.integration.isActive ? "disabled" : editor.integration.status === "OK" ? "healthy" : editor.integration.status === "ERROR" ? "error" : "unchecked";
|
||||
const status = editor.integration
|
||||
? editor.integration.isActive ? STATUS_META[editor.integration.status]
|
||||
: { bg: "var(--n-9)", color: "var(--n-4)", label: t("settings.disabled") }
|
||||
: { bg: "var(--n-9)", color: "var(--n-4)", label: t("common.draft") };
|
||||
return <header className="web-integration-head">
|
||||
<nav className="portal-breadcrumbs">
|
||||
<button className="link is-strong" type="button" onClick={onOpenSettings}>{t("common.settings")}</button><span>/</span>
|
||||
@@ -16,7 +19,7 @@ export function ServerHeader({ editor, onOpenSettings, onBack }: { editor: Serve
|
||||
</nav>
|
||||
<div className="web-integration-title">
|
||||
<span className="server-title-icon"><Icon name={mcp ? "server" : "swap"} size={17} /></span><h2>{name}</h2>
|
||||
<StatusPill status={status} label={status === "healthy" ? t("common.connected") : status === "unchecked" ? t("ai.not_checked") : undefined} /><span className="web-integration-agent">{t(mcp ? "servers.new_mcp" : "servers.new_http")} · {tn("servers.count", mcp ? server.tools?.length ?? 0 : 1)}</span>
|
||||
<ToneBadge className="web-integration-status" bg={status.bg} color={status.color}>{status.label}</ToneBadge><span className="web-integration-agent">{t(mcp ? "servers.new_mcp" : "servers.new_http")} · {tn("servers.count", mcp ? server.tools?.length ?? 0 : 1)}</span>
|
||||
</div>
|
||||
{server.url && <div className="web-integration-embed"><span className="web-integration-embed-code"><code>{mcp ? "" : `${server.method ?? "GET"} `}{server.url}</code><CopyButton value={server.url} className="web-integration-copy" /></span><span>{t(mcp ? "servers.address_note" : "servers.request_note")}</span></div>}
|
||||
</header>;
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { t } from "../../../i18n";
|
||||
import { Icon } from "../../../shared/icons";
|
||||
import "./styles.css";
|
||||
|
||||
// X9: шапка-каркас и центральный индикатор загрузки.
|
||||
export function ServerPageLoading() {
|
||||
return <section className="server-page-loading" role="status" aria-label={t("servers.loading")}>
|
||||
<header><i /><span><i /><i /></span></header>
|
||||
<div><Icon name="refresh" size={15} />{t("servers.loading")}</div>
|
||||
<div><svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" strokeWidth="2.4" strokeLinecap="round" aria-hidden="true"><path d="M21 12a9 9 0 1 1-9-9" /></svg>{t("servers.loading")}</div>
|
||||
</section>;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { t } from "../../../i18n";
|
||||
import { Icon } from "../../../shared/icons";
|
||||
import { ContentState } from "../../../shared/ui";
|
||||
import { Button } from "../../../shared/ui-controls";
|
||||
import "./styles.css";
|
||||
|
||||
export function ServerPageState({ failed = false, onRetry, onBack }: {
|
||||
failed?: boolean;
|
||||
onRetry: () => void;
|
||||
onBack: () => void;
|
||||
}) {
|
||||
const errorIcon = <svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||
<circle cx="12" cy="12" r="9" /><path d="M12 8v4M12 16v.01" />
|
||||
</svg>;
|
||||
return <ContentState className={`server-page-state${failed ? " is-error" : ""}`}
|
||||
icon={failed ? errorIcon : <Icon name="server" size={18} strokeWidth={1.9} />}
|
||||
title={t(failed ? "servers.load_failed" : "servers.not_found")}
|
||||
text={t(failed ? "servers.load_failed_hint" : "servers.not_found_hint")}
|
||||
action={failed ? <Button variant="secondary" onClick={onRetry}>{t("servers.retry")}</Button>
|
||||
: <button className="link is-strong" type="button" onClick={onBack}>{t("servers.back")}</button>} />;
|
||||
}
|
||||
@@ -1,10 +1,11 @@
|
||||
import { t } from "../../../i18n";
|
||||
import { ShieldIcon } from "../../../shared/icons";
|
||||
import { helpArticleUrl } from "../../../shared/help";
|
||||
|
||||
export function ServerWarning() {
|
||||
return <div className="server-warning">
|
||||
<ShieldIcon />
|
||||
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" strokeWidth="1.9" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" />
|
||||
</svg>
|
||||
<p>{t("servers.spoof_warning")}{" "}<a className="link" href={helpArticleUrl("instrumenty-agenta")} target="_blank" rel="noopener noreferrer">{t("servers.help")}</a></p>
|
||||
</div>;
|
||||
}
|
||||
@@ -57,10 +57,13 @@
|
||||
.server-confirm-modal .ant-modal-footer { padding: 14px 24px; margin: 0; border-top: 1px solid var(--n-8); }
|
||||
.server-confirm-check { display: flex; align-items: center; gap: 9px; font-size: 13px; color: var(--n-2); cursor: pointer; }
|
||||
.server-confirm-check input { accent-color: var(--primary); }
|
||||
.server-page-state { display: flex; flex: 1; flex-direction: column; align-items: center; justify-content: center; width: 100%; margin: 0; border: 0; border-radius: 0; box-shadow: none; background: var(--surface-feed); }
|
||||
.server-page-state > p { max-width: 360px; }
|
||||
.server-page-state .content-state-icon { width: 40px; height: 40px; border-radius: 50%; background: var(--n-9); color: var(--n-4); }
|
||||
.server-page-state.is-error .content-state-icon { background: var(--error-bg); color: var(--error-text); }
|
||||
.content-state.server-page-state { display: flex; flex: 1; flex-direction: column; align-items: center; justify-content: center; width: 100%; height: 100%; padding: 30px; margin: 0; border: 0; border-radius: 0; box-shadow: none; background: var(--surface-feed); }
|
||||
.content-state.server-page-state > strong { margin-bottom: 6px; color: var(--n-2); }
|
||||
.content-state.server-page-state > p { max-width: 360px; margin-bottom: 14px; color: var(--n-4); }
|
||||
.content-state.server-page-state .content-state-icon { width: 40px; height: 40px; margin-bottom: 12px; border-radius: 50%; background: var(--n-9); color: var(--n-4); }
|
||||
.content-state.server-page-state.is-error .content-state-icon { background: var(--error-bg); color: var(--error-text); }
|
||||
.content-state.server-page-state .secondary-button { height: 34px; padding: 0 14px; border-radius: 8px; font-size: 13px; font-weight: 500; }
|
||||
.content-state.server-page-state .link.is-strong { font-size: 13px; font-weight: 600; }
|
||||
.server-page-loading { display: flex; height: 100%; flex-direction: column; background: var(--surface-feed); }
|
||||
.server-page-loading i { display: block; height: 14px; border-radius: 5px; background: var(--n-9); }
|
||||
.server-page-loading header { padding: 18px 22px 16px; border-bottom: 1px solid var(--n-8); background: var(--surface-card); }
|
||||
|
||||
@@ -21,6 +21,7 @@ export function useIntegrations(enabled: boolean): IntegrationsState {
|
||||
|
||||
const reload = useCallback(() => {
|
||||
if (!enabled) return;
|
||||
setLoading(true);
|
||||
setFailed(false);
|
||||
api<{ items: Integration[] }>("/api/v1/integrations/")
|
||||
.then((payload) => setItems(payload.items))
|
||||
|
||||
@@ -11,6 +11,7 @@ export const externalServersEn: Record<keyof typeof externalServersRu, Message>
|
||||
"servers.mcp_hint": "A ready server with a set of tools: orders, catalog, prices",
|
||||
"servers.http_hint": "One request to your API, such as order status by number",
|
||||
"servers.agent_tools": "Agent · tools",
|
||||
"servers.list_note": "The server name opens its page. Enable tools for each agent separately in the Tools section of the agent card.",
|
||||
"servers.count": { one: "{count} tool", other: "{count} tools" },
|
||||
"servers.new_mcp": "MCP server",
|
||||
"servers.new_http": "HTTP request",
|
||||
@@ -18,6 +19,8 @@ export const externalServersEn: Record<keyof typeof externalServersRu, Message>
|
||||
"servers.tool": "Tool",
|
||||
"servers.parameters": "Parameters",
|
||||
"servers.delete": "Delete server",
|
||||
"servers.delete_tool": "Delete tool",
|
||||
"servers.retry": "Retry",
|
||||
"servers.changes_note": "Changes reach agents immediately after saving.",
|
||||
"servers.connection_lead": "The server agents use to retrieve your organization's data: order statuses, menus and prices.",
|
||||
"servers.enabled": "Server enabled",
|
||||
|
||||
@@ -8,6 +8,7 @@ export const externalServersRu = {
|
||||
"servers.mcp_hint": "Готовый сервер с набором инструментов: заказы, каталог, цены",
|
||||
"servers.http_hint": "Один запрос к вашему API — например, статус заказа по номеру",
|
||||
"servers.agent_tools": "Агент · инструменты",
|
||||
"servers.list_note": "Название сервера открывает его страницу. Инструменты включаются для каждого агента отдельно — на карточке агента, блок «Инструменты».",
|
||||
"servers.count": { one: "{count} инструмент", few: "{count} инструмента", many: "{count} инструментов", other: "{count} инструмента" },
|
||||
"servers.new_mcp": "MCP-сервер",
|
||||
"servers.new_http": "HTTP-запрос",
|
||||
@@ -15,6 +16,8 @@ export const externalServersRu = {
|
||||
"servers.tool": "Инструмент",
|
||||
"servers.parameters": "Параметры",
|
||||
"servers.delete": "Удалить сервер",
|
||||
"servers.delete_tool": "Удалить инструмент",
|
||||
"servers.retry": "Повторить",
|
||||
"servers.changes_note": "Изменения доходят до агентов сразу после сохранения.",
|
||||
"servers.connection_lead": "Сервер, через который агенты получают данные вашей организации: статусы заказов, меню, цены.",
|
||||
"servers.enabled": "Сервер включён",
|
||||
|
||||
Reference in new issue
Block a user