diff --git a/apps/web/package.json b/apps/web/package.json index f58db91a2..56020f20c 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -21,7 +21,6 @@ "@dnd-kit/modifiers": "^9.0.0", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", - "@tanstack/react-virtual": "^3.13.6", "@embedpdf/core": "^2.14.4", "@embedpdf/engines": "^2.14.4", "@embedpdf/models": "^2.14.4", @@ -50,6 +49,7 @@ "@lume/ui": "workspace:*", "@pierre/diffs": "1.3.0-rc.3", "@radix-ui/react-select": "^2.2.6", + "@tanstack/react-virtual": "^3.13.6", "@tiptap/core": "^3.22.3", "@tiptap/extension-mention": "^3.22.3", "@tiptap/extension-placeholder": "^3.22.3", @@ -67,6 +67,7 @@ "react": "^18.3.1", "react-dom": "^18.3.1", "shadcn": "^4.2.0", + "simple-icons": "^16.28.0", "sonner": "^2.0.0", "tailwind-merge": "^3.5.0", "throttle-debounce": "^5.0.2", @@ -80,6 +81,7 @@ "@types/throttle-debounce": "^5.0.2", "@vitejs/plugin-react": "^4.3.4", "tailwindcss": "^4.1.0", + "tar-stream": "^3.2.0", "typescript": "^5.7.2", "vite": "^6.3.0" } diff --git a/apps/web/scripts/generate-link-icons.mjs b/apps/web/scripts/generate-link-icons.mjs new file mode 100644 index 000000000..839fc5605 --- /dev/null +++ b/apps/web/scripts/generate-link-icons.mjs @@ -0,0 +1,70 @@ +// 构建期:读 OpenConnector v1.3.3 service 列表 + simple-icons,生成 service→{path,hex} 映射。 +import { writeFile } from "node:fs/promises"; +import { extract } from "tar-stream"; +import { Readable } from "node:stream"; +import { gunzipSync } from "node:zlib"; +import * as simpleIcons from "simple-icons"; + +const ARCHIVE_URL = + "https://codeload.github.com/oomol-lab/open-connector/tar.gz/refs/tags/v1.3.3"; + +// service(小写)→ simple-icons slug 手工修正(不一致时填) +const SLUG_OVERRIDES = { + active_campaign: "activecampaign", + google_calendar: "googlecalendar", + microsoft_teams: "microsoftteams", +}; + +function serviceToSlug(service) { + if (SLUG_OVERRIDES[service]) return SLUG_OVERRIDES[service]; + return service.replaceAll("_", "-"); +} + +const services = []; + +async function fetchServiceList() { + const res = await fetch(ARCHIVE_URL); + if (!res.ok || !res.body) throw new Error(`fetch ${res.status}`); + const buf = Buffer.from(await res.arrayBuffer()); + const gz = gunzipSync(buf); + await new Promise((resolve, reject) => { + const extractor = extract(); + extractor.on("entry", (header, stream, next) => { + // 仅关注 providers 目录下的 definition 路径,取目录名 + const m = header.name.match(/open-connector-[^/]+\/src\/providers\/([^/]+)\/definition\.ts$/); + if (m) { + const svc = m[1]; + if (!services.includes(svc)) services.push(svc); + } + stream.on("data", () => {}); + stream.on("end", next); + }); + extractor.on("finish", resolve); + extractor.on("error", reject); + Readable.from(gz).pipe(extractor); + }); + return services.sort(); +} + +const map = {}; +for (const service of await fetchServiceList()) { + const slug = serviceToSlug(service); + // simple-icons 导出形如 siSlack(驼峰) + const exportName = + "si" + slug.split("-").map((p) => p.charAt(0).toUpperCase() + p.slice(1)).join(""); + const icon = simpleIcons[exportName]; + if (icon && icon.path) { + map[service.toLowerCase()] = { path: icon.path, hex: icon.hex }; + } +} + +const out = `// 自动生成(scripts/generate-link-icons.mjs)。勿手改。OpenConnector v1.3.3 × simple-icons。 +export const LINK_ICONS: Record = ${JSON.stringify( + map, +)};\n`; +await writeFile( + new URL("../src/lib/generated/link-icons.ts", import.meta.url), + out, + "utf8", +); +console.log(`generated ${Object.keys(map).length} link icons`); diff --git a/apps/web/src/components/app-shell/LumeSidebar.tsx b/apps/web/src/components/app-shell/LumeSidebar.tsx index 0e5d3e41f..2505eed2d 100644 --- a/apps/web/src/components/app-shell/LumeSidebar.tsx +++ b/apps/web/src/components/app-shell/LumeSidebar.tsx @@ -14,7 +14,7 @@ import { Bot, ListTodo, Sparkles, - PlugZap, + Plug, } from 'lucide-react' import { cn } from '@/lib/utils' import type { @@ -365,7 +365,7 @@ function renderIcon(icon: string, size: number) { case 'sparkles': return case 'plug': - return + return case 'folder': return case 'trash': diff --git a/apps/web/src/components/link/LinkAccountsList.tsx b/apps/web/src/components/link/LinkAccountsList.tsx new file mode 100644 index 000000000..b5bd82089 --- /dev/null +++ b/apps/web/src/components/link/LinkAccountsList.tsx @@ -0,0 +1,38 @@ +import type { LinkConnectionSummary } from "@lume/shared"; +import { authLabel } from "@/lib/link-auth"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; + +interface LinkAccountsListProps { + connections: LinkConnectionSummary[]; + onReconnect: (connectionName: string) => void; + onRequestDelete: (connectionName: string) => void; +} + +export function LinkAccountsList({ connections, onReconnect, onRequestDelete }: LinkAccountsListProps) { + if (connections.length === 0) return null; + return ( +
+
已连接账户({connections.length})
+
+ {connections.map((conn) => ( +
+
+
+ {conn.connectionName} + {conn.default && 默认} +
+
+ {conn.profile?.displayName || conn.profile?.accountId || authLabel(conn.authType)} +
+
+
+ + +
+
+ ))} +
+
+ ); +} diff --git a/apps/web/src/components/link/LinkCatalog.tsx b/apps/web/src/components/link/LinkCatalog.tsx new file mode 100644 index 000000000..75fa901c8 --- /dev/null +++ b/apps/web/src/components/link/LinkCatalog.tsx @@ -0,0 +1,156 @@ +import { useLayoutEffect, useMemo, useRef, useState } from "react"; +import { useVirtualizer } from "@tanstack/react-virtual"; +import { computeColumnCount, PROVIDER_GRID, rowCount } from "@/lib/provider-grid"; +import { linkServicePriority } from "@/lib/provider-ranking"; +import type { LinkConnectionSummary, LinkProviderSummary } from "@lume/shared"; +import { LinkToolbar, type FilterCounts, type LinkFilter } from "./LinkToolbar"; +import { ProviderCard } from "./ProviderCard"; + +interface LinkCatalogProps { + providers: LinkProviderSummary[]; + connections: LinkConnectionSummary[]; + query: string; + onQueryChange: (v: string) => void; + filter: LinkFilter; + onFilterChange: (v: LinkFilter) => void; + selectedService: string | null; + onOpen: (service: string) => void; +} + +function providerStatus(provider: LinkProviderSummary, configuredServices: Set, authTypes: string[]) { + const configured = configuredServices.has(provider.service); + const noSetup = authTypes.includes("no_auth"); + return { configured, noSetup, needsAttention: false }; +} + +export function LinkCatalog({ + providers, connections, query, onQueryChange, filter, onFilterChange, selectedService, onOpen, +}: LinkCatalogProps) { + const configuredServices = useMemo( + () => new Set(connections.filter((c) => c.configured).map((c) => c.service)), + [connections], + ); + + const annotated = useMemo( + () => + providers.map((p) => ({ + provider: p, + status: providerStatus(p, configuredServices, p.authTypes ?? []), + })), + [providers, configuredServices], + ); + + const counts: FilterCounts = useMemo( + () => ({ + all: annotated.length, + connected: annotated.filter((a) => a.status.configured).length, + noSetup: annotated.filter((a) => a.status.noSetup).length, + needsAttention: annotated.filter((a) => a.status.needsAttention).length, + }), + [annotated], + ); + + const visible = useMemo(() => { + return annotated + .filter(({ provider, status }) => { + const matchesQuery = + !query || + `${provider.displayName} ${provider.service} ${provider.description ?? ""}` + .toLowerCase() + .includes(query.toLowerCase()); + const matchesFilter = + filter === "all" || + (filter === "connected" && status.configured) || + (filter === "noSetup" && status.noSetup) || + (filter === "needsAttention" && status.needsAttention); + return matchesQuery && matchesFilter; + }) + .sort((a, b) => { + const configuredRank = Number(b.status.configured) - Number(a.status.configured); + if (configuredRank !== 0) return configuredRank; + const priority = linkServicePriority(a.provider.service) - linkServicePriority(b.provider.service); + if (priority !== 0) return priority; + return a.provider.displayName.localeCompare(b.provider.displayName); + }); + }, [annotated, query, filter]); + + const scrollRef = useRef(null); + const gridRef = useRef(null); + const [containerWidth, setContainerWidth] = useState(0); + + useLayoutEffect(() => { + const node = gridRef.current; + if (!node || typeof ResizeObserver === "undefined") return; + setContainerWidth(node.getBoundingClientRect().width); + const observer = new ResizeObserver((entries) => { + setContainerWidth(entries[0]?.contentRect.width ?? 0); + }); + observer.observe(node); + return () => observer.disconnect(); + }, []); + + const columns = computeColumnCount(containerWidth); + const rows = rowCount(visible.length, columns); + const rowVirtualizer = useVirtualizer({ + count: rows, + getScrollElement: () => scrollRef.current, + estimateSize: () => PROVIDER_GRID.cardHeight + PROVIDER_GRID.gap, + overscan: PROVIDER_GRID.overscanRows, + }); + + return ( +
+
+ +
+
+ {visible.length === 0 ? ( +
+
无匹配连接器
+
尝试更换关键词或清除筛选。
+
+ ) : null} + {/* + gridRef 必须恒挂载:useLayoutEffect deps=[] 只在首挂时 attach ResizeObserver。 + 若放进 visible.length>0 分支,empty↔non-empty 切换会卸载 gridRef → cleanup disconnect + → 重挂时 effect 不重跑 → containerWidth 冻结 0、列数降级 1、窗口缩放失效。 + 空态下 rows=0 → height=0、无 virtual item,div 占位零成本。 + */} +
+ {rowVirtualizer.getVirtualItems().map((vRow) => ( +
+ {Array.from({ length: columns }).map((_, col) => { + const entry = visible[vRow.index * columns + col]; + if (!entry) return null; + return ( + + ); + })} +
+ ))} +
+
+
+ ); +} diff --git a/apps/web/src/components/link/LinkConnectDialog.tsx b/apps/web/src/components/link/LinkConnectDialog.tsx new file mode 100644 index 000000000..98f6e7dcc --- /dev/null +++ b/apps/web/src/components/link/LinkConnectDialog.tsx @@ -0,0 +1,317 @@ +import { useEffect, useState } from "react"; +import { toast } from "sonner"; +import type { + LinkConnectionSummary, LinkCredentialField, LinkOAuthConfigSummary, LinkOAuthSession, LinkProviderDetail, +} from "@lume/shared"; +import { + cancelLinkOAuth, getLinkAction, getLinkOAuthStatus, listLinkOAuthSessions, openExternal, + saveLinkOAuthConfig, startLinkOAuth, upsertLinkConnection, +} from "@/lib/desktop-api"; +import { authLabel, credentialFields } from "@/lib/link-auth"; +import { previewValue, TOOL_OUTPUT_PREVIEW_LIMIT } from "@/lib/tool-output-preview"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Select, SelectContent, SelectItem, SelectTrigger, SelectValue, +} from "@/components/ui/select"; +import { ProviderIcon } from "./ProviderIcon"; +import { SecretField } from "./secret-field"; + +export function LinkConnectDialog({ + provider, + initialConnectionName, + oauthConfig, + connections, + onClose, + onSaved, + onReconnect, + onRequestDelete, +}: { + provider: LinkProviderDetail | null; + initialConnectionName: string; + oauthConfig?: LinkOAuthConfigSummary; + connections: LinkConnectionSummary[]; + onClose: () => void; + onSaved: () => Promise; + onReconnect: (connectionName: string) => void; + onRequestDelete: (connectionName: string) => void; +}) { + const [connectionName, setConnectionName] = useState("default"); + const [authIndex, setAuthIndex] = useState(0); + const [values, setValues] = useState>({}); + const [oauth, setOAuth] = useState(null); + const [actionDetail, setActionDetail] = useState(null); + const [busy, setBusy] = useState(false); + useEffect(() => { + setValues(oauthConfig?.clientId ? { clientId: oauthConfig.clientId } : {}); + setConnectionName(initialConnectionName); + setAuthIndex(0); + setOAuth(null); + setActionDetail(null); + if (provider?.service) { + void listLinkOAuthSessions() + .then((sessions) => setOAuth(sessions.find((session) => session.service === provider.service && session.status === "pending") ?? null)) + .catch(() => undefined); + } + }, [provider?.service, initialConnectionName, oauthConfig?.clientId]); + useEffect(() => { + if (!oauth || oauth.status !== "pending") return; + const timer = setInterval( + () => + void getLinkOAuthStatus(oauth.state) + .then((next) => { + setOAuth(next); + if (next.status === "authorized") void onSaved(); + }) + .catch((error) => + setOAuth({ + ...oauth, + status: "error", + error: error instanceof Error ? error.message : "授权失败", + }), + ), + 1500, + ); + return () => clearInterval(timer); + }, [oauth, onSaved]); + if (!provider) return null; + const auth = provider.auth?.[authIndex] ?? { type: "no_auth" }; + const fields = credentialFields(auth); + const isOAuth = auth.type === "oauth2"; + const oauthFields = credentialFields({ + fields: (oauthConfig?.auth.clientConfigFields ?? + auth.clientConfigFields) as unknown, + }) as Array< + LinkCredentialField & { + location?: "extra" | "secretExtra"; + defaultValue?: string; + } + >; + const save = async () => { + setBusy(true); + try { + if (isOAuth) { + const extra = Object.fromEntries( + oauthFields + .filter((field) => field.location !== "secretExtra") + .map((field) => [ + field.key, + values[field.key] ?? field.defaultValue ?? "", + ]), + ); + const secretExtra = Object.fromEntries( + oauthFields + .filter((field) => field.location === "secretExtra") + .map((field) => [field.key, values[field.key] ?? ""]), + ); + if (!oauthConfig?.configured || values.clientSecret || auth.tokenEndpointAuthMethod === "none") + await saveLinkOAuthConfig( + provider.service, + values.clientId || "", + values.clientSecret || "", + extra, + secretExtra, + ); + const session = await startLinkOAuth(provider.service, connectionName); + setOAuth(session); + await openExternal(session.authorizationUrl || ""); + } else { + await upsertLinkConnection({ + service: provider.service, + connectionName, + authType: String(auth.type), + credentials: Object.fromEntries( + fields.map((field) => [field.key, values[field.key] ?? ""]), + ), + }); + setValues({}); + await onSaved(); + } + } catch (error) { + toast.error(error instanceof Error ? error.message : "保存连接失败"); + } finally { + setBusy(false); + } + }; + return ( + !open && onClose()}> + + + + + {provider.displayName} + + + {provider.description || provider.service} + + +
+ + setConnectionName(event.target.value)} + /> + + + {isOAuth ? ( + <> + + setValues((prev) => ({ ...prev, clientId: value })) + } + secret={false} + /> + + setValues((prev) => ({ ...prev, clientSecret: value })) + } + secret + /> + {oauthFields.map((field) => ( + setValues((prev) => ({ ...prev, [field.key]: value }))} + secret={field.secret || field.location === "secretExtra"} + textarea={field.inputType === "textarea" || field.inputType === "json"} + /> + ))} + {oauthConfig?.expectedRedirectUri && ( +
+
OAuth 回调地址
+
{oauthConfig.expectedRedirectUri}
+
+ )} + + ) : ( + fields.map((field) => ( + + setValues((prev) => ({ ...prev, [field.key]: value })) + } + secret={field.secret} + textarea={ + field.inputType === "textarea" || field.inputType === "json" + } + /> + )) + )} + {oauth && ( +
+ 授权状态:{oauth.status} + {oauth.error ? ` · ${oauth.error}` : ""} +
+ )} + {connections.length > 0 && ( +
+
已连接账户({connections.length})
+
+ {connections.map((conn) => ( +
+
+
+ {conn.connectionName} + {conn.default && 默认} +
+
+ {conn.profile?.displayName || conn.profile?.accountId || authLabel(conn.authType)} +
+
+
+ + +
+
+ ))} +
+
+ )} + {provider.actions && provider.actions.length > 0 && ( +
+
Actions({provider.actions.length})
+
+ {provider.actions.map((action) => ( + + ))} +
+ {actionDetail != null && ( + + )} +
+ )} +
+ + {oauth?.status === "pending" && ( + + )} + + +
+
+ ); +} + +function DetailPreview({ value, bodyClass }: { value: unknown; bodyClass: string }) { + const preview = previewValue(value); + return ( +
+
+        {preview.text}
+      
+ {preview.truncated && ( +
+ 结果过长,已截断到 {TOOL_OUTPUT_PREVIEW_LIMIT.toLocaleString()} 字符。 +
+ )} +
+ ); +} diff --git a/apps/web/src/components/link/LinkDetailPane.tsx b/apps/web/src/components/link/LinkDetailPane.tsx new file mode 100644 index 000000000..bfbb734d1 --- /dev/null +++ b/apps/web/src/components/link/LinkDetailPane.tsx @@ -0,0 +1,61 @@ +import type { LinkConnectionSummary, LinkOAuthConfigSummary, LinkProviderDetail } from "@lume/shared"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { X } from "lucide-react"; +import { authLabel } from "@/lib/link-auth"; +import { ProviderIcon } from "./ProviderIcon"; +import { LinkAccountsList } from "./LinkAccountsList"; + +interface LinkDetailPaneProps { + provider: LinkProviderDetail; + connections: LinkConnectionSummary[]; + oauthConfig?: LinkOAuthConfigSummary; + onConnect: (service: string) => void; + onClose: () => void; + onReconnect: (connectionName: string) => void; + onRequestDelete: (connectionName: string) => void; +} + +export function LinkDetailPane({ provider, connections, onConnect, onClose, onReconnect, onRequestDelete }: LinkDetailPaneProps) { + const configured = connections.some((c) => c.configured); + const authTypes = provider.authTypes?.length ? provider.authTypes : provider.auth?.map((a) => String(a.type)) ?? []; + return ( +
+ {/* 头部 */} +
+
+ +
+

{provider.displayName}

+

{provider.description || provider.service}

+
+
+ +
+ {/* 连接操作 */} +
+
+ {configured ? "已连接" : "未连接"} + +
+ {authTypes.length > 0 && ( +
+ {authTypes.map((t) => {authLabel(t)})} +
+ )} + + {/* 详情 dl */} +
+
服务
+
{provider.service}
+ {provider.categories?.length ? ( + <> +
分类
+
{provider.categories.join("、")}
+ + ) : null} +
+
+
+ ); +} diff --git a/apps/web/src/components/link/LinkToolbar.tsx b/apps/web/src/components/link/LinkToolbar.tsx new file mode 100644 index 000000000..8c2c458cc --- /dev/null +++ b/apps/web/src/components/link/LinkToolbar.tsx @@ -0,0 +1,46 @@ +import { SearchField } from "@/components/ui/search-field"; +import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; + +export type LinkFilter = "all" | "connected" | "noSetup" | "needsAttention"; + +export interface FilterCounts { + all: number; + connected: number; + noSetup: number; + needsAttention: number; +} + +interface LinkToolbarProps { + query: string; + onQueryChange: (value: string) => void; + filter: LinkFilter; + onFilterChange: (value: LinkFilter) => void; + counts: FilterCounts; +} + +export function LinkToolbar({ query, onQueryChange, filter, onFilterChange, counts }: LinkToolbarProps) { + const items: Array<{ value: LinkFilter; label: string; count: number }> = [ + { value: "all", label: "全部", count: counts.all }, + { value: "connected", label: "已连接", count: counts.connected }, + { value: "noSetup", label: "免配置", count: counts.noSetup }, + { value: "needsAttention", label: "需处理", count: counts.needsAttention }, + ]; + return ( +
+ onQueryChange(e.target.value)} + /> + onFilterChange(v as LinkFilter)}> + {items.map((item) => ( + + {item.label} + {item.count} + + ))} + +
+ ); +} diff --git a/apps/web/src/components/link/LinkView.tsx b/apps/web/src/components/link/LinkView.tsx index a9efc3f29..0bce29953 100644 --- a/apps/web/src/components/link/LinkView.tsx +++ b/apps/web/src/components/link/LinkView.tsx @@ -1,121 +1,61 @@ -import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type ReactNode } from "react"; -import { useVirtualizer } from "@tanstack/react-virtual"; -import { computeColumnCount, PROVIDER_GRID, rowCount } from "@/lib/provider-grid"; -import { formatDurationLabel } from "@/lib/format-duration"; -import { formatDateTime } from "@/lib/datetime"; -import { previewValue, TOOL_OUTPUT_PREVIEW_LIMIT } from "@/lib/tool-output-preview"; -import { linkServicePriority } from "@/lib/provider-ranking"; -import { CheckCircle2, XCircle } from "lucide-react"; -import { ProviderCard } from "./ProviderCard"; -import { ProviderIcon } from "./ProviderIcon"; +import { useCallback, useEffect, useState } from "react"; +import { toast } from "sonner"; +import { useAtom, useSetAtom } from "jotai"; import type { - LinkConnectionSummary, - LinkCredentialField, - LinkOAuthConfigSummary, - LinkOAuthSession, - LinkProviderDetail, - LinkProviderSummary, - LinkRunDetail, - LinkRunSummary, + LinkConnectionSummary, LinkOAuthConfigSummary, LinkProviderDetail, LinkProviderSummary, } from "@lume/shared"; -import { toast } from "sonner"; -import { useAtom } from "jotai"; -import { linkProviderTargetAtom } from "@/atoms"; -import { Badge } from "@/components/ui/badge"; -import { Button } from "@/components/ui/button"; import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from "@/components/ui/dialog"; -import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; + activeTabIdAtom, + linkProviderTargetAtom, + settingsInitialTabAtom, + tabsAtom, +} from "@/atoms"; import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; -import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; -import { Textarea } from "@/components/ui/textarea"; -import { ConfirmDialog } from "@/components/ui/confirm-dialog"; -import { - deleteLinkConnection, - getLinkProvider, - getLinkRun, - getLinkRuntimeState, - listLinkConnections, - listLinkOAuthConfigs, - listLinkProviders, - listLinkRuns, - openExternal, - saveLinkOAuthConfig, - startLinkOAuth, - getLinkOAuthStatus, - cancelLinkOAuth, - upsertLinkConnection, - onLinkDataChanged, - onLinkRuntimeState, - listLinkOAuthSessions, - getLinkAction, + deleteLinkConnection, getLinkProvider, getLinkRuntimeState, listLinkConnections, + listLinkOAuthConfigs, listLinkProviders, onLinkDataChanged, onLinkRuntimeState, } from "@/lib/desktop-api"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { ConfirmDialog } from "@/components/ui/confirm-dialog"; +import { LinkCatalog } from "./LinkCatalog"; +import { LinkDetailPane } from "./LinkDetailPane"; +import { LinkConnectDialog } from "./LinkConnectDialog"; +import type { LinkFilter } from "./LinkToolbar"; -// catalog 滚动区可视高度偏移:页头(h1+副标题)+Tabs 头+搜索/筛选栏+垂直间距之和 -const CATALOG_SCROLL_OFFSET = 220; +const SETTINGS_TAB_ID = "__settings__"; +const LINK_RUNTIME_SETTINGS_TAB = "link-runtime"; export function LinkView() { const [providers, setProviders] = useState([]); const [connections, setConnections] = useState([]); - const [runs, setRuns] = useState([]); - const [runCursor, setRunCursor] = useState(); - const [runService, setRunService] = useState(""); - const [runOutcome, setRunOutcome] = useState("all"); - const [runBusy, setRunBusy] = useState(false); const [query, setQuery] = useState(""); - const [category, setCategory] = useState("all"); - const [connectionState, setConnectionState] = useState("all"); + const [filter, setFilter] = useState("all"); const [selected, setSelected] = useState(null); + // selected 控制右侧详情面板;connectOpen 独立控制凭据/OAuth 弹窗(点"连接"才开,与面板解耦) + const [connectOpen, setConnectOpen] = useState(false); const [selectedConnectionName, setSelectedConnectionName] = useState("default"); - const [runDetail, setRunDetail] = useState(null); const [online, setOnline] = useState(false); - const [oauthConfigs, setOAuthConfigs] = useState( - [], - ); + const [oauthConfigs, setOAuthConfigs] = useState([]); const [providerTarget, setProviderTarget] = useAtom(linkProviderTargetAtom); const [deleteTarget, setDeleteTarget] = useState(null); + const setTabs = useSetAtom(tabsAtom); + const setActiveTabId = useSetAtom(activeTabIdAtom); + const setSettingsInitialTab = useSetAtom(settingsInitialTabAtom); const refresh = useCallback(async () => { const runtime = await getLinkRuntimeState(); setOnline(runtime.phase === "online"); if (runtime.phase !== "online") { - setProviders([]); - setConnections([]); - setRuns([]); - setOAuthConfigs([]); - return; + setProviders([]); setConnections([]); setOAuthConfigs([]); return; } - const [nextProviders, nextConnections, nextRuns, nextOAuthConfigs] = - await Promise.all([ - listLinkProviders(), - listLinkConnections(), - listLinkRuns({ - limit: 50, - ...(runService.trim() ? { service: runService.trim() } : {}), - ...(runOutcome === "success" ? { ok: true } : {}), - ...(runOutcome === "failure" ? { ok: false } : {}), - }), - listLinkOAuthConfigs(), - ]); + const [nextProviders, nextConnections, nextOAuthConfigs] = await Promise.all([ + listLinkProviders(), listLinkConnections(), listLinkOAuthConfigs(), + ]); setProviders(nextProviders); setConnections(nextConnections); - setRuns(nextRuns.items); - setRunCursor(nextRuns.nextCursor); setOAuthConfigs(nextOAuthConfigs); - }, [runOutcome, runService]); + }, []); + useEffect(() => { void refresh().catch(() => toast.error("无法读取连接器数据")); let offRuntime: (() => void) | undefined; @@ -124,325 +64,101 @@ export function LinkView() { void onLinkDataChanged(() => void refresh()).then((off) => { offData = off; }); return () => { offRuntime?.(); offData?.(); }; }, [refresh]); + useEffect(() => { if (!online || !providerTarget) return; void getLinkProvider(providerTarget) - .then((provider) => { - setSelectedConnectionName("default"); - setSelected(provider); - setProviderTarget(null); - }) + .then((provider) => { setSelectedConnectionName("default"); setSelected(provider); setProviderTarget(null); }) .catch(() => toast.error("无法打开连接器详情")); }, [online, providerTarget, setProviderTarget]); - const categories = useMemo( - () => - [ - ...new Set(providers.flatMap((provider) => provider.categories ?? [])), - ].sort(), - [providers], - ); - const configuredServices = useMemo( - () => new Set(connections.filter((connection) => connection.configured).map((connection) => connection.service)), - [connections], - ); - const visibleProviders = providers.filter((provider) => { - const configured = configuredServices.has(provider.service); - return ( - (!query || - `${provider.displayName} ${provider.service} ${provider.description ?? ""}` - .toLowerCase() - .includes(query.toLowerCase())) && - (category === "all" || provider.categories?.includes(category)) && - (connectionState === "all" || - (connectionState === "configured") === configured) - ); - }).sort((a, b) => { - // 已连接 → 推荐表 → 字母序(参考 wanta compareConnectionProvidersByRecommendation) - const configuredRank = Number(configuredServices.has(b.service)) - Number(configuredServices.has(a.service)); - if (configuredRank !== 0) return configuredRank; - const priority = linkServicePriority(a.service) - linkServicePriority(b.service); - if (priority !== 0) return priority; - return a.displayName.localeCompare(b.displayName); - }); - - const scrollRef = useRef(null); - const gridRef = useRef(null); - const [containerWidth, setContainerWidth] = useState(0); - // 测量网格容器宽度以计算响应式列数(对齐 BrowserShell/FilesRightPanelWorkspace 的裸 ResizeObserver 模式) - // 用 useLayoutEffect + 同步首测,避免 useState(0) 首次渲染单列 → 实测宽度多列的闪烁 - useLayoutEffect(() => { - const node = gridRef.current; - if (!node || typeof ResizeObserver === "undefined") return; - setContainerWidth(node.getBoundingClientRect().width); - const observer = new ResizeObserver((entries) => { - setContainerWidth(entries[0]?.contentRect.width ?? 0); - }); - observer.observe(node); - return () => observer.disconnect(); - }, []); - - const columns = computeColumnCount(containerWidth); - const rows = rowCount(visibleProviders.length, columns); - const rowVirtualizer = useVirtualizer({ - count: rows, - getScrollElement: () => scrollRef.current, - estimateSize: () => PROVIDER_GRID.cardHeight + PROVIDER_GRID.gap, - overscan: PROVIDER_GRID.overscanRows, - }); const openProvider = (service: string) => { void getLinkProvider(service) - .then((detail) => { - setSelectedConnectionName("default"); - setSelected(detail); - }) + .then((detail) => { setSelectedConnectionName("default"); setSelected(detail); }) .catch(() => toast.error("无法打开连接器详情")); }; + // 对齐 link-result.tsx/BrowserShell 的标准入口:atoms 驱动 tab 切换,settingsInitialTab 定位到 link-runtime + const openLinkRuntimeSettings = () => { + setSettingsInitialTab(LINK_RUNTIME_SETTINGS_TAB); + setTabs((tabs) => + tabs.some((tab) => tab.id === SETTINGS_TAB_ID) + ? tabs + : [...tabs, { id: SETTINGS_TAB_ID, type: "settings", title: "设置" }], + ); + setActiveTabId(SETTINGS_TAB_ID); + }; + + if (!online) { + return ( +
+ 未启用 +

连接器

+

+ 连接器需要本机 OpenConnector Link 运行时。请在「设置 → Link 运行时」中启用。 +

+ +
+ ); + } + return ( -
-
+
+

连接器

-

- 由本机 OpenConnector Link 提供,连接凭据不会进入渲染器。 -

+

由本机 OpenConnector Link 提供,连接凭据不会进入渲染器。

- - {online ? "本地运行中" : "未启用"} - + {online ? "本地运行中" : "未启用"}
- {!online ? ( -
- 请在「设置 → Link 运行时」中启用本地运行时。 +
+
+
- ) : ( - - - 应用目录 - 我的连接 - 运行记录 - - -
- setQuery(event.target.value)} - /> - [item, item]), - ]} - /> - -
-
-
- {rowVirtualizer.getVirtualItems().map((vRow) => ( -
- {Array.from({ length: columns }).map((_, col) => { - const provider = visibleProviders[vRow.index * columns + col]; - if (!provider) return null; - return ( - c.service === provider.service && c.configured)} - onOpen={openProvider} - /> - ); - })} -
- ))} -
-
-
- - {connections.length ? ( - connections.map((connection) => ( -
-
-
- {connection.profile?.displayName || - connection.connectionName} -
-
- {connection.service} · {connection.authType} - {connection.default ? " · 默认" : ""} -
-
-
- - -
-
- )) - ) : ( - 还没有连接。 - )} -
- -
- setRunService(event.target.value)} - /> - -
- {runs.length ? ( - <> - {runs.map((run) => ( - - ))} - {runCursor && ( - - )} - - ) : ( - 暂无运行记录。 - )} -
-
- )} - item.service === selected?.service, - )} - connections={connections.filter( - (item) => item.service === selected?.service, + {selected && ( +
+ c.service === selected.service)} + oauthConfig={oauthConfigs.find((o) => o.service === selected.service)} + onConnect={() => setConnectOpen(true)} + onClose={() => setSelected(null)} + onReconnect={(name) => { setSelectedConnectionName(name); setConnectOpen(true); }} + onRequestDelete={(name) => { + const target = connections.find((c) => c.service === selected.service && c.connectionName === name); + if (target) setDeleteTarget(target); + }} + /> +
)} - onClose={() => setSelected(null)} - onSaved={async () => { - await refresh(); - setSelected(null); - }} - onReconnect={(name) => setSelectedConnectionName(name)} - onRequestDelete={(name) => { - // deleteTarget 形状为完整 LinkConnectionSummary(ConfirmDialog 消费其 service/connectionName/profile) - const target = connections.find( - (item) => - item.service === selected?.service && - item.connectionName === name, - ); - if (target) setDeleteTarget(target); - }} - /> - !open && setRunDetail(null)} - > - - - 运行详情 - {runDetail?.id} - - - - +
+ {selected && connectOpen && ( + o.service === selected.service)} + connections={connections.filter((c) => c.service === selected.service)} + onClose={() => setConnectOpen(false)} + onSaved={async () => { await refresh(); setConnectOpen(false); }} + onReconnect={(name) => setSelectedConnectionName(name)} + onRequestDelete={(name) => { + const target = connections.find((c) => c.service === selected.service && c.connectionName === name); + if (target) setDeleteTarget(target); + }} + /> + )} !open && setDeleteTarget(null)} @@ -461,391 +177,3 @@ export function LinkView() {
); } - -function ProviderDialog({ - provider, - initialConnectionName, - oauthConfig, - connections, - onClose, - onSaved, - onReconnect, - onRequestDelete, -}: { - provider: LinkProviderDetail | null; - initialConnectionName: string; - oauthConfig?: LinkOAuthConfigSummary; - connections: LinkConnectionSummary[]; - onClose: () => void; - onSaved: () => Promise; - onReconnect: (connectionName: string) => void; - onRequestDelete: (connectionName: string) => void; -}) { - const [connectionName, setConnectionName] = useState("default"); - const [authIndex, setAuthIndex] = useState(0); - const [values, setValues] = useState>({}); - const [oauth, setOAuth] = useState(null); - const [actionDetail, setActionDetail] = useState(null); - const [busy, setBusy] = useState(false); - useEffect(() => { - setValues(oauthConfig?.clientId ? { clientId: oauthConfig.clientId } : {}); - setConnectionName(initialConnectionName); - setAuthIndex(0); - setOAuth(null); - setActionDetail(null); - if (provider?.service) { - void listLinkOAuthSessions() - .then((sessions) => setOAuth(sessions.find((session) => session.service === provider.service && session.status === "pending") ?? null)) - .catch(() => undefined); - } - }, [provider?.service, initialConnectionName, oauthConfig?.clientId]); - useEffect(() => { - if (!oauth || oauth.status !== "pending") return; - const timer = setInterval( - () => - void getLinkOAuthStatus(oauth.state) - .then((next) => { - setOAuth(next); - if (next.status === "authorized") void onSaved(); - }) - .catch((error) => - setOAuth({ - ...oauth, - status: "error", - error: error instanceof Error ? error.message : "授权失败", - }), - ), - 1500, - ); - return () => clearInterval(timer); - }, [oauth, onSaved]); - if (!provider) return null; - const auth = provider.auth?.[authIndex] ?? { type: "no_auth" }; - const fields = credentialFields(auth); - const isOAuth = auth.type === "oauth2"; - const oauthFields = credentialFields({ - fields: (oauthConfig?.auth.clientConfigFields ?? - auth.clientConfigFields) as unknown, - }) as Array< - LinkCredentialField & { - location?: "extra" | "secretExtra"; - defaultValue?: string; - } - >; - const save = async () => { - setBusy(true); - try { - if (isOAuth) { - const extra = Object.fromEntries( - oauthFields - .filter((field) => field.location !== "secretExtra") - .map((field) => [ - field.key, - values[field.key] ?? field.defaultValue ?? "", - ]), - ); - const secretExtra = Object.fromEntries( - oauthFields - .filter((field) => field.location === "secretExtra") - .map((field) => [field.key, values[field.key] ?? ""]), - ); - if (!oauthConfig?.configured || values.clientSecret || auth.tokenEndpointAuthMethod === "none") - await saveLinkOAuthConfig( - provider.service, - values.clientId || "", - values.clientSecret || "", - extra, - secretExtra, - ); - const session = await startLinkOAuth(provider.service, connectionName); - setOAuth(session); - await openExternal(session.authorizationUrl || ""); - } else { - await upsertLinkConnection({ - service: provider.service, - connectionName, - authType: String(auth.type), - credentials: Object.fromEntries( - fields.map((field) => [field.key, values[field.key] ?? ""]), - ), - }); - setValues({}); - await onSaved(); - } - } catch (error) { - toast.error(error instanceof Error ? error.message : "保存连接失败"); - } finally { - setBusy(false); - } - }; - return ( - !open && onClose()}> - - - - - {provider.displayName} - - - {provider.description || provider.service} - - -
- - setConnectionName(event.target.value)} - /> - - - {isOAuth ? ( - <> - - setValues((prev) => ({ ...prev, clientId: value })) - } - secret={false} - /> - - setValues((prev) => ({ ...prev, clientSecret: value })) - } - secret - /> - {oauthFields.map((field) => ( - setValues((prev) => ({ ...prev, [field.key]: value }))} - secret={field.secret || field.location === "secretExtra"} - textarea={field.inputType === "textarea" || field.inputType === "json"} - /> - ))} - {oauthConfig?.expectedRedirectUri && ( -
-
OAuth 回调地址
-
{oauthConfig.expectedRedirectUri}
-
- )} - - ) : ( - fields.map((field) => ( - - setValues((prev) => ({ ...prev, [field.key]: value })) - } - secret={field.secret} - textarea={ - field.inputType === "textarea" || field.inputType === "json" - } - /> - )) - )} - {oauth && ( -
- 授权状态:{oauth.status} - {oauth.error ? ` · ${oauth.error}` : ""} -
- )} - {connections.length > 0 && ( -
-
已连接账户({connections.length})
-
- {connections.map((conn) => ( -
-
-
- {conn.connectionName} - {conn.default && 默认} -
-
- {conn.profile?.displayName || conn.profile?.accountId || authLabel(conn.authType)} -
-
-
- - -
-
- ))} -
-
- )} - {provider.actions && provider.actions.length > 0 && ( -
-
Actions({provider.actions.length})
-
- {provider.actions.map((action) => ( - - ))} -
- {actionDetail != null && ( - - )} -
- )} -
- - {oauth?.status === "pending" && ( - - )} - - -
-
- ); -} -function credentialFields( - auth: Record, -): LinkCredentialField[] { - const configured = auth.type === "api_key" - ? [ - { - key: "apiKey", - label: typeof auth.label === "string" ? auth.label : "API Key", - inputType: "password" as const, - required: true, - secret: true, - ...(typeof auth.placeholder === "string" ? { placeholder: auth.placeholder } : {}), - ...(typeof auth.description === "string" ? { description: auth.description } : {}), - }, - ...(Array.isArray(auth.extraFields) ? auth.extraFields : []), - ] - : auth.fields; - return Array.isArray(configured) - ? configured.filter((item): item is LinkCredentialField => - Boolean( - item && - typeof item === "object" && - typeof (item as LinkCredentialField).key === "string", - ), - ) - : []; -} -function authLabel(type: string): string { - return ({ no_auth: "无需认证", api_key: "API Key", custom_credential: "自定义凭据", oauth2: "OAuth 2.0" } as Record)[type] ?? type; -} -function SecretField({ - label, - value, - onChange, - secret, - textarea, -}: { - label: string; - value: string; - onChange: (value: string) => void; - secret: boolean; - textarea?: boolean; -}) { - return ( -
- - {textarea ? ( -