slidev-addon-agent 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +54 -0
- package/agent/constants.ts +119 -0
- package/agent/deck-context.ts +67 -0
- package/agent/index.ts +201 -0
- package/agent/middleware.ts +163 -0
- package/agent/skills/slidev/README.md +61 -0
- package/agent/skills/slidev/SKILL.md +189 -0
- package/agent/skills/slidev/references/animation-click-marker.md +37 -0
- package/agent/skills/slidev/references/animation-drawing.md +68 -0
- package/agent/skills/slidev/references/animation-rough-marker.md +53 -0
- package/agent/skills/slidev/references/api-slide-hooks.md +37 -0
- package/agent/skills/slidev/references/build-og-image.md +36 -0
- package/agent/skills/slidev/references/build-pdf.md +40 -0
- package/agent/skills/slidev/references/build-remote-assets.md +34 -0
- package/agent/skills/slidev/references/build-seo-meta.md +43 -0
- package/agent/skills/slidev/references/code-groups.md +64 -0
- package/agent/skills/slidev/references/code-import-snippet.md +55 -0
- package/agent/skills/slidev/references/code-line-highlighting.md +50 -0
- package/agent/skills/slidev/references/code-line-numbers.md +46 -0
- package/agent/skills/slidev/references/code-magic-move.md +57 -0
- package/agent/skills/slidev/references/code-max-height.md +37 -0
- package/agent/skills/slidev/references/code-twoslash.md +42 -0
- package/agent/skills/slidev/references/core-animations.md +196 -0
- package/agent/skills/slidev/references/core-cli.md +140 -0
- package/agent/skills/slidev/references/core-components.md +197 -0
- package/agent/skills/slidev/references/core-exporting.md +148 -0
- package/agent/skills/slidev/references/core-frontmatter.md +195 -0
- package/agent/skills/slidev/references/core-global-context.md +155 -0
- package/agent/skills/slidev/references/core-headmatter.md +188 -0
- package/agent/skills/slidev/references/core-hosting.md +152 -0
- package/agent/skills/slidev/references/core-layouts.md +286 -0
- package/agent/skills/slidev/references/core-syntax.md +155 -0
- package/agent/skills/slidev/references/diagram-latex.md +55 -0
- package/agent/skills/slidev/references/diagram-mermaid.md +44 -0
- package/agent/skills/slidev/references/diagram-plantuml.md +45 -0
- package/agent/skills/slidev/references/editor-monaco-run.md +44 -0
- package/agent/skills/slidev/references/editor-monaco-write.md +24 -0
- package/agent/skills/slidev/references/editor-monaco.md +50 -0
- package/agent/skills/slidev/references/editor-prettier.md +40 -0
- package/agent/skills/slidev/references/editor-side.md +23 -0
- package/agent/skills/slidev/references/editor-vscode.md +55 -0
- package/agent/skills/slidev/references/layout-canvas-size.md +25 -0
- package/agent/skills/slidev/references/layout-draggable.md +57 -0
- package/agent/skills/slidev/references/layout-global-layers.md +50 -0
- package/agent/skills/slidev/references/layout-slots.md +75 -0
- package/agent/skills/slidev/references/layout-transform.md +33 -0
- package/agent/skills/slidev/references/layout-zoom.md +39 -0
- package/agent/skills/slidev/references/presenter-notes-ruby.md +35 -0
- package/agent/skills/slidev/references/presenter-recording.md +30 -0
- package/agent/skills/slidev/references/presenter-remote.md +40 -0
- package/agent/skills/slidev/references/presenter-timer.md +34 -0
- package/agent/skills/slidev/references/style-direction.md +34 -0
- package/agent/skills/slidev/references/style-icons.md +46 -0
- package/agent/skills/slidev/references/style-scoped.md +50 -0
- package/agent/skills/slidev/references/syntax-block-frontmatter.md +39 -0
- package/agent/skills/slidev/references/syntax-frontmatter-merging.md +49 -0
- package/agent/skills/slidev/references/syntax-importing-slides.md +60 -0
- package/agent/skills/slidev/references/syntax-mdc.md +51 -0
- package/agent/skills/slidev/references/tool-eject-theme.md +27 -0
- package/agent/tools/export-tool.ts +216 -0
- package/agent/tools/review-tool.ts +136 -0
- package/app/index.ts +124 -0
- package/components/MessageItem.vue +231 -0
- package/components/SlidevAgentNavButton.vue +48 -0
- package/components/SlidevAgentSidebar.vue +766 -0
- package/components/SubagentCard.vue +184 -0
- package/components/TypingDots.vue +62 -0
- package/dist/agent/constants.js +117 -0
- package/dist/agent/deck-context.js +47 -0
- package/dist/agent/index.js +167 -0
- package/dist/agent/middleware.js +134 -0
- package/dist/agent/slide-preview-tool.js +257 -0
- package/dist/agent/tools/export-tool.js +167 -0
- package/dist/agent/tools/review-tool.js +111 -0
- package/dist/app/index.js +101 -0
- package/dist/bin/slidev-agent.js +155 -0
- package/dist/lib/bridge.js +151 -0
- package/dist/lib/env.js +17 -0
- package/dist/lib/headless-tools.js +10 -0
- package/dist/lib/langgraph-init.js +59 -0
- package/dist/lib/review-tool.js +98 -0
- package/lib/bridge.ts +212 -0
- package/lib/config.ts +79 -0
- package/lib/env.ts +38 -0
- package/lib/headless-tool-impl.ts +26 -0
- package/lib/headless-tools.ts +11 -0
- package/lib/langgraph-init.ts +79 -0
- package/lib/messages.ts +169 -0
- package/lib/render-chat-markdown.ts +19 -0
- package/lib/sidebar.ts +573 -0
- package/lib/state.ts +44 -0
- package/package.json +65 -0
- package/public/deepagents.svg +12 -0
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { createMiddleware, ToolMessage } from "langchain";
|
|
3
|
+
const FILESYSTEM_TOOL_NAMES = new Set([
|
|
4
|
+
"ls",
|
|
5
|
+
"read_file",
|
|
6
|
+
"write_file",
|
|
7
|
+
"edit_file",
|
|
8
|
+
"glob",
|
|
9
|
+
"grep",
|
|
10
|
+
]);
|
|
11
|
+
const SINGLE_PATH_ARG_KEYS = ["path", "file_path"];
|
|
12
|
+
const ARRAY_PATH_ARG_KEYS = ["paths"];
|
|
13
|
+
const HOST_ABSOLUTE_PREFIXES = [
|
|
14
|
+
"/Users/",
|
|
15
|
+
"/private/",
|
|
16
|
+
"/var/",
|
|
17
|
+
"/tmp/",
|
|
18
|
+
"/etc/",
|
|
19
|
+
"/opt/",
|
|
20
|
+
"/Volumes/",
|
|
21
|
+
"/home/",
|
|
22
|
+
];
|
|
23
|
+
const HOST_RELATIVE_PREFIXES = HOST_ABSOLUTE_PREFIXES.map(prefix => prefix.slice(1));
|
|
24
|
+
function isWithinRoot(rootDir, candidatePath) {
|
|
25
|
+
const relativePath = path.relative(rootDir, candidatePath);
|
|
26
|
+
return relativePath === "" || (!relativePath.startsWith("..") && !path.isAbsolute(relativePath));
|
|
27
|
+
}
|
|
28
|
+
function toProjectRelativePath(rootDir, candidatePath) {
|
|
29
|
+
const relativePath = path.relative(rootDir, candidatePath).replaceAll(path.sep, "/");
|
|
30
|
+
return relativePath ? `/${relativePath}` : "/";
|
|
31
|
+
}
|
|
32
|
+
function looksLikeHostAbsolutePath(value) {
|
|
33
|
+
return HOST_ABSOLUTE_PREFIXES.some(prefix => value.startsWith(prefix));
|
|
34
|
+
}
|
|
35
|
+
function looksLikeMissingLeadingSlashHostPath(value) {
|
|
36
|
+
return HOST_RELATIVE_PREFIXES.some(prefix => value.startsWith(prefix));
|
|
37
|
+
}
|
|
38
|
+
function normalizeFilesystemPath(rootDir, rawValue) {
|
|
39
|
+
const value = rawValue.trim();
|
|
40
|
+
if (!value)
|
|
41
|
+
return { normalized: rawValue, error: null };
|
|
42
|
+
if (looksLikeHostAbsolutePath(value)) {
|
|
43
|
+
const candidatePath = path.resolve(value);
|
|
44
|
+
if (isWithinRoot(rootDir, candidatePath)) {
|
|
45
|
+
return { normalized: toProjectRelativePath(rootDir, candidatePath), error: null };
|
|
46
|
+
}
|
|
47
|
+
return {
|
|
48
|
+
normalized: rawValue,
|
|
49
|
+
error: [
|
|
50
|
+
`Filesystem tools are rooted at the agent project, so host absolute paths are not allowed here: ${value}`,
|
|
51
|
+
"Use a project-root-relative path such as `/slides.md`, `/pages/foo.md`, or `/example/slides.md` instead.",
|
|
52
|
+
].join("\n"),
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
if (looksLikeMissingLeadingSlashHostPath(value)) {
|
|
56
|
+
const candidatePath = path.resolve(path.sep, value);
|
|
57
|
+
if (isWithinRoot(rootDir, candidatePath))
|
|
58
|
+
return { normalized: toProjectRelativePath(rootDir, candidatePath), error: null };
|
|
59
|
+
return {
|
|
60
|
+
normalized: rawValue,
|
|
61
|
+
error: [
|
|
62
|
+
`Filesystem tools received a host-style path instead of a project-relative path: ${value}`,
|
|
63
|
+
"Use a project-root-relative path such as `/slides.md`, `/pages/foo.md`, or `/example/slides.md` instead.",
|
|
64
|
+
].join("\n"),
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
return { normalized: rawValue, error: null };
|
|
68
|
+
}
|
|
69
|
+
function toolErrorMessage(toolCallId, content) {
|
|
70
|
+
return new ToolMessage({
|
|
71
|
+
content,
|
|
72
|
+
tool_call_id: toolCallId || "",
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
export function createFilesystemPathGuardMiddleware(rootDir) {
|
|
76
|
+
const absoluteRootDir = path.resolve(rootDir);
|
|
77
|
+
return createMiddleware({
|
|
78
|
+
name: "FilesystemPathGuardMiddleware",
|
|
79
|
+
wrapToolCall: async (request, handler) => {
|
|
80
|
+
const { toolCall } = request;
|
|
81
|
+
const { args: rawArgs, id: toolCallId, name: toolName } = toolCall;
|
|
82
|
+
if (!FILESYSTEM_TOOL_NAMES.has(toolName)) {
|
|
83
|
+
return handler(request);
|
|
84
|
+
}
|
|
85
|
+
if (!rawArgs || typeof rawArgs !== "object" || Array.isArray(rawArgs)) {
|
|
86
|
+
return handler(request);
|
|
87
|
+
}
|
|
88
|
+
let changed = false;
|
|
89
|
+
const nextArgs = { ...rawArgs };
|
|
90
|
+
for (const key of SINGLE_PATH_ARG_KEYS) {
|
|
91
|
+
const value = nextArgs[key];
|
|
92
|
+
if (typeof value !== "string")
|
|
93
|
+
continue;
|
|
94
|
+
const { normalized, error } = normalizeFilesystemPath(absoluteRootDir, value);
|
|
95
|
+
if (error) {
|
|
96
|
+
return toolErrorMessage(toolCallId, error);
|
|
97
|
+
}
|
|
98
|
+
if (normalized !== value) {
|
|
99
|
+
nextArgs[key] = normalized;
|
|
100
|
+
changed = true;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
for (const key of ARRAY_PATH_ARG_KEYS) {
|
|
104
|
+
const value = nextArgs[key];
|
|
105
|
+
if (!Array.isArray(value))
|
|
106
|
+
continue;
|
|
107
|
+
const normalizedPaths = [];
|
|
108
|
+
for (const entry of value) {
|
|
109
|
+
if (typeof entry !== "string") {
|
|
110
|
+
normalizedPaths.push(entry);
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
const { normalized, error } = normalizeFilesystemPath(absoluteRootDir, entry);
|
|
114
|
+
if (error) {
|
|
115
|
+
return toolErrorMessage(toolCallId, error);
|
|
116
|
+
}
|
|
117
|
+
normalizedPaths.push(normalized);
|
|
118
|
+
if (normalized !== entry)
|
|
119
|
+
changed = true;
|
|
120
|
+
}
|
|
121
|
+
nextArgs[key] = normalizedPaths;
|
|
122
|
+
}
|
|
123
|
+
if (!changed)
|
|
124
|
+
return handler(request);
|
|
125
|
+
return handler({
|
|
126
|
+
...request,
|
|
127
|
+
toolCall: {
|
|
128
|
+
...toolCall,
|
|
129
|
+
args: nextArgs,
|
|
130
|
+
},
|
|
131
|
+
});
|
|
132
|
+
},
|
|
133
|
+
});
|
|
134
|
+
}
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { createRequire } from "node:module";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { tool } from "langchain";
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
function env(name, fallback = "") {
|
|
7
|
+
const value = process.env[name];
|
|
8
|
+
return typeof value === "string" && value.trim() ? value.trim() : fallback;
|
|
9
|
+
}
|
|
10
|
+
function normalizeOrigin(raw) {
|
|
11
|
+
return raw.replace(/\/$/, "");
|
|
12
|
+
}
|
|
13
|
+
const SLIDE_OVERFLOW_EVAL_SCRIPT = `(() => {
|
|
14
|
+
const root = document.querySelector("#slide-content") || document.querySelector(".slidev-slide-content")
|
|
15
|
+
if (!root || !(root instanceof HTMLElement))
|
|
16
|
+
return { ok: false, error: "no_slide_content" }
|
|
17
|
+
|
|
18
|
+
const tolerance = 3
|
|
19
|
+
const rootRect = root.getBoundingClientRect()
|
|
20
|
+
|
|
21
|
+
// Scrollable overflow (layout) — complements geometry when descendants use transforms.
|
|
22
|
+
const scrollOverflowX = root.scrollWidth > root.clientWidth + tolerance
|
|
23
|
+
const scrollOverflowY = root.scrollHeight > root.clientHeight + tolerance
|
|
24
|
+
|
|
25
|
+
let maxRight = rootRect.left
|
|
26
|
+
let maxBottom = rootRect.top
|
|
27
|
+
let minLeft = rootRect.right
|
|
28
|
+
let minTop = rootRect.bottom
|
|
29
|
+
|
|
30
|
+
const walk = (node) => {
|
|
31
|
+
if (node instanceof HTMLElement) {
|
|
32
|
+
const style = window.getComputedStyle(node)
|
|
33
|
+
if (style.visibility === "hidden" || style.display === "none")
|
|
34
|
+
return
|
|
35
|
+
const rect = node.getBoundingClientRect()
|
|
36
|
+
if (rect.width < 1 && rect.height < 1)
|
|
37
|
+
return
|
|
38
|
+
maxRight = Math.max(maxRight, rect.right)
|
|
39
|
+
maxBottom = Math.max(maxBottom, rect.bottom)
|
|
40
|
+
minLeft = Math.min(minLeft, rect.left)
|
|
41
|
+
minTop = Math.min(minTop, rect.top)
|
|
42
|
+
for (const c of node.children)
|
|
43
|
+
walk(c)
|
|
44
|
+
if (node.shadowRoot)
|
|
45
|
+
walk(node.shadowRoot)
|
|
46
|
+
}
|
|
47
|
+
else if (node instanceof ShadowRoot) {
|
|
48
|
+
for (const c of node.children)
|
|
49
|
+
walk(c)
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
walk(root)
|
|
54
|
+
|
|
55
|
+
const horizontalGeom = maxRight > rootRect.right + tolerance || minLeft < rootRect.left - tolerance
|
|
56
|
+
const verticalGeom = maxBottom > rootRect.bottom + tolerance || minTop < rootRect.top - tolerance
|
|
57
|
+
const horizontalOverflow = scrollOverflowX || horizontalGeom
|
|
58
|
+
const verticalOverflow = scrollOverflowY || verticalGeom
|
|
59
|
+
|
|
60
|
+
return {
|
|
61
|
+
ok: true,
|
|
62
|
+
overflow: { horizontal: horizontalOverflow, vertical: verticalOverflow },
|
|
63
|
+
scrollOverflow: { horizontal: scrollOverflowX, vertical: scrollOverflowY },
|
|
64
|
+
geometryOverflow: { horizontal: horizontalGeom, vertical: verticalGeom },
|
|
65
|
+
scrollMetrics: {
|
|
66
|
+
scrollWidth: root.scrollWidth,
|
|
67
|
+
clientWidth: root.clientWidth,
|
|
68
|
+
scrollHeight: root.scrollHeight,
|
|
69
|
+
clientHeight: root.clientHeight,
|
|
70
|
+
},
|
|
71
|
+
slideRect: {
|
|
72
|
+
left: rootRect.left,
|
|
73
|
+
top: rootRect.top,
|
|
74
|
+
right: rootRect.right,
|
|
75
|
+
bottom: rootRect.bottom,
|
|
76
|
+
width: rootRect.width,
|
|
77
|
+
height: rootRect.height,
|
|
78
|
+
},
|
|
79
|
+
contentBounds: { minLeft, minTop, maxRight, maxBottom },
|
|
80
|
+
viewport: { width: window.innerWidth, height: window.innerHeight },
|
|
81
|
+
}
|
|
82
|
+
})()`;
|
|
83
|
+
function getAgentBrowserJsPath() {
|
|
84
|
+
const custom = env("SLIDEV_AGENT_BROWSER_PATH");
|
|
85
|
+
if (custom)
|
|
86
|
+
return custom;
|
|
87
|
+
try {
|
|
88
|
+
const require = createRequire(import.meta.url);
|
|
89
|
+
const pkgDir = path.dirname(require.resolve("agent-browser/package.json"));
|
|
90
|
+
return path.join(pkgDir, "bin", "agent-browser.js");
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
function parseEvalResultStdout(stdout) {
|
|
97
|
+
const trimmed = stdout.trim();
|
|
98
|
+
if (!trimmed)
|
|
99
|
+
throw new Error("empty eval output");
|
|
100
|
+
const parsed = JSON.parse(trimmed);
|
|
101
|
+
if (parsed.success === false)
|
|
102
|
+
throw new Error(parsed.error || "agent-browser eval failed");
|
|
103
|
+
if (parsed.data && typeof parsed.data === "object" && "result" in parsed.data)
|
|
104
|
+
return parsed.data.result;
|
|
105
|
+
return parsed.data;
|
|
106
|
+
}
|
|
107
|
+
function runAgentBrowser(args, stdin) {
|
|
108
|
+
const jsPath = getAgentBrowserJsPath();
|
|
109
|
+
if (!jsPath) {
|
|
110
|
+
return {
|
|
111
|
+
ok: false,
|
|
112
|
+
stdout: "",
|
|
113
|
+
stderr: "agent-browser is not installed. Add it to the project (e.g. pnpm add -D agent-browser) and run `agent-browser install` once to fetch Chrome for Testing, or install the agent-browser CLI globally.",
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
const r = spawnSync(process.execPath, [jsPath, ...args], {
|
|
117
|
+
encoding: "utf8",
|
|
118
|
+
maxBuffer: 12 * 1024 * 1024,
|
|
119
|
+
input: stdin,
|
|
120
|
+
env: process.env,
|
|
121
|
+
});
|
|
122
|
+
const stdout = r.stdout ?? "";
|
|
123
|
+
const stderr = r.stderr ?? "";
|
|
124
|
+
const ok = r.status === 0 && !r.error;
|
|
125
|
+
if (r.error)
|
|
126
|
+
return { ok: false, stdout, stderr: `${r.error.message}\n${stderr}` };
|
|
127
|
+
return { ok, stdout, stderr };
|
|
128
|
+
}
|
|
129
|
+
export function createInspectSlidePreviewTool() {
|
|
130
|
+
return tool(async (input) => {
|
|
131
|
+
const slideNumber = Math.floor(input.slideNumber);
|
|
132
|
+
if (!Number.isFinite(slideNumber) || slideNumber < 1) {
|
|
133
|
+
return JSON.stringify({
|
|
134
|
+
ok: false,
|
|
135
|
+
error: "slideNumber must be a positive integer (Slidev route index, e.g. 1 for /1).",
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
const origin = normalizeOrigin(input.origin || env("SLIDEV_AGENT_PREVIEW_ORIGIN", "http://127.0.0.1:3030"));
|
|
139
|
+
const url = `${origin}/${slideNumber}`;
|
|
140
|
+
const session = env("SLIDEV_AGENT_BROWSER_SESSION", "slidev-agent-preview");
|
|
141
|
+
const sessionArgs = ["--json", "--session", session];
|
|
142
|
+
const viewport = runAgentBrowser([...sessionArgs, "set", "viewport", "1920", "1080"]);
|
|
143
|
+
if (!viewport.ok)
|
|
144
|
+
return formatToolError(url, viewport, "set viewport");
|
|
145
|
+
const opened = runAgentBrowser([...sessionArgs, "open", url]);
|
|
146
|
+
if (!opened.ok) {
|
|
147
|
+
runAgentBrowser([...sessionArgs, "close"]);
|
|
148
|
+
return JSON.stringify({
|
|
149
|
+
ok: false,
|
|
150
|
+
error: `Could not open ${url}. Is the Slidev dev server running and SLIDEV_AGENT_PREVIEW_ORIGIN correct?`,
|
|
151
|
+
detail: combineOut(opened),
|
|
152
|
+
url,
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
const load = runAgentBrowser([...sessionArgs, "wait", "--load", "domcontentloaded"]);
|
|
156
|
+
if (!load.ok)
|
|
157
|
+
void load;
|
|
158
|
+
const settle = runAgentBrowser([...sessionArgs, "wait", "600"]);
|
|
159
|
+
if (!settle.ok)
|
|
160
|
+
void settle;
|
|
161
|
+
let dom;
|
|
162
|
+
try {
|
|
163
|
+
const ev = runAgentBrowser([...sessionArgs, "eval", "--stdin"], SLIDE_OVERFLOW_EVAL_SCRIPT);
|
|
164
|
+
if (!ev.ok) {
|
|
165
|
+
runAgentBrowser([...sessionArgs, "close"]);
|
|
166
|
+
return JSON.stringify({
|
|
167
|
+
ok: false,
|
|
168
|
+
error: "eval failed while measuring slide layout.",
|
|
169
|
+
detail: combineOut(ev),
|
|
170
|
+
url,
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
dom = parseEvalPayload(ev.stdout);
|
|
174
|
+
}
|
|
175
|
+
catch (e) {
|
|
176
|
+
runAgentBrowser([...sessionArgs, "close"]);
|
|
177
|
+
const message = e instanceof Error ? e.message : String(e);
|
|
178
|
+
return JSON.stringify({
|
|
179
|
+
ok: false,
|
|
180
|
+
error: `Could not parse slide inspection result: ${message}`,
|
|
181
|
+
url,
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
runAgentBrowser([...sessionArgs, "close"]);
|
|
185
|
+
if (dom.ok === false || dom.error) {
|
|
186
|
+
return JSON.stringify({
|
|
187
|
+
ok: false,
|
|
188
|
+
error: `Loaded ${url} but could not find slide content (#slide-content).`,
|
|
189
|
+
url,
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
const overflow = dom.overflow;
|
|
193
|
+
const horizontal = Boolean(overflow?.horizontal);
|
|
194
|
+
const vertical = Boolean(overflow?.vertical);
|
|
195
|
+
const hasOverflow = horizontal || vertical;
|
|
196
|
+
const scrollOverflow = dom.scrollOverflow;
|
|
197
|
+
const geometryOverflow = dom.geometryOverflow;
|
|
198
|
+
return JSON.stringify({
|
|
199
|
+
ok: true,
|
|
200
|
+
url,
|
|
201
|
+
engine: "agent-browser",
|
|
202
|
+
overflow: { horizontal, vertical },
|
|
203
|
+
scrollOverflow: {
|
|
204
|
+
horizontal: Boolean(scrollOverflow?.horizontal),
|
|
205
|
+
vertical: Boolean(scrollOverflow?.vertical),
|
|
206
|
+
},
|
|
207
|
+
geometryOverflow: {
|
|
208
|
+
horizontal: Boolean(geometryOverflow?.horizontal),
|
|
209
|
+
vertical: Boolean(geometryOverflow?.vertical),
|
|
210
|
+
},
|
|
211
|
+
hasOverflow,
|
|
212
|
+
slideRect: dom.slideRect,
|
|
213
|
+
contentBounds: dom.contentBounds,
|
|
214
|
+
scrollMetrics: dom.scrollMetrics,
|
|
215
|
+
viewport: dom.viewport,
|
|
216
|
+
hint: hasOverflow
|
|
217
|
+
? "Content appears clipped or extending past the slide frame. Remove or shrink elements, split into another slide, simplify diagrams, or reduce columns/font sizes; then re-run this check."
|
|
218
|
+
: "No obvious overflow detected against the slide content box (scroll size + descendant geometry within #slide-content). Content teleported outside this node cannot be measured here.",
|
|
219
|
+
});
|
|
220
|
+
}, {
|
|
221
|
+
name: "inspect_slide_preview",
|
|
222
|
+
description: [
|
|
223
|
+
"Verify how a Slidev slide renders in the running dev server before finishing slide work.",
|
|
224
|
+
"Uses the agent-browser CLI (Vercel) to open the slide in Chrome and measure overflow inside `#slide-content` (scroll dimensions + bounding boxes of descendants, including open shadow roots). Does not capture a PNG screenshot.",
|
|
225
|
+
"Requires agent-browser on PATH or as a dependency, a one-time `agent-browser install` for Chrome for Testing, a running Slidev preview (default origin http://127.0.0.1:3030), and optional env SLIDEV_AGENT_PREVIEW_ORIGIN.",
|
|
226
|
+
"Optional: SLIDEV_AGENT_BROWSER_PATH (path to agent-browser.js), SLIDEV_AGENT_BROWSER_SESSION (isolated session name).",
|
|
227
|
+
"Pass the 1-based slide index as shown in the app URL (e.g. slide 3 → slideNumber: 3).",
|
|
228
|
+
"If the new slide is not imported into the deck yet, skip this tool and say so; the orchestrator can import it and re-delegate so you can re-run this check.",
|
|
229
|
+
"Call this after substantive slide edits when the slide index is known; do not skip only because the layout looks correct in the file.",
|
|
230
|
+
"Successful responses include `url` (full page URL for that slide); surface that URL as a markdown link in your final message so the user can open the slide in one click.",
|
|
231
|
+
].join(" "),
|
|
232
|
+
schema: z.object({
|
|
233
|
+
slideNumber: z.number().describe("1-based Slidev slide number in the URL path (e.g. 5 for /5)."),
|
|
234
|
+
origin: z.string().optional().describe("Preview server origin, e.g. http://127.0.0.1:3030. Defaults to SLIDEV_AGENT_PREVIEW_ORIGIN or http://127.0.0.1:3030."),
|
|
235
|
+
}),
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
function combineOut(r) {
|
|
239
|
+
return [r.stderr, r.stdout].filter(Boolean).join("\n").trim();
|
|
240
|
+
}
|
|
241
|
+
function formatToolError(url, r, step) {
|
|
242
|
+
return JSON.stringify({
|
|
243
|
+
ok: false,
|
|
244
|
+
skipped: true,
|
|
245
|
+
error: `agent-browser failed during ${step}. Install: https://github.com/vercel-labs/agent-browser — add the npm package or global CLI, run \`agent-browser install\` once, then retry.`,
|
|
246
|
+
detail: combineOut(r),
|
|
247
|
+
url,
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
function parseEvalPayload(stdout) {
|
|
251
|
+
const raw = parseEvalResultStdout(stdout);
|
|
252
|
+
if (raw && typeof raw === "object" && !Array.isArray(raw))
|
|
253
|
+
return raw;
|
|
254
|
+
if (typeof raw === "string")
|
|
255
|
+
return JSON.parse(raw);
|
|
256
|
+
throw new Error("unexpected eval payload shape");
|
|
257
|
+
}
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { mkdir, readFile, readdir, realpath } from "node:fs/promises";
|
|
3
|
+
import { spawn } from "node:child_process";
|
|
4
|
+
import { createRequire } from "node:module";
|
|
5
|
+
import { pathToFileURL } from "node:url";
|
|
6
|
+
import { tool } from "langchain";
|
|
7
|
+
import { z } from "zod";
|
|
8
|
+
import { resolveDeckExecutionContext } from "../deck-context.js";
|
|
9
|
+
const slidevExportScreenshotSchema = z.object({
|
|
10
|
+
slideIndex: z.number().int().positive().describe("1-based slide index to export as PNG."),
|
|
11
|
+
outputDir: z.string().optional().describe("Optional output directory. Prefer omitting this unless you need a custom project-local path."),
|
|
12
|
+
timeout: z.number().int().positive().optional().describe("Optional Slidev export timeout in milliseconds. Defaults to 60000."),
|
|
13
|
+
wait: z.number().int().nonnegative().optional().describe("Optional Slidev export wait in milliseconds. Defaults to 500."),
|
|
14
|
+
});
|
|
15
|
+
function isWithinRoot(rootDir, candidatePath) {
|
|
16
|
+
return candidatePath === rootDir || candidatePath.startsWith(`${rootDir}${path.sep}`);
|
|
17
|
+
}
|
|
18
|
+
async function resolveOutputDir(rootDir, deckHostDir, slideIndex, outputDir) {
|
|
19
|
+
const fallbackOutputDir = path.join(deckHostDir, ".slidev-agent-artifacts", `verify-${slideIndex}`);
|
|
20
|
+
const requestedOutputDir = outputDir?.trim() || fallbackOutputDir;
|
|
21
|
+
const resolvedOutputDir = path.isAbsolute(requestedOutputDir)
|
|
22
|
+
? path.resolve(requestedOutputDir)
|
|
23
|
+
: path.resolve(deckHostDir, requestedOutputDir);
|
|
24
|
+
const [realRoot, realOutputDirParent] = await Promise.all([
|
|
25
|
+
realpath(rootDir).catch(() => rootDir),
|
|
26
|
+
realpath(path.dirname(resolvedOutputDir)).catch(() => path.dirname(resolvedOutputDir)),
|
|
27
|
+
]);
|
|
28
|
+
if (!isWithinRoot(realRoot, realOutputDirParent) && !isWithinRoot(rootDir, resolvedOutputDir)) {
|
|
29
|
+
throw new Error(`slidev_export_screenshot outputDir must stay within the project root (${rootDir}).`);
|
|
30
|
+
}
|
|
31
|
+
return resolvedOutputDir;
|
|
32
|
+
}
|
|
33
|
+
function getPnpmCommand() {
|
|
34
|
+
return process.platform === "win32" ? "pnpm.cmd" : "pnpm";
|
|
35
|
+
}
|
|
36
|
+
const ANSI_ESCAPE_PATTERN = /\u001B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])/g;
|
|
37
|
+
async function resolveDeckSlideCount(deckHostDir, entryPath) {
|
|
38
|
+
const requireFromDeck = createRequire(path.join(deckHostDir, "package.json"));
|
|
39
|
+
const slidevCliPackageJsonPath = requireFromDeck.resolve("@slidev/cli/package.json");
|
|
40
|
+
const slidevCliPackageJson = JSON.parse(await readFile(slidevCliPackageJsonPath, "utf8"));
|
|
41
|
+
const slidevCliEntry = path.resolve(path.dirname(slidevCliPackageJsonPath), slidevCliPackageJson.module || slidevCliPackageJson.main || "dist/index.mjs");
|
|
42
|
+
const slidevCli = await import(pathToFileURL(slidevCliEntry).href);
|
|
43
|
+
const resolved = await slidevCli.resolveOptions?.({ entry: entryPath }, "export");
|
|
44
|
+
return Array.isArray(resolved?.data?.slides) ? resolved.data.slides.length : null;
|
|
45
|
+
}
|
|
46
|
+
function stripAnsi(output) {
|
|
47
|
+
return output.replace(ANSI_ESCAPE_PATTERN, "");
|
|
48
|
+
}
|
|
49
|
+
function extractLikelyBuildError(output) {
|
|
50
|
+
const cleanedOutput = stripAnsi(output);
|
|
51
|
+
const lines = cleanedOutput.split(/\r?\n/);
|
|
52
|
+
const errorStartIndex = lines.findIndex(line => /\[vite\].*(Pre-transform error|Internal server error)/.test(line)
|
|
53
|
+
|| /Plugin:\s+vite:vue/.test(line)
|
|
54
|
+
|| /Failed to resolve import/i.test(line)
|
|
55
|
+
|| /Invalid end tag\./.test(line));
|
|
56
|
+
if (errorStartIndex === -1)
|
|
57
|
+
return null;
|
|
58
|
+
const errorLines = [];
|
|
59
|
+
for (let index = errorStartIndex; index < lines.length; index += 1) {
|
|
60
|
+
const line = lines[index];
|
|
61
|
+
if (index > errorStartIndex && !line.trim())
|
|
62
|
+
break;
|
|
63
|
+
if (/^\s*✓\s+exported to\b/.test(line))
|
|
64
|
+
break;
|
|
65
|
+
errorLines.push(line);
|
|
66
|
+
if (errorLines.length >= 30)
|
|
67
|
+
break;
|
|
68
|
+
}
|
|
69
|
+
return errorLines.join("\n").trim() || null;
|
|
70
|
+
}
|
|
71
|
+
function runCommand(cwd, args) {
|
|
72
|
+
return new Promise((resolve, reject) => {
|
|
73
|
+
const child = spawn(getPnpmCommand(), args, {
|
|
74
|
+
cwd,
|
|
75
|
+
env: process.env,
|
|
76
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
77
|
+
});
|
|
78
|
+
let output = "";
|
|
79
|
+
child.stdout.on("data", (chunk) => {
|
|
80
|
+
output += chunk.toString();
|
|
81
|
+
});
|
|
82
|
+
child.stderr.on("data", (chunk) => {
|
|
83
|
+
output += chunk.toString();
|
|
84
|
+
});
|
|
85
|
+
child.on("error", reject);
|
|
86
|
+
child.on("close", (exitCode) => {
|
|
87
|
+
resolve({
|
|
88
|
+
exitCode: exitCode ?? 1,
|
|
89
|
+
output,
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
export function createSlidevExportScreenshotTool(rootDir) {
|
|
95
|
+
return tool(async ({ slideIndex, outputDir, timeout, wait }) => {
|
|
96
|
+
const deckContext = resolveDeckExecutionContext(path.resolve(rootDir));
|
|
97
|
+
const resolvedOutputDir = await resolveOutputDir(deckContext.rootDir, deckContext.deckHostDir, slideIndex, outputDir);
|
|
98
|
+
const totalSlides = await resolveDeckSlideCount(deckContext.deckHostDir, deckContext.entryPath).catch(() => null);
|
|
99
|
+
if (totalSlides !== null && slideIndex > totalSlides) {
|
|
100
|
+
throw new Error([
|
|
101
|
+
`slidev_export_screenshot requested slide ${slideIndex}, but the resolved deck only has ${totalSlides} slides.`,
|
|
102
|
+
`Resolved deck entry: ${path.relative(deckContext.rootDir, deckContext.entryPath) || path.basename(deckContext.entryPath)}`,
|
|
103
|
+
"This usually means the slide is not imported into the final deck yet or the 1-based slide index was counted incorrectly.",
|
|
104
|
+
].join("\n"));
|
|
105
|
+
}
|
|
106
|
+
await mkdir(resolvedOutputDir, { recursive: true });
|
|
107
|
+
const args = [
|
|
108
|
+
"exec",
|
|
109
|
+
"slidev-agent",
|
|
110
|
+
"export",
|
|
111
|
+
"--format",
|
|
112
|
+
"png",
|
|
113
|
+
"--range",
|
|
114
|
+
String(slideIndex),
|
|
115
|
+
"--per-slide",
|
|
116
|
+
"--output",
|
|
117
|
+
resolvedOutputDir,
|
|
118
|
+
"--timeout",
|
|
119
|
+
String(timeout ?? 60000),
|
|
120
|
+
"--wait",
|
|
121
|
+
String(wait ?? 500),
|
|
122
|
+
"--scale",
|
|
123
|
+
"1",
|
|
124
|
+
];
|
|
125
|
+
const result = await runCommand(deckContext.deckHostDir, args);
|
|
126
|
+
if (result.exitCode !== 0) {
|
|
127
|
+
throw new Error([
|
|
128
|
+
`slidev_export_screenshot failed with exit code ${result.exitCode}.`,
|
|
129
|
+
`Deck directory: ${deckContext.deckHostDir}`,
|
|
130
|
+
`Command: ${getPnpmCommand()} ${args.join(" ")}`,
|
|
131
|
+
result.output.trim(),
|
|
132
|
+
].filter(Boolean).join("\n"));
|
|
133
|
+
}
|
|
134
|
+
const imageFiles = (await readdir(resolvedOutputDir))
|
|
135
|
+
.filter(fileName => fileName.toLowerCase().endsWith(".png"))
|
|
136
|
+
.sort((a, b) => a.localeCompare(b));
|
|
137
|
+
if (imageFiles.length === 0) {
|
|
138
|
+
const buildError = extractLikelyBuildError(result.output);
|
|
139
|
+
if (buildError) {
|
|
140
|
+
throw new Error([
|
|
141
|
+
"slidev_export_screenshot hit a Slidev/Vite build error and produced no PNG files.",
|
|
142
|
+
`Deck directory: ${deckContext.deckHostDir}`,
|
|
143
|
+
`Command: ${getPnpmCommand()} ${args.join(" ")}`,
|
|
144
|
+
buildError,
|
|
145
|
+
].join("\n"));
|
|
146
|
+
}
|
|
147
|
+
throw new Error([
|
|
148
|
+
`slidev_export_screenshot succeeded but produced no PNG files in ${resolvedOutputDir}.`,
|
|
149
|
+
totalSlides !== null ? `Resolved deck slide count: ${totalSlides}. Requested slide: ${slideIndex}.` : "",
|
|
150
|
+
"This usually means the requested 1-based slide index does not exist in the final rendered deck yet.",
|
|
151
|
+
].filter(Boolean).join("\n"));
|
|
152
|
+
}
|
|
153
|
+
const imagePaths = imageFiles.map(fileName => path.join(resolvedOutputDir, fileName));
|
|
154
|
+
return {
|
|
155
|
+
slideIndex,
|
|
156
|
+
deckDir: deckContext.deckHostDir,
|
|
157
|
+
outputDir: resolvedOutputDir,
|
|
158
|
+
imagePaths,
|
|
159
|
+
primaryImagePath: imagePaths[0],
|
|
160
|
+
command: `${getPnpmCommand()} ${args.join(" ")}`,
|
|
161
|
+
};
|
|
162
|
+
}, {
|
|
163
|
+
name: "slidev_export_screenshot",
|
|
164
|
+
description: "Export one Slidev slide to PNG from the correct deck directory and return the generated image path.",
|
|
165
|
+
schema: slidevExportScreenshotSchema,
|
|
166
|
+
});
|
|
167
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { fileURLToPath } from "node:url";
|
|
3
|
+
import { readFile, realpath, stat } from "node:fs/promises";
|
|
4
|
+
import { initChatModel, tool } from "langchain";
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
import { model } from "../../lib/env.js";
|
|
7
|
+
const slidevReviewScreenshotSchema = z.object({
|
|
8
|
+
imagePath: z.string().min(1).describe("Path to the exported PNG screenshot file. Use a path inside the project root, for example `.slidev-agent-artifacts/verify-5/1.png`."),
|
|
9
|
+
slideIndex: z.number().int().positive().optional().describe("Optional 1-based slide index represented by the screenshot."),
|
|
10
|
+
focus: z.string().optional().describe("Optional short note about what to scrutinize most closely."),
|
|
11
|
+
});
|
|
12
|
+
const slidevReviewScreenshotResultSchema = z.object({
|
|
13
|
+
pass: z.boolean().describe("Whether the slide looks presentation-ready in the screenshot."),
|
|
14
|
+
summary: z.string().describe("Short visual verdict."),
|
|
15
|
+
issues: z.array(z.string()).describe("Concrete visual problems found in the screenshot."),
|
|
16
|
+
suggestions: z.array(z.string()).describe("Targeted improvements to address the issues."),
|
|
17
|
+
});
|
|
18
|
+
function isWithinRoot(rootDir, candidatePath) {
|
|
19
|
+
return candidatePath === rootDir || candidatePath.startsWith(`${rootDir}${path.sep}`);
|
|
20
|
+
}
|
|
21
|
+
function normalizeImagePathInput(imagePath) {
|
|
22
|
+
const trimmed = imagePath.trim();
|
|
23
|
+
if (!trimmed)
|
|
24
|
+
return trimmed;
|
|
25
|
+
if (trimmed.startsWith("file://"))
|
|
26
|
+
return fileURLToPath(trimmed);
|
|
27
|
+
return trimmed;
|
|
28
|
+
}
|
|
29
|
+
async function resolveImagePath(rootDir, imagePath) {
|
|
30
|
+
const absoluteRoot = path.resolve(rootDir);
|
|
31
|
+
const normalizedImagePath = normalizeImagePathInput(imagePath);
|
|
32
|
+
const resolved = path.isAbsolute(normalizedImagePath)
|
|
33
|
+
? path.resolve(normalizedImagePath)
|
|
34
|
+
: path.resolve(absoluteRoot, normalizedImagePath);
|
|
35
|
+
if (resolved === absoluteRoot)
|
|
36
|
+
throw new Error("Screenshot path must point to an image file, not the project root.");
|
|
37
|
+
if (isWithinRoot(absoluteRoot, resolved))
|
|
38
|
+
return resolved;
|
|
39
|
+
const [realRoot, realResolved] = await Promise.all([
|
|
40
|
+
realpath(absoluteRoot).catch(() => absoluteRoot),
|
|
41
|
+
realpath(resolved).catch(() => resolved),
|
|
42
|
+
]);
|
|
43
|
+
if (!isWithinRoot(realRoot, realResolved)) {
|
|
44
|
+
throw new Error(`Screenshot path must stay within the project root (${absoluteRoot}). Export the PNG into a project-local folder such as ".slidev-agent-artifacts/verify-<slideIndex>" and pass that PNG file path to slidev_review_screenshot.`);
|
|
45
|
+
}
|
|
46
|
+
return realResolved;
|
|
47
|
+
}
|
|
48
|
+
function isPngBuffer(fileBuffer) {
|
|
49
|
+
return fileBuffer.length >= 8
|
|
50
|
+
&& fileBuffer[0] === 0x89
|
|
51
|
+
&& fileBuffer[1] === 0x50
|
|
52
|
+
&& fileBuffer[2] === 0x4E
|
|
53
|
+
&& fileBuffer[3] === 0x47
|
|
54
|
+
&& fileBuffer[4] === 0x0D
|
|
55
|
+
&& fileBuffer[5] === 0x0A
|
|
56
|
+
&& fileBuffer[6] === 0x1A
|
|
57
|
+
&& fileBuffer[7] === 0x0A;
|
|
58
|
+
}
|
|
59
|
+
export function createSlidevReviewScreenshotTool(rootDir) {
|
|
60
|
+
return tool(async ({ imagePath, slideIndex, focus }) => {
|
|
61
|
+
const resolvedImagePath = await resolveImagePath(rootDir, imagePath);
|
|
62
|
+
const fileInfo = await stat(resolvedImagePath).catch(() => null);
|
|
63
|
+
if (!fileInfo?.isFile())
|
|
64
|
+
throw new Error(`Screenshot not found: ${imagePath}`);
|
|
65
|
+
const imageBuffer = await readFile(resolvedImagePath);
|
|
66
|
+
if (path.extname(resolvedImagePath).toLowerCase() !== ".png") {
|
|
67
|
+
throw new Error(`slidev_review_screenshot only accepts PNG files. Got: ${path.extname(resolvedImagePath) || "unknown"}`);
|
|
68
|
+
}
|
|
69
|
+
if (!isPngBuffer(imageBuffer))
|
|
70
|
+
throw new Error(`slidev_review_screenshot expected a real PNG file at: ${imagePath}`);
|
|
71
|
+
const imageData = imageBuffer.toString("base64");
|
|
72
|
+
const reviewModel = await initChatModel(model, {
|
|
73
|
+
temperature: 0,
|
|
74
|
+
});
|
|
75
|
+
const structuredReviewer = reviewModel.withStructuredOutput(slidevReviewScreenshotResultSchema);
|
|
76
|
+
const review = await structuredReviewer.invoke([
|
|
77
|
+
{
|
|
78
|
+
role: "user",
|
|
79
|
+
content: [
|
|
80
|
+
{
|
|
81
|
+
type: "text",
|
|
82
|
+
text: [
|
|
83
|
+
`Review this rendered Slidev slide screenshot${slideIndex ? ` for slide ${slideIndex}` : ""}.`,
|
|
84
|
+
"Judge only what is visible in the image. Do not guess about hidden or source markdown content.",
|
|
85
|
+
"Mark the slide as failing if you notice clipped or off-screen content, overflow, cramped layout, collisions, unreadable or tiny text, broken wrapping, poor contrast, or obvious alignment problems.",
|
|
86
|
+
"Only pass the slide if it looks presentation-ready.",
|
|
87
|
+
focus ? `Pay extra attention to: ${focus}` : "",
|
|
88
|
+
"Return concise structured output.",
|
|
89
|
+
].filter(Boolean).join("\n"),
|
|
90
|
+
},
|
|
91
|
+
{
|
|
92
|
+
type: "image",
|
|
93
|
+
source_type: "base64",
|
|
94
|
+
data: imageData,
|
|
95
|
+
mimeType: "image/png",
|
|
96
|
+
mime_type: "image/png",
|
|
97
|
+
},
|
|
98
|
+
],
|
|
99
|
+
},
|
|
100
|
+
]);
|
|
101
|
+
return {
|
|
102
|
+
...review,
|
|
103
|
+
imagePath: path.relative(rootDir, resolvedImagePath) || path.basename(resolvedImagePath),
|
|
104
|
+
slideIndex: slideIndex || null,
|
|
105
|
+
};
|
|
106
|
+
}, {
|
|
107
|
+
name: "slidev_review_screenshot",
|
|
108
|
+
description: "Review an exported PNG slide screenshot with a multimodal model and report visual issues such as clipping, overflow, collisions, poor contrast, and unreadable text.",
|
|
109
|
+
schema: slidevReviewScreenshotSchema,
|
|
110
|
+
});
|
|
111
|
+
}
|