deer-flow/frontend/src/components/workspace/plugin-surfaces.tsx
Wenchao An e2f19d8335
feat(plugins): full-stack plugin APIs and bookmarks (#5647)
* feat(plugins): add full-stack contributions and bookmarks example

* ci(plugins): provision bookmark gateway for browser tests

* fix(plugins): authenticate module downloads through configured backend

* fix(plugins): isolate contributions and localize extension UI

* fix(plugins): preserve bookmark agent routing and contain async callbacks

* fix(plugins): pin durable batch workers to app extension snapshots
2026-09-22 11:18:57 +08:00

107 lines
2.7 KiB
TypeScript

"use client";
import { useRouter } from "next/navigation";
import { useEffect, useRef, useState } from "react";
import { useAuth } from "@/core/auth/AuthProvider";
import type { PluginSurface, SurfaceSlot } from "@/core/extensions/contracts";
import {
useFrontendExtensions,
useFrontendServices,
} from "@/core/extensions/hooks";
import {
activeFrontendExtensions,
type LoadedContribution,
} from "@/core/extensions/registry";
import {
bindFrontendServices,
openConversation,
} from "@/core/extensions/services";
import { mountSurface } from "@/core/extensions/surfaces";
import { useI18n } from "@/core/i18n/hooks";
function Surface({
entry,
surface,
threadId,
}: {
entry: LoadedContribution;
surface: PluginSurface;
threadId?: string;
}) {
const ref = useRef<HTMLDivElement>(null);
const router = useRouter();
const { locale, t } = useI18n();
const { user } = useAuth();
const services = useFrontendServices();
const currentServices = useRef(services);
currentServices.current = services;
const [failed, setFailed] = useState(false);
useEffect(() => {
if (!ref.current) return;
const abort = new AbortController();
const cleanup = mountSurface(
ref.current,
surface,
{
namespace: entry.namespace,
locale,
settings: entry.settings,
threadId,
openConversation: (id, signal) =>
openConversation(id, (path) => router.push(path), signal),
callBackend: bindFrontendServices(
currentServices.current,
entry,
abort.signal,
).callBackend,
},
() => setFailed(true),
);
return () => {
abort.abort();
cleanup();
};
}, [entry, surface, locale, threadId, user?.id, router]);
return (
<section aria-label={surface.title}>
{failed && <p role="alert">{t.extensions.viewFailed}</p>}
<div ref={ref} />
</section>
);
}
export function PluginSurfaces({
slot,
namespace,
surfaceId,
threadId,
}: {
slot: SurfaceSlot;
namespace?: string;
surfaceId?: string;
threadId?: string;
}) {
const query = useFrontendExtensions();
const { user } = useAuth();
return activeFrontendExtensions(query.data ?? [])
.filter(
({ contribution }) => !namespace || contribution.namespace === namespace,
)
.flatMap(({ contribution: entry, extension }) =>
(extension.surfaces ?? [])
.filter(
(surface) =>
surface.slot === slot && (!surfaceId || surface.id === surfaceId),
)
.map((surface) => (
<Surface
key={`${user?.id}:${threadId}:${entry.namespace}:${surface.id}`}
entry={entry}
surface={surface}
threadId={threadId}
/>
)),
);
}