提交 427a845b authored 作者: Vittorio's avatar Vittorio

支持连接器

上级 99d0221f
{ {
"i18n-ally.localesPaths": [ "i18n-ally.localesPaths": ["src/i18n", "src/i18n/locales"]
"src/i18n", }
"src/i18n/locales"
]
}
\ No newline at end of file
CREATE TABLE `connectors` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`name` text NOT NULL,
`type` text NOT NULL,
`source_url` text NOT NULL,
`spec_version` text,
`description` text,
`raw_spec` text,
`endpoints_json` text,
`last_synced_at` integer,
`created_at` integer DEFAULT (unixepoch()) NOT NULL,
`updated_at` integer DEFAULT (unixepoch()) NOT NULL
);
差异被折叠。
...@@ -204,6 +204,13 @@ ...@@ -204,6 +204,13 @@
"when": 1776728360068, "when": 1776728360068,
"tag": "0028_icy_veda", "tag": "0028_icy_veda",
"breakpoints": true "breakpoints": true
},
{
"idx": 29,
"version": "6",
"when": 1777625433944,
"tag": "0029_elite_khan",
"breakpoints": true
} }
] ]
} }
\ No newline at end of file
...@@ -4,6 +4,7 @@ import type { RuntimeMode2, UserSettings } from "@/lib/schemas"; ...@@ -4,6 +4,7 @@ import type { RuntimeMode2, UserSettings } from "@/lib/schemas";
export const currentAppAtom = atom<App | null>(null); export const currentAppAtom = atom<App | null>(null);
export const selectedAppIdAtom = atom<number | null>(null); export const selectedAppIdAtom = atom<number | null>(null);
export const selectedConnectorIdAtom = atom<number | null>(null);
export const versionsListAtom = atom<Version[]>([]); export const versionsListAtom = atom<Version[]>([]);
export const previewModeAtom = atom< export const previewModeAtom = atom<
| "preview" | "preview"
......
import { useNavigate } from "@tanstack/react-router";
import { Globe, PlusCircle } from "lucide-react";
import { useAtomValue } from "jotai";
import { selectedConnectorIdAtom } from "@/atoms/appAtoms";
import {
SidebarGroup,
SidebarGroupContent,
SidebarGroupLabel,
SidebarMenu,
SidebarMenuItem,
} from "@/components/ui/sidebar";
import { Button } from "@/components/ui/button";
import { useLoadConnectors } from "@/hooks/useLoadConnectors";
import type { ConnectorSummary } from "@/ipc/types";
import { formatDistanceToNow } from "date-fns";
import { useTranslation } from "react-i18next";
export function ConnectorList({ show }: { show?: boolean }) {
const { t } = useTranslation("home");
const navigate = useNavigate();
const selectedConnectorId = useAtomValue(selectedConnectorIdAtom);
const { connectors, loading, error } = useLoadConnectors();
if (!show) {
return null;
}
return (
<SidebarGroup
className="overflow-y-auto h-[calc(100vh-112px)]"
data-testid="connector-list-container"
>
<SidebarGroupLabel>{t("connectors.list.title")}</SidebarGroupLabel>
<SidebarGroupContent>
<div className="flex flex-col space-y-2">
<Button
onClick={() => navigate({ to: "/connectors", search: {} })}
variant="outline"
className="flex items-center justify-start gap-2 mx-2 py-2"
>
<PlusCircle size={16} />
<span>{t("connectors.list.import")}</span>
</Button>
{loading ? (
<div className="py-2 px-4 text-sm text-gray-500">
{t("connectors.list.loading")}
</div>
) : error ? (
<div className="py-2 px-4 text-sm text-red-500">
{t("connectors.list.error")}
</div>
) : connectors.length === 0 ? (
<div className="py-2 px-4 text-sm text-gray-500">
{t("connectors.list.empty")}
</div>
) : (
<SidebarMenu className="space-y-1" data-testid="connector-list">
{connectors.map((connector: ConnectorSummary) => (
<SidebarMenuItem key={connector.id} className="mb-1 relative">
<div
className="flex w-[206px] items-center"
title={connector.name}
>
<Button
variant="ghost"
onClick={() =>
navigate({
to: "/connectors",
search: { id: connector.id },
})
}
className={`justify-start w-full text-left py-3 hover:bg-sidebar-accent/80 ${
selectedConnectorId === connector.id
? "bg-sidebar-accent text-sidebar-accent-foreground"
: ""
}`}
>
<div className="flex flex-col w-4/5">
<div className="flex items-center gap-2">
<Globe size={12} className="flex-shrink-0" />
<span className="truncate">{connector.name}</span>
</div>
<span className="text-xs text-gray-500 truncate">
{connector.endpointCount}{" "}
{t("connectors.endpointsLabel")}{" "}
{connector.lastSyncedAt
? formatDistanceToNow(
new Date(connector.lastSyncedAt),
{
addSuffix: true,
},
)
: t("connectors.neverSynced")}
</span>
</div>
</Button>
</div>
</SidebarMenuItem>
))}
</SidebarMenu>
)}
</div>
</SidebarGroupContent>
</SidebarGroup>
);
}
...@@ -145,8 +145,8 @@ export function ContextFilesPicker() { ...@@ -145,8 +145,8 @@ export function ContextFilesPicker() {
<TooltipContent className="max-w-[300px]"> <TooltipContent className="max-w-[300px]">
{isSmartContextEnabled ? ( {isSmartContextEnabled ? (
<p> <p>
With Smart Context, bit-PM uses the most relevant files as With Smart Context, bit-PM uses the most relevant files
context. as context.
</p> </p>
) : ( ) : (
<p>By default, bit-PM uses your whole codebase.</p> <p>By default, bit-PM uses your whole codebase.</p>
......
...@@ -101,8 +101,8 @@ ${debugInfo.logs.slice(-3_500) || "No logs available"} ...@@ -101,8 +101,8 @@ ${debugInfo.logs.slice(-3_500) || "No logs available"}
<div className="mt-4 p-3 bg-blue-50 dark:bg-blue-950 border border-blue-200 dark:border-blue-800 rounded-md flex items-center gap-2"> <div className="mt-4 p-3 bg-blue-50 dark:bg-blue-950 border border-blue-200 dark:border-blue-800 rounded-md flex items-center gap-2">
<LightbulbIcon className="h-4 w-4 text-blue-700 dark:text-blue-400 flex-shrink-0" /> <LightbulbIcon className="h-4 w-4 text-blue-700 dark:text-blue-400 flex-shrink-0" />
<p className="text-sm text-blue-700 dark:text-blue-400"> <p className="text-sm text-blue-700 dark:text-blue-400">
<strong>Tip:</strong> Try closing and re-opening bit-PM as a temporary <strong>Tip:</strong> Try closing and re-opening bit-PM as a
workaround. temporary workaround.
</p> </p>
</div> </div>
</div> </div>
......
...@@ -623,7 +623,9 @@ export function ImportAppDialog({ isOpen, onClose }: ImportAppDialogProps) { ...@@ -623,7 +623,9 @@ export function ImportAppDialog({ isOpen, onClose }: ImportAppDialogProps) {
onChange={(e) => onChange={(e) =>
setInstallCommand(e.target.value) setInstallCommand(e.target.value)
} }
placeholder={t("home:installCommandPlaceholder")} placeholder={t(
"home:installCommandPlaceholder",
)}
className="text-sm" className="text-sm"
disabled={importing} disabled={importing}
/> />
......
...@@ -423,7 +423,9 @@ function NodeInstallButton({ ...@@ -423,7 +423,9 @@ function NodeInstallButton({
case "waiting-for-continue": case "waiting-for-continue":
return ( return (
<Button className="mt-3" onClick={finishNodeInstall}> <Button className="mt-3" onClick={finishNodeInstall}>
<div className="flex items-center gap-2">{t("setup.continueInstalled")}</div> <div className="flex items-center gap-2">
{t("setup.continueInstalled")}
</div>
</Button> </Button>
); );
case "finished-checking": case "finished-checking":
......
import { import { Home, Inbox, Settings, HelpCircle, Globe } from "lucide-react";
Home,
Inbox,
Settings,
HelpCircle,
} from "lucide-react";
import { Link, useRouterState } from "@tanstack/react-router"; import { Link, useRouterState } from "@tanstack/react-router";
import { useSidebar } from "@/components/ui/sidebar"; // import useSidebar hook import { useSidebar } from "@/components/ui/sidebar"; // import useSidebar hook
import { useEffect, useState, useRef } from "react"; import { useEffect, useState, useRef } from "react";
...@@ -24,6 +19,7 @@ import { ...@@ -24,6 +19,7 @@ import {
} from "@/components/ui/sidebar"; } from "@/components/ui/sidebar";
import { ChatList } from "./ChatList"; import { ChatList } from "./ChatList";
import { AppList } from "./AppList"; import { AppList } from "./AppList";
import { ConnectorList } from "./ConnectorList";
import { HelpDialog } from "./HelpDialog"; // Import the new dialog import { HelpDialog } from "./HelpDialog"; // Import the new dialog
import { SettingsList } from "./SettingsList"; import { SettingsList } from "./SettingsList";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
...@@ -35,6 +31,11 @@ const items = [ ...@@ -35,6 +31,11 @@ const items = [
to: "/", to: "/",
icon: Home, icon: Home,
}, },
{
key: "connectors",
to: "/connectors",
icon: Globe,
},
{ {
key: "chat", key: "chat",
to: "/chat", to: "/chat",
...@@ -50,6 +51,7 @@ const items = [ ...@@ -50,6 +51,7 @@ const items = [
// Hover state types // Hover state types
type HoverState = type HoverState =
| "start-hover:app" | "start-hover:app"
| "start-hover:connector"
| "start-hover:chat" | "start-hover:chat"
| "start-hover:settings" | "start-hover:settings"
| "clear-hover" | "clear-hover"
...@@ -84,12 +86,16 @@ export function AppSidebar() { ...@@ -84,12 +86,16 @@ export function AppSidebar() {
const isAppRoute = const isAppRoute =
routerState.location.pathname === "/" || routerState.location.pathname === "/" ||
routerState.location.pathname.startsWith("/app-details"); routerState.location.pathname.startsWith("/app-details");
const isConnectorRoute =
routerState.location.pathname.startsWith("/connectors");
const isChatRoute = routerState.location.pathname === "/chat"; const isChatRoute = routerState.location.pathname === "/chat";
const isSettingsRoute = routerState.location.pathname.startsWith("/settings"); const isSettingsRoute = routerState.location.pathname.startsWith("/settings");
let selectedItem: string | null = null; let selectedItem: string | null = null;
if (hoverState === "start-hover:app") { if (hoverState === "start-hover:app") {
selectedItem = "apps"; selectedItem = "apps";
} else if (hoverState === "start-hover:connector") {
selectedItem = "connectors";
} else if (hoverState === "start-hover:chat") { } else if (hoverState === "start-hover:chat") {
selectedItem = "chat"; selectedItem = "chat";
} else if (hoverState === "start-hover:settings") { } else if (hoverState === "start-hover:settings") {
...@@ -97,6 +103,8 @@ export function AppSidebar() { ...@@ -97,6 +103,8 @@ export function AppSidebar() {
} else if (state === "expanded") { } else if (state === "expanded") {
if (isAppRoute) { if (isAppRoute) {
selectedItem = "apps"; selectedItem = "apps";
} else if (isConnectorRoute) {
selectedItem = "connectors";
} else if (isChatRoute) { } else if (isChatRoute) {
selectedItem = "chat"; selectedItem = "chat";
} else if (isSettingsRoute) { } else if (isSettingsRoute) {
...@@ -127,6 +135,7 @@ export function AppSidebar() { ...@@ -127,6 +135,7 @@ export function AppSidebar() {
{/* Right Column: Chat List Section */} {/* Right Column: Chat List Section */}
<div className="w-[272px]"> <div className="w-[272px]">
<AppList show={selectedItem === "apps"} /> <AppList show={selectedItem === "apps"} />
<ConnectorList show={selectedItem === "connectors"} />
<ChatList show={selectedItem === "chat"} /> <ChatList show={selectedItem === "chat"} />
<SettingsList show={selectedItem === "settings"} /> <SettingsList show={selectedItem === "settings"} />
</div> </div>
...@@ -182,9 +191,11 @@ function AppIcons({ ...@@ -182,9 +191,11 @@ function AppIcons({
const label = const label =
item.key === "apps" item.key === "apps"
? t("home:sidebar.apps") ? t("home:sidebar.apps")
: item.key === "chat" : item.key === "connectors"
? t("home:sidebar.chat") ? t("home:sidebar.connectors")
: t("settings:title"); : item.key === "chat"
? t("home:sidebar.chat")
: t("settings:title");
return ( return (
<SidebarMenuItem key={item.key}> <SidebarMenuItem key={item.key}>
...@@ -198,6 +209,8 @@ function AppIcons({ ...@@ -198,6 +209,8 @@ function AppIcons({
onMouseEnter={() => { onMouseEnter={() => {
if (item.key === "apps") { if (item.key === "apps") {
onHoverChange("start-hover:app"); onHoverChange("start-hover:app");
} else if (item.key === "connectors") {
onHoverChange("start-hover:connector");
} else if (item.key === "chat") { } else if (item.key === "chat") {
onHoverChange("start-hover:chat"); onHoverChange("start-hover:chat");
} else if (item.key === "settings") { } else if (item.key === "settings") {
......
...@@ -245,7 +245,9 @@ export function HomeChatInput({ ...@@ -245,7 +245,9 @@ export function HomeChatInput({
<Mic size={20} /> <Mic size={20} />
<Lock size={10} className="absolute -top-0.5 -right-0.5" /> <Lock size={10} className="absolute -top-0.5 -right-0.5" />
</TooltipTrigger> </TooltipTrigger>
<TooltipContent>{t("chat:voiceToTextRequiresPro")}</TooltipContent> <TooltipContent>
{t("chat:voiceToTextRequiresPro")}
</TooltipContent>
</Tooltip> </Tooltip>
)} )}
...@@ -261,7 +263,9 @@ export function HomeChatInput({ ...@@ -261,7 +263,9 @@ export function HomeChatInput({
> >
<StopCircleIcon size={20} /> <StopCircleIcon size={20} />
</TooltipTrigger> </TooltipTrigger>
<TooltipContent>{t("chat:cancelGenerationUnavailable")}</TooltipContent> <TooltipContent>
{t("chat:cancelGenerationUnavailable")}
</TooltipContent>
</Tooltip> </Tooltip>
) : ( ) : (
<Tooltip> <Tooltip>
......
...@@ -86,6 +86,29 @@ export const chats = sqliteTable("chats", { ...@@ -86,6 +86,29 @@ export const chats = sqliteTable("chats", {
chatMode: text("chat_mode").$type<StoredChatMode | null>(), chatMode: text("chat_mode").$type<StoredChatMode | null>(),
}); });
export const connectors = sqliteTable("connectors", {
id: integer("id").primaryKey({ autoIncrement: true }),
name: text("name").notNull(),
type: text("type", { enum: ["openapi"] }).notNull(),
sourceUrl: text("source_url").notNull(),
specVersion: text("spec_version"),
description: text("description"),
rawSpec: text("raw_spec", { mode: "json" }).$type<Record<
string,
unknown
> | null>(),
endpointsJson: text("endpoints_json", { mode: "json" }).$type<
ConnectorEndpoint[] | null
>(),
lastSyncedAt: integer("last_synced_at", { mode: "timestamp" }),
createdAt: integer("created_at", { mode: "timestamp" })
.notNull()
.default(sql`(unixepoch())`),
updatedAt: integer("updated_at", { mode: "timestamp" })
.notNull()
.default(sql`(unixepoch())`),
});
export const messages = sqliteTable("messages", { export const messages = sqliteTable("messages", {
id: integer("id").primaryKey({ autoIncrement: true }), id: integer("id").primaryKey({ autoIncrement: true }),
chatId: integer("chat_id") chatId: integer("chat_id")
...@@ -120,6 +143,16 @@ export const messages = sqliteTable("messages", { ...@@ -120,6 +143,16 @@ export const messages = sqliteTable("messages", {
.default(sql`(unixepoch())`), .default(sql`(unixepoch())`),
}); });
export type ConnectorEndpoint = {
id: string;
method: string;
path: string;
operationId: string | null;
summary: string | null;
description: string | null;
tags: string[];
};
export const versions = sqliteTable( export const versions = sqliteTable(
"versions", "versions",
{ {
...@@ -148,6 +181,8 @@ export const appsRelations = relations(apps, ({ many }) => ({ ...@@ -148,6 +181,8 @@ export const appsRelations = relations(apps, ({ many }) => ({
versions: many(versions), versions: many(versions),
})); }));
export const connectorsRelations = relations(connectors, () => ({}));
export const chatsRelations = relations(chats, ({ many, one }) => ({ export const chatsRelations = relations(chats, ({ many, one }) => ({
messages: many(messages), messages: many(messages),
app: one(apps, { app: one(apps, {
......
import { useEffect } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useAtom } from "jotai";
import { ipc, type Connector } from "@/ipc/types";
import { queryKeys } from "@/lib/queryKeys";
import { selectedConnectorIdAtom } from "@/atoms/appAtoms";
export function useLoadConnector(connectorId: number | null) {
const [, setSelectedConnectorId] = useAtom(selectedConnectorIdAtom);
const queryClient = useQueryClient();
const { data, isLoading, error, refetch } = useQuery<Connector>({
queryKey: queryKeys.connectors.detail({ connectorId }),
queryFn: async () =>
ipc.connector.getConnector({ connectorId: connectorId ?? -1 }),
enabled: connectorId !== null,
});
useEffect(() => {
setSelectedConnectorId(connectorId);
}, [connectorId, setSelectedConnectorId]);
const refreshConnector = async () => {
await refetch();
await queryClient.invalidateQueries({ queryKey: queryKeys.connectors.all });
};
return {
connector: data ?? null,
loading: isLoading,
error: error ?? null,
refreshConnector,
};
}
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { ipc } from "@/ipc/types";
import { queryKeys } from "@/lib/queryKeys";
export function useLoadConnectors() {
const queryClient = useQueryClient();
const { data, isLoading, error } = useQuery({
queryKey: queryKeys.connectors.all,
queryFn: async () => {
const response = await ipc.connector.listConnectors();
return response.connectors;
},
});
const refreshConnectors = () => {
return queryClient.invalidateQueries({
queryKey: queryKeys.connectors.all,
});
};
return {
connectors: data ?? [],
loading: isLoading,
error: error ?? null,
refreshConnectors,
};
}
...@@ -90,8 +90,42 @@ ...@@ -90,8 +90,42 @@
"noFavorites": "Star an app from its details page to pin it here", "noFavorites": "Star an app from its details page to pin it here",
"others": "Other apps" "others": "Other apps"
}, },
"connectors": {
"kicker": "External APIs",
"title": "API Connectors",
"description": "Import a standard Swagger / OpenAPI URL, store it as a first-class connector, and browse the API endpoints Dyad can work with.",
"importTitle": "Import Swagger / OpenAPI",
"importDescription": "Paste a public OpenAPI JSON or YAML URL. Dyad will fetch it, parse the available endpoints, and save it as a connector.",
"urlPlaceholder": "https://example.com/openapi.json",
"namePlaceholder": "Optional connector name",
"importButton": "Import connector",
"importing": "Importing...",
"refresh": "Refresh",
"delete": "Delete",
"openSpec": "Open spec",
"endpointCount": "Endpoints",
"endpointsLabel": "endpoints",
"lastSynced": "Last synced",
"neverSynced": "Never",
"untagged": "Untagged",
"loading": "Loading connector...",
"emptyState": "Import an OpenAPI spec to start browsing its available API endpoints.",
"messages": {
"imported": "Connector imported successfully",
"refreshed": "Connector refreshed successfully",
"deleted": "Connector deleted"
},
"list": {
"title": "Your Connectors",
"import": "Import Connector",
"loading": "Loading connectors...",
"error": "Error loading connectors",
"empty": "No connectors yet"
}
},
"sidebar": { "sidebar": {
"apps": "Apps", "apps": "Apps",
"connectors": "Connectors",
"chat": "Chat", "chat": "Chat",
"help": "Help" "help": "Help"
}, },
......
...@@ -87,8 +87,42 @@ ...@@ -87,8 +87,42 @@
"noFavorites": "在应用详情页为应用加星,即可将其固定到这里", "noFavorites": "在应用详情页为应用加星,即可将其固定到这里",
"others": "其他应用" "others": "其他应用"
}, },
"connectors": {
"kicker": "外部 API",
"title": "API 连接器",
"description": "导入标准的 Swagger / OpenAPI 地址,把它保存为一级连接器,并浏览 Dyad 可用的 API 接口。",
"importTitle": "导入 Swagger / OpenAPI",
"importDescription": "粘贴公开的 OpenAPI JSON 或 YAML 地址。Dyad 会抓取规范、解析可用接口,并保存为连接器。",
"urlPlaceholder": "https://example.com/openapi.json",
"namePlaceholder": "可选的连接器名称",
"importButton": "导入连接器",
"importing": "正在导入...",
"refresh": "刷新",
"delete": "删除",
"openSpec": "打开规范",
"endpointCount": "接口数量",
"endpointsLabel": "个接口",
"lastSynced": "上次同步",
"neverSynced": "从未同步",
"untagged": "未分类",
"loading": "正在加载连接器...",
"emptyState": "导入一个 OpenAPI 规范后,就可以在这里浏览可用 API 接口。",
"messages": {
"imported": "连接器导入成功",
"refreshed": "连接器刷新成功",
"deleted": "连接器已删除"
},
"list": {
"title": "我的连接器",
"import": "导入连接器",
"loading": "正在加载连接器...",
"error": "加载连接器时出错",
"empty": "还没有连接器"
}
},
"sidebar": { "sidebar": {
"apps": "应用", "apps": "应用",
"connectors": "连接器",
"chat": "聊天", "chat": "聊天",
"help": "帮助" "help": "帮助"
}, },
......
import { and, desc, eq } from "drizzle-orm";
import { db } from "../../db";
import { connectors } from "../../db/schema";
import { createTypedHandler } from "./base";
import { connectorContracts } from "../types/connector";
import {
fetchAndParseOpenApiDocument,
extractOpenApiEndpoints,
} from "../utils/openapi_utils";
import { DyadError, DyadErrorKind } from "@/errors/dyad_error";
function toConnectorSummary(
connector: typeof connectors.$inferSelect,
endpointCount?: number,
) {
return {
id: connector.id,
name: connector.name,
type: connector.type,
sourceUrl: connector.sourceUrl,
specVersion: connector.specVersion,
description: connector.description,
endpointCount: endpointCount ?? connector.endpointsJson?.length ?? 0,
lastSyncedAt: connector.lastSyncedAt,
createdAt: connector.createdAt,
updatedAt: connector.updatedAt,
};
}
function toConnectorDetail(connector: typeof connectors.$inferSelect) {
return {
...toConnectorSummary(connector),
rawSpec: connector.rawSpec,
endpoints: connector.endpointsJson ?? [],
};
}
export function registerConnectorHandlers() {
createTypedHandler(connectorContracts.listConnectors, async () => {
const allConnectors = await db.query.connectors.findMany({
orderBy: [desc(connectors.updatedAt)],
});
return {
connectors: allConnectors.map((connector) =>
toConnectorSummary(connector),
),
};
});
createTypedHandler(connectorContracts.getConnector, async (_, params) => {
const connector = await db.query.connectors.findFirst({
where: eq(connectors.id, params.connectorId),
});
if (!connector) {
throw new DyadError("Connector not found", DyadErrorKind.NotFound);
}
return toConnectorDetail(connector);
});
createTypedHandler(
connectorContracts.importOpenApiConnector,
async (_, params) => {
const imported = await fetchAndParseOpenApiDocument(params.url);
const now = new Date();
const connectorName =
params.name?.trim() && params.name.trim().length > 0
? params.name.trim()
: imported.name;
const existing = await db.query.connectors.findFirst({
where: and(
eq(connectors.sourceUrl, params.url),
eq(connectors.type, "openapi"),
),
});
if (existing) {
await db
.update(connectors)
.set({
name: connectorName,
description: imported.description,
specVersion: imported.specVersion,
rawSpec: imported.rawSpec,
endpointsJson: imported.endpoints,
lastSyncedAt: now,
updatedAt: now,
})
.where(eq(connectors.id, existing.id));
const updated = await db.query.connectors.findFirst({
where: eq(connectors.id, existing.id),
});
if (!updated) {
throw new DyadError(
"Connector disappeared during update.",
DyadErrorKind.Internal,
);
}
return toConnectorDetail(updated);
}
const result = await db
.insert(connectors)
.values({
name: connectorName,
type: "openapi",
sourceUrl: params.url,
description: imported.description,
specVersion: imported.specVersion,
rawSpec: imported.rawSpec,
endpointsJson: imported.endpoints,
lastSyncedAt: now,
updatedAt: now,
})
.returning();
const created = result[0];
if (!created) {
throw new DyadError(
"Failed to create connector.",
DyadErrorKind.Internal,
);
}
return toConnectorDetail(created);
},
);
createTypedHandler(connectorContracts.refreshConnector, async (_, params) => {
const connector = await db.query.connectors.findFirst({
where: eq(connectors.id, params.connectorId),
});
if (!connector) {
throw new DyadError("Connector not found", DyadErrorKind.NotFound);
}
const imported = await fetchAndParseOpenApiDocument(connector.sourceUrl);
const now = new Date();
await db
.update(connectors)
.set({
name: connector.name,
description: imported.description,
specVersion: imported.specVersion,
rawSpec: imported.rawSpec,
endpointsJson: imported.endpoints,
lastSyncedAt: now,
updatedAt: now,
})
.where(eq(connectors.id, connector.id));
const refreshed = await db.query.connectors.findFirst({
where: eq(connectors.id, connector.id),
});
if (!refreshed) {
throw new DyadError(
"Connector disappeared during refresh.",
DyadErrorKind.Internal,
);
}
return toConnectorDetail(refreshed);
});
createTypedHandler(connectorContracts.deleteConnector, async (_, params) => {
const connector = await db.query.connectors.findFirst({
where: eq(connectors.id, params.connectorId),
});
if (!connector) {
throw new DyadError("Connector not found", DyadErrorKind.NotFound);
}
await db.delete(connectors).where(eq(connectors.id, params.connectorId));
});
}
export { extractOpenApiEndpoints };
...@@ -42,6 +42,7 @@ import { registerFreeAgentQuotaHandlers } from "./handlers/free_agent_quota_hand ...@@ -42,6 +42,7 @@ import { registerFreeAgentQuotaHandlers } from "./handlers/free_agent_quota_hand
import { registerPlanHandlers } from "./handlers/plan_handlers"; import { registerPlanHandlers } from "./handlers/plan_handlers";
import { registerMediaHandlers } from "./handlers/media_handlers"; import { registerMediaHandlers } from "./handlers/media_handlers";
import { registerImageGenerationHandlers } from "./handlers/image_generation_handlers"; import { registerImageGenerationHandlers } from "./handlers/image_generation_handlers";
import { registerConnectorHandlers } from "./handlers/connector_handlers";
export function registerIpcHandlers() { export function registerIpcHandlers() {
// Register all IPC handlers by category // Register all IPC handlers by category
...@@ -89,4 +90,5 @@ export function registerIpcHandlers() { ...@@ -89,4 +90,5 @@ export function registerIpcHandlers() {
registerPlanHandlers(); registerPlanHandlers();
registerMediaHandlers(); registerMediaHandlers();
registerImageGenerationHandlers(); registerImageGenerationHandlers();
registerConnectorHandlers();
} }
...@@ -43,6 +43,7 @@ import { planEvents, planContracts } from "../types/plan"; ...@@ -43,6 +43,7 @@ import { planEvents, planContracts } from "../types/plan";
import { audioContracts } from "../types/audio"; import { audioContracts } from "../types/audio";
import { mediaContracts } from "../types/media"; import { mediaContracts } from "../types/media";
import { imageGenerationContracts } from "../types/image_generation"; import { imageGenerationContracts } from "../types/image_generation";
import { connectorContracts } from "../types/connector";
// ============================================================================= // =============================================================================
// Invoke Channels (derived from all contracts) // Invoke Channels (derived from all contracts)
...@@ -101,6 +102,7 @@ export const VALID_INVOKE_CHANNELS = [ ...@@ -101,6 +102,7 @@ export const VALID_INVOKE_CHANNELS = [
...getInvokeChannels(audioContracts), ...getInvokeChannels(audioContracts),
...getInvokeChannels(mediaContracts), ...getInvokeChannels(mediaContracts),
...getInvokeChannels(imageGenerationContracts), ...getInvokeChannels(imageGenerationContracts),
...getInvokeChannels(connectorContracts),
// Test-only channels // Test-only channels
...TEST_INVOKE_CHANNELS, ...TEST_INVOKE_CHANNELS,
......
import { z } from "zod";
import { createClient, defineContract } from "../contracts/core";
export const ConnectorEndpointSchema = z.object({
id: z.string(),
method: z.string(),
path: z.string(),
operationId: z.string().nullable(),
summary: z.string().nullable(),
description: z.string().nullable(),
tags: z.array(z.string()),
});
export const ConnectorSummarySchema = z.object({
id: z.number(),
name: z.string(),
type: z.literal("openapi"),
sourceUrl: z.string(),
specVersion: z.string().nullable(),
description: z.string().nullable(),
endpointCount: z.number(),
lastSyncedAt: z.date().nullable(),
createdAt: z.date(),
updatedAt: z.date(),
});
export const ConnectorSchema = ConnectorSummarySchema.extend({
rawSpec: z.record(z.string(), z.unknown()).nullable(),
endpoints: z.array(ConnectorEndpointSchema),
});
export const ConnectorIdParamsSchema = z.object({
connectorId: z.number(),
});
export const ListConnectorsResponseSchema = z.object({
connectors: z.array(ConnectorSummarySchema),
});
export const ImportOpenApiConnectorParamsSchema = z.object({
url: z.string().url(),
name: z.string().trim().min(1).optional(),
});
export const connectorContracts = {
listConnectors: defineContract({
channel: "connector:list",
input: z.void(),
output: ListConnectorsResponseSchema,
}),
getConnector: defineContract({
channel: "connector:get",
input: ConnectorIdParamsSchema,
output: ConnectorSchema,
}),
importOpenApiConnector: defineContract({
channel: "connector:import-openapi",
input: ImportOpenApiConnectorParamsSchema,
output: ConnectorSchema,
}),
refreshConnector: defineContract({
channel: "connector:refresh",
input: ConnectorIdParamsSchema,
output: ConnectorSchema,
}),
deleteConnector: defineContract({
channel: "connector:delete",
input: ConnectorIdParamsSchema,
output: z.void(),
}),
} as const;
export const connectorClient = createClient(connectorContracts);
export type ConnectorEndpoint = z.infer<typeof ConnectorEndpointSchema>;
export type ConnectorSummary = z.infer<typeof ConnectorSummarySchema>;
export type Connector = z.infer<typeof ConnectorSchema>;
export type ImportOpenApiConnectorParams = z.infer<
typeof ImportOpenApiConnectorParamsSchema
>;
...@@ -22,6 +22,8 @@ ...@@ -22,6 +22,8 @@
* }); * });
*/ */
import { connectorClient } from "./connector";
// ============================================================================= // =============================================================================
// Contract Exports // Contract Exports
// ============================================================================= // =============================================================================
...@@ -54,6 +56,7 @@ export { freeAgentQuotaContracts } from "./free_agent_quota"; ...@@ -54,6 +56,7 @@ export { freeAgentQuotaContracts } from "./free_agent_quota";
export { audioContracts } from "./audio"; export { audioContracts } from "./audio";
export { mediaContracts } from "./media"; export { mediaContracts } from "./media";
export { imageGenerationContracts } from "./image_generation"; export { imageGenerationContracts } from "./image_generation";
export { connectorContracts } from "./connector";
// ============================================================================= // =============================================================================
// Client Exports // Client Exports
...@@ -87,6 +90,7 @@ export { freeAgentQuotaClient } from "./free_agent_quota"; ...@@ -87,6 +90,7 @@ export { freeAgentQuotaClient } from "./free_agent_quota";
export { audioClient } from "./audio"; export { audioClient } from "./audio";
export { mediaClient } from "./media"; export { mediaClient } from "./media";
export { imageGenerationClient } from "./image_generation"; export { imageGenerationClient } from "./image_generation";
export { connectorClient } from "./connector";
// ============================================================================= // =============================================================================
// Type Exports // Type Exports
...@@ -247,6 +251,13 @@ export type { ...@@ -247,6 +251,13 @@ export type {
UpdatePromptParamsDto, UpdatePromptParamsDto,
} from "./prompts"; } from "./prompts";
export type {
Connector,
ConnectorEndpoint,
ConnectorSummary,
ImportOpenApiConnectorParams,
} from "./connector";
// Template types // Template types
export type { export type {
Template, Template,
...@@ -423,6 +434,7 @@ export const ipc = { ...@@ -423,6 +434,7 @@ export const ipc = {
supabase: supabaseClient, supabase: supabaseClient,
neon: neonClient, neon: neonClient,
migration: migrationClient, migration: migrationClient,
connector: connectorClient,
// Features // Features
system: systemClient, system: systemClient,
......
import { describe, expect, it } from "vitest";
import { extractOpenApiEndpoints } from "./openapi_utils";
describe("extractOpenApiEndpoints", () => {
it("extracts endpoints from a JSON OpenAPI document", () => {
const endpoints = extractOpenApiEndpoints({
openapi: "3.0.3",
info: {
title: "Pets",
},
paths: {
"/pets": {
get: {
summary: "List pets",
operationId: "listPets",
tags: ["pets"],
},
post: {
summary: "Create pet",
operationId: "createPet",
tags: ["pets"],
},
},
},
});
expect(endpoints).toEqual([
{
id: "listPets",
method: "GET",
path: "/pets",
operationId: "listPets",
summary: "List pets",
description: null,
tags: ["pets"],
},
{
id: "createPet",
method: "POST",
path: "/pets",
operationId: "createPet",
summary: "Create pet",
description: null,
tags: ["pets"],
},
]);
});
it("supports Swagger-style documents", () => {
const endpoints = extractOpenApiEndpoints({
swagger: "2.0",
paths: {
"/users/{id}": {
delete: {
summary: "Delete user",
},
},
},
});
expect(endpoints).toEqual([
{
id: "DELETE /users/{id}",
method: "DELETE",
path: "/users/{id}",
operationId: null,
summary: "Delete user",
description: null,
tags: [],
},
]);
});
});
import YAML from "yaml";
import { DyadError, DyadErrorKind } from "@/errors/dyad_error";
import type { ConnectorEndpoint } from "@/db/schema";
const OPENAPI_METHODS = [
"get",
"post",
"put",
"patch",
"delete",
"options",
"head",
] as const;
type OpenApiLikeDocument = {
openapi?: string;
swagger?: string;
info?: {
title?: string;
description?: string;
};
paths?: Record<string, Record<string, Record<string, unknown>>>;
};
function parseSpecText(rawText: string): Record<string, unknown> {
try {
return JSON.parse(rawText) as Record<string, unknown>;
} catch {
try {
const parsed = YAML.parse(rawText);
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
throw new Error("OpenAPI document must be an object");
}
return parsed as Record<string, unknown>;
} catch (error) {
throw new DyadError(
`Failed to parse OpenAPI document: ${
error instanceof Error ? error.message : String(error)
}`,
DyadErrorKind.Validation,
);
}
}
}
function buildEndpointId(method: string, path: string, operationId?: string) {
return operationId?.trim() || `${method.toUpperCase()} ${path}`;
}
export function extractOpenApiEndpoints(
document: Record<string, unknown>,
): ConnectorEndpoint[] {
const spec = document as OpenApiLikeDocument;
if (!spec.paths || typeof spec.paths !== "object") {
throw new DyadError(
"OpenAPI document does not contain any paths.",
DyadErrorKind.Validation,
);
}
const endpoints: ConnectorEndpoint[] = [];
for (const [path, operations] of Object.entries(spec.paths)) {
if (!operations || typeof operations !== "object") continue;
for (const method of OPENAPI_METHODS) {
const operation = operations[method];
if (!operation || typeof operation !== "object") continue;
const summary =
typeof operation.summary === "string" ? operation.summary : null;
const description =
typeof operation.description === "string"
? operation.description
: null;
const operationId =
typeof operation.operationId === "string"
? operation.operationId
: null;
const tags = Array.isArray(operation.tags)
? operation.tags.filter((tag): tag is string => typeof tag === "string")
: [];
endpoints.push({
id: buildEndpointId(method, path, operationId ?? undefined),
method: method.toUpperCase(),
path,
operationId,
summary,
description,
tags,
});
}
}
if (endpoints.length === 0) {
throw new DyadError(
"No API endpoints were found in the OpenAPI document.",
DyadErrorKind.Validation,
);
}
return endpoints.sort((a, b) => {
const pathCompare = a.path.localeCompare(b.path);
if (pathCompare !== 0) return pathCompare;
return a.method.localeCompare(b.method);
});
}
export async function fetchAndParseOpenApiDocument(url: string): Promise<{
rawSpec: Record<string, unknown>;
name: string;
description: string | null;
specVersion: string | null;
endpoints: ConnectorEndpoint[];
}> {
let response: Response;
try {
response = await fetch(url);
} catch (error) {
throw new DyadError(
`Failed to fetch OpenAPI document: ${
error instanceof Error ? error.message : String(error)
}`,
DyadErrorKind.External,
);
}
if (!response.ok) {
throw new DyadError(
`Failed to fetch OpenAPI document (${response.status} ${response.statusText}).`,
DyadErrorKind.External,
);
}
const rawText = await response.text();
const rawSpec = parseSpecText(rawText);
const spec = rawSpec as OpenApiLikeDocument;
const specVersion =
typeof spec.openapi === "string"
? spec.openapi
: typeof spec.swagger === "string"
? spec.swagger
: null;
if (!specVersion) {
throw new DyadError(
"This document is not a valid Swagger/OpenAPI specification.",
DyadErrorKind.Validation,
);
}
const endpoints = extractOpenApiEndpoints(rawSpec);
const name =
typeof spec.info?.title === "string" && spec.info.title.trim().length > 0
? spec.info.title.trim()
: new URL(url).hostname;
const description =
typeof spec.info?.description === "string" ? spec.info.description : null;
return {
rawSpec,
name,
description,
specVersion,
endpoints,
};
}
...@@ -59,6 +59,12 @@ export const queryKeys = { ...@@ -59,6 +59,12 @@ export const queryKeys = {
["chats", "search", appId, query] as const, ["chats", "search", appId, query] as const,
}, },
connectors: {
all: ["connectors"] as const,
detail: ({ connectorId }: { connectorId: number | null }) =>
["connectors", "detail", connectorId] as const,
},
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
// Plans // Plans
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
......
...@@ -84,9 +84,7 @@ export default function AppsPage() { ...@@ -84,9 +84,7 @@ export default function AppsPage() {
) : filteredApps.length === 0 ? ( ) : filteredApps.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16 gap-3"> <div className="flex flex-col items-center justify-center py-16 gap-3">
<p className="text-muted-foreground text-center"> <p className="text-muted-foreground text-center">
{searchQuery {searchQuery ? t("apps.noSearchResults") : t("apps.empty")}
? t("apps.noSearchResults")
: t("apps.empty")}
</p> </p>
{!searchQuery && ( {!searchQuery && (
<Button onClick={() => navigate({ to: "/" })} size="sm"> <Button onClick={() => navigate({ to: "/" })} size="sm">
......
差异被折叠。
...@@ -11,6 +11,7 @@ import { appsRoute } from "./routes/apps"; ...@@ -11,6 +11,7 @@ import { appsRoute } from "./routes/apps";
import { themesRoute } from "./routes/themes"; import { themesRoute } from "./routes/themes";
import { promptsRoute } from "./routes/prompts"; import { promptsRoute } from "./routes/prompts";
import { mediaRoute } from "./routes/media"; import { mediaRoute } from "./routes/media";
import { connectorsRoute } from "./routes/connectors";
const routeTree = rootRoute.addChildren([ const routeTree = rootRoute.addChildren([
homeRoute, homeRoute,
...@@ -20,6 +21,7 @@ const routeTree = rootRoute.addChildren([ ...@@ -20,6 +21,7 @@ const routeTree = rootRoute.addChildren([
themesRoute, themesRoute,
promptsRoute, promptsRoute,
mediaRoute, mediaRoute,
connectorsRoute,
chatRoute, chatRoute,
appDetailsRoute, appDetailsRoute,
settingsRoute.addChildren([providerSettingsRoute]), settingsRoute.addChildren([providerSettingsRoute]),
......
import { createRoute } from "@tanstack/react-router";
import { z } from "zod";
import { rootRoute } from "./root";
import ConnectorsPage from "../pages/connectors";
export const connectorsRoute = createRoute({
getParentRoute: () => rootRoute,
path: "/connectors",
component: ConnectorsPage,
validateSearch: z.object({
id: z.number().optional(),
}),
});
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论