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:
@@ -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";
|
||||
Reference in New Issue
Block a user