mirror of
https://github.com/Tencent/BrowserSkill.git
synced 2026-09-10 13:01:23 +08:00
feat(dsh-plugin-browserskill): add the web client half with a browser_screenshot toolview
Make the package dual-face: a dsh.client declaration (platform 'web') plus an exports["./client"] bundle built in dsh's closure-factory shape (window.__ModuleLoader__.load handoff, platform modules external, everything else inlined, CSS Modules via lightningcss). The client registers a keyed 'tool.call.toolview' view for browser_screenshot that keeps the terminal block (command + output) and, when the settled result carries an image block, resolves the durable attachment through the client session's authorized readAttachment RPC and renders it with the shared MessageImage thumbnail/lightbox atoms. Path-only results render unchanged; every other browser_* tool keeps the stock terminal card. Tests: view-model derivation (running/ok/error, image vs path-only), component rendering through @testing-library/react + happy-dom, and the registration key. biome.json now excludes the package's lib/ build output (same treatment as dist/).
This commit is contained in:
@@ -39,6 +39,7 @@
|
||||
"**",
|
||||
"!**/node_modules/**",
|
||||
"!**/dist/**",
|
||||
"!packages/dsh-plugin-browserskill/lib/**",
|
||||
"!**/target/**",
|
||||
"!**/.wxt/**",
|
||||
"!**/.output/**",
|
||||
|
||||
@@ -69,6 +69,15 @@ All fields are optional and validated through the plugin's Schemastery `Config`:
|
||||
- **UI cards**: calls render as terminal cards (command line as title, output as the completed
|
||||
card). Screenshots additionally attach the image itself when the host mounts an attachment store
|
||||
and the active model route declares image input; otherwise the PNG path is returned.
|
||||
- **Web UI toolview (browser half)**: the package is dual-face. `dsh.client` (platform `web`) ships
|
||||
`lib/client.js`, which registers a keyed `tool.call.toolview` view for `browser_screenshot`. The
|
||||
custom view keeps the terminal block (command + output) and, when the settled result carries an
|
||||
image block, resolves the durable attachment through the client session's authorized
|
||||
`readAttachment` RPC and renders it with the shared `MessageImage` thumbnail/lightbox atoms.
|
||||
Every other `browser_*` tool keeps the stock terminal card. The bundle follows the dsh client
|
||||
contract: a CJS closure factory handed to `window.__ModuleLoader__.load`, platform modules
|
||||
(`react`, `dsh-client-ui-*`) external, everything else inlined, CSS Modules compiled by
|
||||
lightningcss.
|
||||
- **Errors**: non-zero bsk exits surface the CLI's JSON error envelope (`code`, `message`, `hint`)
|
||||
so the model gets the daemon's actionable guidance.
|
||||
- **Long-running work** (e.g. `bsk record`) is not backgrounded via `ctx.jobs` yet — tracked as a
|
||||
|
||||
@@ -11,6 +11,9 @@
|
||||
"types": "./lib/index.d.mts",
|
||||
"default": "./lib/index.mjs"
|
||||
},
|
||||
"./client": {
|
||||
"default": "./lib/client.js"
|
||||
},
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
@@ -20,6 +23,13 @@
|
||||
"dsh": {
|
||||
"bundle": {
|
||||
"patch": "./cordis.patch.yml"
|
||||
},
|
||||
"client": {
|
||||
"platform": "web",
|
||||
"inject": [
|
||||
"@deepseek-ai/dsh-client-runtime",
|
||||
"@deepseek-ai/dsh-client-ui-tool"
|
||||
]
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
@@ -29,24 +39,54 @@
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/cordis": "^4.0.1",
|
||||
"@deepseek-ai/dsh-tools": "^0.1.0-rc.6"
|
||||
"@deepseek-ai/dsh-client-runtime": "^0.1.0-rc.6",
|
||||
"@deepseek-ai/dsh-client-ui-attachment": "^0.1.0-rc.6",
|
||||
"@deepseek-ai/dsh-client-ui-primitives": "^0.1.0-rc.6",
|
||||
"@deepseek-ai/dsh-client-ui-tool": "^0.1.0-rc.6",
|
||||
"@deepseek-ai/dsh-tools": "^0.1.0-rc.6",
|
||||
"react": "^18.2.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@deepseek-ai/dsh-attachment": {
|
||||
"optional": true
|
||||
},
|
||||
"@deepseek-ai/dsh-client-runtime": {
|
||||
"optional": true
|
||||
},
|
||||
"@deepseek-ai/dsh-client-ui-attachment": {
|
||||
"optional": true
|
||||
},
|
||||
"@deepseek-ai/dsh-client-ui-primitives": {
|
||||
"optional": true
|
||||
},
|
||||
"@deepseek-ai/dsh-client-ui-tool": {
|
||||
"optional": true
|
||||
},
|
||||
"@deepseek-ai/dsh-llm": {
|
||||
"optional": true
|
||||
},
|
||||
"react": {
|
||||
"optional": true
|
||||
}
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/cordis": "^4.0.1",
|
||||
"@deepseek-ai/dsh-agent": "^0.1.0-rc.6",
|
||||
"@deepseek-ai/dsh-attachment": "^0.1.0-rc.6",
|
||||
"@deepseek-ai/dsh-client-runtime": "^0.1.0-rc.6",
|
||||
"@deepseek-ai/dsh-client-ui-attachment": "^0.1.0-rc.6",
|
||||
"@deepseek-ai/dsh-client-ui-primitives": "^0.1.0-rc.6",
|
||||
"@deepseek-ai/dsh-client-ui-tool": "^0.1.0-rc.6",
|
||||
"@deepseek-ai/dsh-llm": "^0.1.0-rc.6",
|
||||
"@deepseek-ai/dsh-tools": "^0.1.0-rc.6",
|
||||
"@deepseek-ai/schemastery": "^3.18.1",
|
||||
"@testing-library/react": "^16.1.0",
|
||||
"@types/node": "^22.10.0",
|
||||
"@types/react": "~18.3.1",
|
||||
"happy-dom": "^15.11.7",
|
||||
"lightningcss": "^1.30.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"tsdown": "^0.22.14",
|
||||
"typescript": "^5.8.0",
|
||||
"vitest": "^4.1.6"
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
/* browser_screenshot toolview: disclosure row over a terminal block plus the
|
||||
screenshot image. Semantic tokens only (--dsw-alias-*), no literal colors. */
|
||||
|
||||
.card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.summary {
|
||||
min-width: 0;
|
||||
margin-left: 6px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
|
||||
.card[data-state="error"] .summary {
|
||||
color: var(--dsw-alias-label-error);
|
||||
}
|
||||
|
||||
.body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 4px 0 4px 22px;
|
||||
}
|
||||
|
||||
.image-wrap {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.inspect-button {
|
||||
align-self: flex-start;
|
||||
border: none;
|
||||
background: none;
|
||||
padding: 0;
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.inspect-button:hover {
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
|
||||
.inspect-button:focus-visible {
|
||||
outline: 2px solid var(--dsw-alias-border-focus);
|
||||
outline-offset: 2px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.inspect-button {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
// browser_screenshot keyed toolview: the terminal card's command line and
|
||||
// output stay exactly as the host renders them, and a result carrying an
|
||||
// image block additionally renders the screenshot itself (thumbnail with the
|
||||
// shared click-to-open lightbox). Pure function of the frozen call/result
|
||||
// slice — live and replay paths render identically.
|
||||
|
||||
import type { ImageAttachmentRef } from "@deepseek-ai/dsh-attachment";
|
||||
import {
|
||||
type ImageLoader,
|
||||
MessageImage,
|
||||
type MessageImageLabels,
|
||||
} from "@deepseek-ai/dsh-client-ui-attachment";
|
||||
import {
|
||||
DisclosureRow,
|
||||
StateDot,
|
||||
type StateDotState,
|
||||
TerminalBlock,
|
||||
type TerminalBlockLabels,
|
||||
} from "@deepseek-ai/dsh-client-ui-primitives";
|
||||
import type { ToolCallViewProps } from "@deepseek-ai/dsh-client-ui-tool/client";
|
||||
import { useState } from "react";
|
||||
import css from "./ScreenshotToolView.module.css";
|
||||
|
||||
/** Resolved loader bound to the owning session at registration time. */
|
||||
export type ScreenshotImageLoader = (attachment: ImageAttachmentRef) => Promise<string>;
|
||||
|
||||
export type ScreenshotToolViewProps = ToolCallViewProps & {
|
||||
loadImage: ScreenshotImageLoader;
|
||||
};
|
||||
|
||||
type ViewState = "running" | "ok" | "error";
|
||||
|
||||
interface ViewModel {
|
||||
readonly state: ViewState;
|
||||
readonly command: string;
|
||||
readonly output: string | null;
|
||||
readonly image: ImageAttachmentRef | null;
|
||||
readonly summary: string;
|
||||
}
|
||||
|
||||
const TERMINAL_LABELS: Partial<TerminalBlockLabels> = {
|
||||
running: "Running",
|
||||
failed: "Failed",
|
||||
done: "Done",
|
||||
copy: "Copy",
|
||||
copied: "Copied",
|
||||
noOutput: "No output",
|
||||
collapseAria: "Collapse output",
|
||||
collapse: "Collapse",
|
||||
expandAria: (hidden) => `Expand the remaining ${hidden} output lines`,
|
||||
expand: (hidden) => `… ${hidden} more lines`,
|
||||
};
|
||||
|
||||
const IMAGE_LABELS: MessageImageLabels = {
|
||||
image: "screenshot",
|
||||
open: "Open the original screenshot",
|
||||
openNamed: (label) => `Open screenshot ${label}`,
|
||||
loading: "Loading…",
|
||||
loadFailed: "Load failed — retry",
|
||||
lightbox: { dialog: "Screenshot preview", close: "Close preview" },
|
||||
};
|
||||
|
||||
function firstLine(text: string): string {
|
||||
const newline = text.indexOf("\n");
|
||||
return newline === -1 ? text : text.slice(0, newline);
|
||||
}
|
||||
|
||||
/** Rebuild the command line from the logged arguments (mirrors the host presenter). */
|
||||
function commandOf(argsRaw: string, callId: string): string {
|
||||
try {
|
||||
const args = JSON.parse(argsRaw) as { session?: unknown; ref?: unknown };
|
||||
const parts = ["bsk", "screenshot", "--session"];
|
||||
parts.push(
|
||||
typeof args.session === "string" && args.session !== "" ? args.session : "(current)",
|
||||
);
|
||||
if (typeof args.ref === "string" && args.ref !== "") parts.push("--ref", args.ref);
|
||||
return parts.join(" ");
|
||||
} catch {
|
||||
// Streaming can expose a truncated JSON prefix; it is still the best label.
|
||||
return argsRaw === "" ? `bsk screenshot (${callId})` : `bsk screenshot ${firstLine(argsRaw)}`;
|
||||
}
|
||||
}
|
||||
|
||||
/** Derive the display model from the frozen block only. */
|
||||
export function viewModelOf(block: ToolCallViewProps["block"]): ViewModel {
|
||||
const settled = "kind" in block;
|
||||
const argsRaw = (settled ? block.call?.argsRaw : block.argsRaw) ?? "";
|
||||
const command = commandOf(argsRaw, block.callId);
|
||||
if (!settled) {
|
||||
return { state: "running", command, output: null, image: null, summary: command };
|
||||
}
|
||||
const textBlock = block.content.find((item) => item.type === "text");
|
||||
const imageBlock = block.content.find((item) => item.type === "image");
|
||||
const output = textBlock !== undefined && textBlock.type === "text" ? textBlock.text : null;
|
||||
const image =
|
||||
imageBlock !== undefined && imageBlock.type === "image" ? imageBlock.attachment : null;
|
||||
const state: ViewState = block.isError ? "error" : "ok";
|
||||
const summary = output !== null ? firstLine(output) : command;
|
||||
return { state, command, output, image, summary };
|
||||
}
|
||||
|
||||
function dotState(state: ViewState): StateDotState {
|
||||
switch (state) {
|
||||
case "running":
|
||||
return "ongoing";
|
||||
case "error":
|
||||
return "error";
|
||||
default:
|
||||
return "done";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render one browser_screenshot call: a disclosure row over a terminal block
|
||||
* (command + output) plus the screenshot image when the result carries one.
|
||||
*/
|
||||
export function ScreenshotToolView({ block, cwd, inspect, loadImage }: ScreenshotToolViewProps) {
|
||||
const model = viewModelOf(block);
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const expandable = model.state === "running" || model.output !== null || model.image !== null;
|
||||
const open = expanded && expandable;
|
||||
return (
|
||||
<div className={css.card} data-tool="browser_screenshot" data-state={model.state}>
|
||||
<DisclosureRow
|
||||
icon={<StateDot state={dotState(model.state)} />}
|
||||
title="Screenshot"
|
||||
open={open}
|
||||
expandable={expandable}
|
||||
onToggle={() => setExpanded((value) => !value)}
|
||||
expandOnRowClick
|
||||
previewChevron
|
||||
collapsedContent={<span className={css.summary}>{model.summary}</span>}
|
||||
>
|
||||
<div className={css.body}>
|
||||
<TerminalBlock
|
||||
command={model.command}
|
||||
cwd={cwd ?? undefined}
|
||||
output={model.output ?? undefined}
|
||||
running={model.state === "running"}
|
||||
labels={TERMINAL_LABELS}
|
||||
/>
|
||||
{model.image !== null ? (
|
||||
<div className={css["image-wrap"]}>
|
||||
<MessageImage
|
||||
attachment={model.image}
|
||||
load={loadImage}
|
||||
variant="single"
|
||||
labels={IMAGE_LABELS}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
{inspect !== undefined ? (
|
||||
<button type="button" className={css["inspect-button"]} onClick={inspect}>
|
||||
Inspect
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</DisclosureRow>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* dsh-plugin-browserskill browser half: registers the `browser_screenshot`
|
||||
* keyed toolview (`tool.call.toolview` slot) so screenshot results render the
|
||||
* captured image inline in the Web UI instead of a bare JSON block. Every
|
||||
* other browser_* tool keeps the stock terminal card.
|
||||
*
|
||||
* The image bytes never enter the session log: the view resolves the durable
|
||||
* attachment reference through the client session's authorized
|
||||
* `readAttachment` RPC and hands a blob URL to the shared MessageImage atom.
|
||||
*/
|
||||
|
||||
import type { ImageAttachmentRef } from "@deepseek-ai/dsh-attachment";
|
||||
import type { ClientContext, ISessions, SessionId } from "@deepseek-ai/dsh-client-runtime/client";
|
||||
import type { ToolCallViewProps } from "@deepseek-ai/dsh-client-ui-tool/client";
|
||||
import { createElement } from "react";
|
||||
import { ScreenshotToolView } from "./ScreenshotToolView";
|
||||
|
||||
/** Required services: the slot registry plus session-scoped attachment reads. */
|
||||
export const inject = ["slots", "sessions"];
|
||||
|
||||
/** Resolve one durable image attachment into a browser blob URL. */
|
||||
async function loadSessionImage(
|
||||
sessions: ISessions,
|
||||
sessionId: SessionId,
|
||||
attachment: ImageAttachmentRef,
|
||||
): Promise<string> {
|
||||
const session = sessions.binding(sessionId)?.session;
|
||||
if (session === undefined) {
|
||||
throw new Error(`screenshot toolview: session "${String(sessionId)}" is not bound`);
|
||||
}
|
||||
const result = await session.readAttachment(attachment.attachmentId);
|
||||
if (!result.ok) {
|
||||
throw new Error(
|
||||
`screenshot toolview: readAttachment failed: ${result.error.code}: ${result.error.message}`,
|
||||
);
|
||||
}
|
||||
const bytes = Uint8Array.from(result.value.data);
|
||||
return URL.createObjectURL(
|
||||
new Blob([bytes.buffer as ArrayBuffer], { type: result.value.attachment.mediaType }),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Client plugin body: register the keyed view with its session-bound loader.
|
||||
* @param ctx - client root context.
|
||||
*/
|
||||
export function apply(ctx: ClientContext): void {
|
||||
const sessions = ctx.get("sessions") as unknown as ISessions;
|
||||
ctx.slots.inject("tool.call.toolview", () =>
|
||||
ctx.slots.register(
|
||||
{ name: "tool.call.toolview", key: "browser_screenshot" },
|
||||
(props: ToolCallViewProps) =>
|
||||
createElement(ScreenshotToolView, {
|
||||
...props,
|
||||
loadImage: (attachment: ImageAttachmentRef) =>
|
||||
loadSessionImage(sessions, props.sessionId, attachment),
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
declare module "*.module.css" {
|
||||
const classes: Record<string, string>;
|
||||
export default classes;
|
||||
}
|
||||
|
||||
declare module "*.css";
|
||||
@@ -0,0 +1,151 @@
|
||||
// @vitest-environment happy-dom
|
||||
// browser_screenshot keyed toolview: view-model derivation from frozen blocks,
|
||||
// image/path-only/running/error rendering, the registration key, and the
|
||||
// session-bound attachment loader.
|
||||
|
||||
import type { RunningToolCall, ToolResultNode } from "@deepseek-ai/dsh-client-runtime/client";
|
||||
import { cleanup, render, screen, waitFor } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { apply } from "../../src/client/index";
|
||||
import {
|
||||
ScreenshotToolView,
|
||||
type ScreenshotToolViewProps,
|
||||
viewModelOf,
|
||||
} from "../../src/client/ScreenshotToolView";
|
||||
|
||||
const ATTACHMENT = {
|
||||
attachmentId: "sha256:abc123",
|
||||
mediaType: "image/png",
|
||||
bytes: 8,
|
||||
width: 800,
|
||||
height: 457,
|
||||
name: "screenshot-s1.png",
|
||||
} as never;
|
||||
|
||||
function runningBlock(argsRaw: string): RunningToolCall {
|
||||
return {
|
||||
callId: "c1",
|
||||
name: "browser_screenshot",
|
||||
argsRaw,
|
||||
turn: 1,
|
||||
step: 1,
|
||||
time: 0,
|
||||
callView: null,
|
||||
subCalls: [],
|
||||
} as never;
|
||||
}
|
||||
|
||||
function settledBlock(argsRaw: string, content: unknown[], isError = false): ToolResultNode {
|
||||
return {
|
||||
kind: "tool-result",
|
||||
seq: 1,
|
||||
time: 0,
|
||||
callId: "c1",
|
||||
call: { name: "browser_screenshot", argsRaw },
|
||||
callTime: 0,
|
||||
content,
|
||||
isError,
|
||||
callView: null,
|
||||
resultView: null,
|
||||
subCalls: [],
|
||||
} as never;
|
||||
}
|
||||
|
||||
const IMAGE_TEXT = "[session s1] screenshot of tab 7 (800x457px)";
|
||||
const PATH_TEXT = "[session s1] screenshot saved to /tmp/shot.png (800x457px, 8 bytes)";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
describe("viewModelOf", () => {
|
||||
it("derives the running model from the call frame", () => {
|
||||
const model = viewModelOf(runningBlock('{"session":"s1"}'));
|
||||
expect(model.state).toBe("running");
|
||||
expect(model.command).toBe("bsk screenshot --session s1");
|
||||
expect(model.image).toBeNull();
|
||||
});
|
||||
|
||||
it("marks settled results carrying an image block", () => {
|
||||
const block = settledBlock("{}", [
|
||||
{ type: "text", text: IMAGE_TEXT },
|
||||
{ type: "image", attachment: ATTACHMENT },
|
||||
]);
|
||||
const model = viewModelOf(block);
|
||||
expect(model.state).toBe("ok");
|
||||
expect(model.image).toBe(ATTACHMENT);
|
||||
expect(model.output).toBe(IMAGE_TEXT);
|
||||
expect(model.command).toBe("bsk screenshot --session (current)");
|
||||
});
|
||||
|
||||
it("keeps the path-only form image-free", () => {
|
||||
const model = viewModelOf(settledBlock("{}", [{ type: "text", text: PATH_TEXT }]));
|
||||
expect(model.state).toBe("ok");
|
||||
expect(model.image).toBeNull();
|
||||
expect(model.summary).toBe(PATH_TEXT);
|
||||
});
|
||||
|
||||
it("marks error results", () => {
|
||||
const model = viewModelOf(settledBlock("{}", [{ type: "text", text: "Error: boom" }], true));
|
||||
expect(model.state).toBe("error");
|
||||
});
|
||||
});
|
||||
|
||||
describe("ScreenshotToolView", () => {
|
||||
const baseProps = { callId: "c1", toolName: "browser_screenshot", openFile: () => {} };
|
||||
const renderView = (
|
||||
block: ScreenshotToolViewProps["block"],
|
||||
loadImage: ScreenshotToolViewProps["loadImage"],
|
||||
) => {
|
||||
const props = { ...baseProps, block, loadImage } as unknown as ScreenshotToolViewProps;
|
||||
return render(<ScreenshotToolView {...props} />);
|
||||
};
|
||||
|
||||
it("renders the screenshot image through the loader", async () => {
|
||||
const block = settledBlock("{}", [
|
||||
{ type: "text", text: IMAGE_TEXT },
|
||||
{ type: "image", attachment: ATTACHMENT },
|
||||
]);
|
||||
const loadImage = vi.fn(async () => "blob:mock-url");
|
||||
renderView(block, loadImage);
|
||||
// Collapsed row shows the summary; expand to reach the image.
|
||||
expect(screen.getByText(IMAGE_TEXT)).toBeTruthy();
|
||||
const toggle = screen.getByRole("button", { name: /screenshot/i });
|
||||
toggle.click();
|
||||
await waitFor(() => expect(loadImage).toHaveBeenCalledWith(ATTACHMENT));
|
||||
await screen.findByRole("img", { name: "screenshot-s1.png" });
|
||||
});
|
||||
|
||||
it("renders the path-only form without touching the loader", async () => {
|
||||
const block = settledBlock("{}", [{ type: "text", text: PATH_TEXT }]);
|
||||
const loadImage = vi.fn(async () => "blob:unused");
|
||||
renderView(block, loadImage);
|
||||
screen.getByRole("button", { name: /screenshot/i }).click();
|
||||
await screen.findByText(PATH_TEXT, { exact: false });
|
||||
expect(loadImage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("renders the running form without output", () => {
|
||||
const loadImage = vi.fn();
|
||||
renderView(runningBlock("{}"), loadImage);
|
||||
expect(screen.getByText("bsk screenshot --session (current)")).toBeTruthy();
|
||||
expect(loadImage).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("client plugin registration", () => {
|
||||
it("registers only the browser_screenshot keyed toolview", () => {
|
||||
const registrations: { name: string; key?: string }[] = [];
|
||||
const sessions = { binding: () => undefined };
|
||||
const ctx = {
|
||||
get: (key: string) => (key === "sessions" ? sessions : undefined),
|
||||
slots: {
|
||||
inject: (_name: string, fn: () => unknown) => fn(),
|
||||
register: (slot: { name: string; key?: string }, view: unknown) => {
|
||||
registrations.push(slot);
|
||||
expect(typeof view).toBe("function");
|
||||
},
|
||||
},
|
||||
};
|
||||
apply(ctx as never);
|
||||
expect(registrations).toEqual([{ name: "tool.call.toolview", key: "browser_screenshot" }]);
|
||||
});
|
||||
});
|
||||
@@ -3,6 +3,8 @@
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"jsx": "react-jsx",
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
|
||||
@@ -1,10 +1,124 @@
|
||||
import { defineConfig } from "tsdown";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { basename, dirname, resolve as resolvePath } from "node:path";
|
||||
import { transform } from "lightningcss";
|
||||
import { defineConfig, type UserConfig } from "tsdown";
|
||||
|
||||
export default defineConfig({
|
||||
entry: ["src/index.ts"],
|
||||
format: ["esm"],
|
||||
dts: true,
|
||||
/**
|
||||
* Two build faces of the dual-face package:
|
||||
* - host: the Cordis tool plugin (lib/index.mjs, ESM, dsh packages external);
|
||||
* - client: the Web UI bundle (lib/client.js) in dsh's closure-factory shape —
|
||||
* the artifact registers itself through `window.__ModuleLoader__.load`, with
|
||||
* platform modules resolved from the loader's frozen module table and every
|
||||
* other dependency inlined. Mirrors deepseek-harness `packages/client/tsdown.client.ts`.
|
||||
*/
|
||||
|
||||
/** Module specifiers the dsh web shell shares into its frozen module table. */
|
||||
const CLIENT_EXTERNALS: readonly string[] = [
|
||||
"react",
|
||||
"react/jsx-runtime",
|
||||
"react-dom",
|
||||
"react-dom/client",
|
||||
"@deepseek-ai/cordis",
|
||||
"@deepseek-ai/dsh-client-ui-slots",
|
||||
"@deepseek-ai/dsh-client-web-react",
|
||||
"@deepseek-ai/dsh-client-ui-primitives",
|
||||
"@deepseek-ai/dsh-client-ui-attachment",
|
||||
"@deepseek-ai/dsh-client-schema-form",
|
||||
"@deepseek-ai/dsh-client-runtime/client",
|
||||
];
|
||||
|
||||
/** Wire/type layers with no shared runtime identity: safe to inline. */
|
||||
const INLINE_SAFE = /^@deepseek-ai\/dsh-(host-apiproxy|session|llm|tools|brand|attachment)(\/|$)/;
|
||||
const VENDORED_LIBRARY = /^@deepseek-ai\/(cosmokit|schemastery)(\/|$)/;
|
||||
|
||||
const CSS_VIRTUAL_PREFIX = "\0bsk-css:";
|
||||
const CSS_VIRTUAL_SUFFIX = ".mjs";
|
||||
const CLIENT_ID = "dsh-plugin-browserskill";
|
||||
|
||||
const client: UserConfig = {
|
||||
name: `${CLIENT_ID}/client`,
|
||||
entry: { client: "src/client/index.ts" },
|
||||
outDir: "lib",
|
||||
// dsh host packages are provided by the profile the plugin is installed into.
|
||||
external: [/^@deepseek-ai\//],
|
||||
});
|
||||
format: "cjs",
|
||||
platform: "browser",
|
||||
dts: false,
|
||||
clean: false,
|
||||
external: [...CLIENT_EXTERNALS],
|
||||
// Anything not in the loader module table must inline; an unanswerable
|
||||
// require() is a guaranteed runtime throw.
|
||||
noExternal: (id: string) => (CLIENT_EXTERNALS.includes(id) ? undefined : true),
|
||||
define: {
|
||||
"process.env.NODE_ENV": JSON.stringify(process.env.NODE_ENV ?? "production"),
|
||||
"import.meta.env.MODE": JSON.stringify(process.env.NODE_ENV ?? "production"),
|
||||
"import.meta.env": JSON.stringify({ MODE: process.env.NODE_ENV ?? "production" }),
|
||||
},
|
||||
plugins: [
|
||||
{
|
||||
// Bundle purity gate: platform modules stay external, wire layers
|
||||
// inline, every other @deepseek-ai value import is a build error.
|
||||
name: "bsk-client-bundle-purity",
|
||||
resolveId(source: string) {
|
||||
if (!source.startsWith("@deepseek-ai/")) return null;
|
||||
if (CLIENT_EXTERNALS.includes(source)) return null;
|
||||
if (VENDORED_LIBRARY.test(source)) return null;
|
||||
if (INLINE_SAFE.test(source)) return null;
|
||||
throw new Error(
|
||||
`client bundle purity: "${source}" is not a platform module or an inline-safe wire layer — ` +
|
||||
"collaborate through cordis services (type-only imports are erased and never reach this gate)",
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "bsk-css-modules-inline",
|
||||
resolveId(source: string, importer: string | undefined) {
|
||||
if (!source.endsWith(".module.css")) return null;
|
||||
const abs = importer !== undefined ? resolvePath(dirname(importer), source) : source;
|
||||
return CSS_VIRTUAL_PREFIX + abs + CSS_VIRTUAL_SUFFIX;
|
||||
},
|
||||
async load(virtualId: string) {
|
||||
if (!virtualId.startsWith(CSS_VIRTUAL_PREFIX)) return null;
|
||||
const fileId = virtualId.slice(CSS_VIRTUAL_PREFIX.length, -CSS_VIRTUAL_SUFFIX.length);
|
||||
this.addWatchFile(fileId);
|
||||
const source = await readFile(fileId);
|
||||
const { code, exports: cssExports } = transform({
|
||||
filename: fileId,
|
||||
code: source,
|
||||
cssModules: { pattern: "[hash]_[local]" },
|
||||
minify: true,
|
||||
});
|
||||
const classMap: Record<string, string> = {};
|
||||
for (const [local, exp] of Object.entries(cssExports ?? {})) classMap[local] = exp.name;
|
||||
return [
|
||||
`const css = ${JSON.stringify(code.toString())};`,
|
||||
`const tagId = ${JSON.stringify(`${CLIENT_ID}/${basename(fileId)}`)};`,
|
||||
"if (typeof document !== 'undefined' && document.querySelector('style[data-plugin-css=' + JSON.stringify(tagId) + ']') === null) {",
|
||||
" const tag = document.createElement('style');",
|
||||
` tag.dataset.plugin = ${JSON.stringify(CLIENT_ID)};`,
|
||||
" tag.dataset.pluginCss = tagId;",
|
||||
" tag.textContent = css;",
|
||||
" document.head.appendChild(tag);",
|
||||
"}",
|
||||
`export default ${JSON.stringify(classMap)};`,
|
||||
].join("\n");
|
||||
},
|
||||
},
|
||||
],
|
||||
outputOptions: {
|
||||
entryFileNames: "client.js",
|
||||
banner: `window.__ModuleLoader__.load({ id: ${JSON.stringify(CLIENT_ID)}, factory: (require) => {`,
|
||||
footer: "return module.exports; } });",
|
||||
intro: "var module = { exports: {} }; var exports = module.exports;",
|
||||
},
|
||||
};
|
||||
|
||||
export default defineConfig([
|
||||
{
|
||||
entry: ["src/index.ts"],
|
||||
format: ["esm"],
|
||||
dts: true,
|
||||
outDir: "lib",
|
||||
// dsh host packages are provided by the profile the plugin is installed into.
|
||||
external: [/^@deepseek-ai\//],
|
||||
},
|
||||
client,
|
||||
]);
|
||||
|
||||
@@ -3,6 +3,13 @@ import { defineConfig } from "vitest/config";
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: "node",
|
||||
include: ["tests/**/*.test.ts"],
|
||||
include: ["tests/**/*.test.ts", "tests/**/*.test.tsx"],
|
||||
server: {
|
||||
deps: {
|
||||
// dsh client packages ship ESM with CSS imports; run them through the
|
||||
// transform pipeline (where CSS is stubbed) instead of Node's loader.
|
||||
inline: [/@deepseek-ai\//],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
Generated
+2024
-15
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user