mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-17 19:20:39 +09:00
Expand plugin host surface (#5205)
## Thinking Path > - Paperclip orchestrates AI agents for zero-human companies > - The plugin system is the extension boundary for optional product capabilities > - Rich plugins need more than a worker entrypoint: they need scoped database storage, local project folders, managed agents/routines, host navigation, and reusable UI components > - The LLM Wiki work exposed those missing host surfaces while keeping plugin code outside the core control plane > - This pull request expands the core plugin host, SDK, server APIs, and UI bridge so plugins can declare and use those surfaces > - The benefit is that future plugins can integrate with Paperclip through documented, validated contracts instead of bespoke server or UI imports ## What Changed - Added plugin-managed database namespaces and migration tracking, including Drizzle schema/migration files and SQL validation for namespace isolation. - Added server support for plugin local folders, managed agents, managed routines, scoped plugin APIs, and plugin operation visibility. - Expanded shared plugin manifest/types/validators and SDK host/testing/UI exports for richer plugin surfaces. - Added reusable UI pieces for file trees, managed routines, resizable sidebars, route sidebars, and plugin bridge initialization. - Updated plugin docs and example plugins to use the expanded host and SDK surface. ## Verification - `pnpm install --frozen-lockfile` - `pnpm run preflight:workspace-links && pnpm exec vitest run packages/shared/src/validators/plugin.test.ts server/src/__tests__/plugin-database.test.ts server/src/__tests__/plugin-local-folders.test.ts server/src/__tests__/plugin-managed-agents.test.ts server/src/__tests__/plugin-managed-routines.test.ts server/src/__tests__/plugin-orchestration-apis.test.ts ui/src/api/plugins.test.ts ui/src/components/FileTree.test.tsx ui/src/components/ResizableSidebarPane.test.tsx ui/src/pages/PluginPage.test.tsx ui/src/plugins/bridge.test.ts` passed: 11 files, 67 tests. - Confirmed this PR changes 89 files and does not include `pnpm-lock.yaml` or `.github/workflows/*`. ## Risks - Medium: this expands plugin host contracts across db/shared/server/ui and includes a new core migration (`0076_useful_elektra.sql`). - The plugin database namespace validator is intentionally restrictive; plugin authors may need follow-up affordances for SQL patterns that remain blocked. - Merge this before the LLM Wiki plugin PR so the plugin can resolve the new SDK and host APIs. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used - OpenAI Codex, GPT-5 coding agent, tool-enabled shell/git/GitHub workflow. Context window size was not exposed by the runtime. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] If this change affects the UI, I have included before/after screenshots - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
parent
d6bee62f02
commit
3c73ed26b5
89 changed files with 27516 additions and 914 deletions
|
|
@ -19,6 +19,12 @@ interface MarkdownBodyProps {
|
|||
style?: React.CSSProperties;
|
||||
softBreaks?: boolean;
|
||||
linkIssueReferences?: boolean;
|
||||
/** Opt into Obsidian-style [[target]] / [[target|label]] wikilinks. */
|
||||
enableWikiLinks?: boolean;
|
||||
/** Base href used for wikilinks when no resolver is supplied. */
|
||||
wikiLinkRoot?: string;
|
||||
/** Optional href resolver for wikilinks. Return null to leave a token as plain text. */
|
||||
resolveWikiLinkHref?: (target: string, label: string) => string | null | undefined;
|
||||
/** Optional resolver for relative image paths (e.g. within export packages) */
|
||||
resolveImageSrc?: (src: string) => string | null;
|
||||
/** Called when a user clicks an inline image */
|
||||
|
|
@ -111,6 +117,160 @@ function safeMarkdownUrlTransform(url: string): string {
|
|||
return parseMentionChipHref(url) ? url : defaultUrlTransform(url);
|
||||
}
|
||||
|
||||
type MarkdownAstNode = {
|
||||
type?: string;
|
||||
value?: string;
|
||||
children?: MarkdownAstNode[];
|
||||
url?: string;
|
||||
title?: string | null;
|
||||
data?: {
|
||||
hProperties?: Record<string, string>;
|
||||
};
|
||||
};
|
||||
|
||||
type ParsedWikiLink = {
|
||||
target: string;
|
||||
label: string;
|
||||
};
|
||||
|
||||
const WIKI_LINK_PATTERN = /\[\[([^\]\r\n]+)\]\]/g;
|
||||
const WIKI_LINK_SKIP_PARENT_TYPES = new Set([
|
||||
"definition",
|
||||
"image",
|
||||
"imageReference",
|
||||
"link",
|
||||
"linkReference",
|
||||
]);
|
||||
|
||||
function parseWikiLinkBody(body: string): ParsedWikiLink | null {
|
||||
const [rawTarget, ...rawLabelParts] = body.split("|");
|
||||
const target = rawTarget?.trim() ?? "";
|
||||
const label = rawLabelParts.length > 0 ? rawLabelParts.join("|").trim() : target;
|
||||
if (!target || target.includes("[") || target.includes("]")) return null;
|
||||
return {
|
||||
target,
|
||||
label: label || target,
|
||||
};
|
||||
}
|
||||
|
||||
function encodeWikiLinkTarget(target: string): string | null {
|
||||
const trimmed = target.trim();
|
||||
if (!trimmed || /^[a-z][a-z\d+.-]*:/i.test(trimmed) || trimmed.startsWith("//")) return null;
|
||||
|
||||
const hashIndex = trimmed.indexOf("#");
|
||||
const rawPath = (hashIndex >= 0 ? trimmed.slice(0, hashIndex) : trimmed)
|
||||
.trim()
|
||||
.replace(/^\/+/, "");
|
||||
if (
|
||||
!rawPath ||
|
||||
rawPath.includes("\\") ||
|
||||
rawPath.split("/").some((segment) => !segment || segment === "." || segment === "..")
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const encodedPath = rawPath.split("/").map((segment) => encodeURIComponent(segment)).join("/");
|
||||
const rawHash = hashIndex >= 0 ? trimmed.slice(hashIndex + 1).trim() : "";
|
||||
return rawHash ? `${encodedPath}#${encodeURIComponent(rawHash)}` : encodedPath;
|
||||
}
|
||||
|
||||
function defaultWikiLinkHref(target: string, wikiLinkRoot?: string): string | null {
|
||||
const encodedTarget = encodeWikiLinkTarget(target);
|
||||
if (!encodedTarget) return null;
|
||||
const root = wikiLinkRoot?.trim().replace(/\/+$/, "") ?? "";
|
||||
return root ? `${root}/${encodedTarget}` : encodedTarget;
|
||||
}
|
||||
|
||||
function createWikiLinkNode(href: string, wikiLink: ParsedWikiLink): MarkdownAstNode {
|
||||
return {
|
||||
type: "link",
|
||||
url: href,
|
||||
title: null,
|
||||
data: {
|
||||
hProperties: {
|
||||
"data-paperclip-wiki-link": "true",
|
||||
"data-paperclip-wiki-target": wikiLink.target,
|
||||
},
|
||||
},
|
||||
children: [{ type: "text", value: wikiLink.label }],
|
||||
};
|
||||
}
|
||||
|
||||
function splitTextByWikiLinks(
|
||||
value: string,
|
||||
options: {
|
||||
wikiLinkRoot?: string;
|
||||
resolveWikiLinkHref?: (target: string, label: string) => string | null | undefined;
|
||||
},
|
||||
): MarkdownAstNode[] {
|
||||
const nodes: MarkdownAstNode[] = [];
|
||||
let lastIndex = 0;
|
||||
|
||||
for (const match of value.matchAll(WIKI_LINK_PATTERN)) {
|
||||
const raw = match[0] ?? "";
|
||||
const body = match[1] ?? "";
|
||||
const start = match.index ?? 0;
|
||||
if (start > lastIndex) {
|
||||
nodes.push({ type: "text", value: value.slice(lastIndex, start) });
|
||||
}
|
||||
|
||||
const wikiLink = parseWikiLinkBody(body);
|
||||
let resolvedHref: string | null = null;
|
||||
if (wikiLink) {
|
||||
if (options.resolveWikiLinkHref) {
|
||||
const customHref = options.resolveWikiLinkHref(wikiLink.target, wikiLink.label);
|
||||
resolvedHref = customHref === undefined
|
||||
? defaultWikiLinkHref(wikiLink.target, options.wikiLinkRoot)
|
||||
: customHref;
|
||||
} else {
|
||||
resolvedHref = defaultWikiLinkHref(wikiLink.target, options.wikiLinkRoot);
|
||||
}
|
||||
}
|
||||
|
||||
if (wikiLink && resolvedHref) {
|
||||
nodes.push(createWikiLinkNode(resolvedHref, wikiLink));
|
||||
} else {
|
||||
nodes.push({ type: "text", value: raw });
|
||||
}
|
||||
lastIndex = start + raw.length;
|
||||
}
|
||||
|
||||
if (lastIndex < value.length) {
|
||||
nodes.push({ type: "text", value: value.slice(lastIndex) });
|
||||
}
|
||||
|
||||
return nodes;
|
||||
}
|
||||
|
||||
function transformWikiLinkChildren(
|
||||
node: MarkdownAstNode,
|
||||
options: {
|
||||
wikiLinkRoot?: string;
|
||||
resolveWikiLinkHref?: (target: string, label: string) => string | null | undefined;
|
||||
},
|
||||
) {
|
||||
if (!node.children || WIKI_LINK_SKIP_PARENT_TYPES.has(node.type ?? "")) return;
|
||||
|
||||
node.children = node.children.flatMap((child) => {
|
||||
if (child.type === "text" && typeof child.value === "string" && child.value.includes("[[")) {
|
||||
return splitTextByWikiLinks(child.value, options);
|
||||
}
|
||||
transformWikiLinkChildren(child, options);
|
||||
return child;
|
||||
});
|
||||
}
|
||||
|
||||
function createRemarkWikiLinks(options: {
|
||||
wikiLinkRoot?: string;
|
||||
resolveWikiLinkHref?: (target: string, label: string) => string | null | undefined;
|
||||
}) {
|
||||
return function remarkWikiLinks() {
|
||||
return (tree: MarkdownAstNode) => {
|
||||
transformWikiLinkChildren(tree, options);
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
function isGitHubUrl(href: string | null | undefined): boolean {
|
||||
if (!href) return false;
|
||||
try {
|
||||
|
|
@ -321,11 +481,17 @@ export function MarkdownBody({
|
|||
style,
|
||||
softBreaks = true,
|
||||
linkIssueReferences = true,
|
||||
enableWikiLinks = false,
|
||||
wikiLinkRoot,
|
||||
resolveWikiLinkHref,
|
||||
resolveImageSrc,
|
||||
onImageClick,
|
||||
}: MarkdownBodyProps) {
|
||||
const { theme } = useTheme();
|
||||
const remarkPlugins: NonNullable<Options["remarkPlugins"]> = [remarkGfm];
|
||||
if (enableWikiLinks) {
|
||||
remarkPlugins.push(createRemarkWikiLinks({ wikiLinkRoot, resolveWikiLinkHref }));
|
||||
}
|
||||
if (linkIssueReferences) {
|
||||
remarkPlugins.push(remarkLinkIssueReferences);
|
||||
}
|
||||
|
|
@ -370,7 +536,22 @@ export function MarkdownBody({
|
|||
{codeChildren}
|
||||
</code>
|
||||
),
|
||||
a: ({ href, style: linkStyle, children: linkChildren }) => {
|
||||
a: ({ node: _node, href, style: linkStyle, children: linkChildren, ...anchorProps }) => {
|
||||
const dataProps = anchorProps as Record<string, unknown>;
|
||||
const isWikiLink = dataProps["data-paperclip-wiki-link"] === "true";
|
||||
if (isWikiLink && href && !/^[a-z][a-z\d+.-]*:/i.test(href) && !href.startsWith("//")) {
|
||||
return (
|
||||
<Link
|
||||
to={href}
|
||||
{...anchorProps}
|
||||
rel="noreferrer"
|
||||
style={mergeWrapStyle(linkStyle as React.CSSProperties | undefined)}
|
||||
>
|
||||
{linkChildren}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
const issueRef = linkIssueReferences ? parseIssueReferenceFromHref(href) : null;
|
||||
if (issueRef) {
|
||||
return (
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue