Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/pull_request.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,9 @@ jobs:
- run: yarn install --frozen-lockfile --network-timeout 120000
- run: yarn run typecheck
- run: yarn run lint
# Windows checkouts get CRLF line endings, which prettier reports as unformatted.
- run: yarn run format:check
if: runner.os == 'Linux'
- run: yarn run build
- run: yarn run test
# Sanity-check `npm pack` only once (Linux + Node 24) to avoid
Expand Down
4 changes: 4 additions & 0 deletions .prettierignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
.scoria/
scoria.config.json
dist/
coverage/
3 changes: 3 additions & 0 deletions .prettierrc
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"printWidth": 160
}
7 changes: 1 addition & 6 deletions demo/App.vue
Original file line number Diff line number Diff line change
Expand Up @@ -36,12 +36,7 @@
<div v-if="ViewComponent" class="bg-white rounded-lg p-5 mb-5 shadow-md">
<h2 class="text-gray-600 text-xl mb-4">View Component</h2>
<div class="border border-gray-200 rounded p-4">
<component
:is="ViewComponent"
:selectedResult="result"
:sendTextMessage="handleSendTextMessage"
@updateResult="handleUpdate"
/>
<component :is="ViewComponent" :selectedResult="result" :sendTextMessage="handleSendTextMessage" @updateResult="handleUpdate" />
</div>
<div v-if="lastSentMessage" class="mt-4 p-3 bg-emerald-50 border border-emerald-200 rounded-md">
<strong class="text-emerald-800 block mb-2">Sent Message:</strong>
Expand Down Expand Up @@ -72,7 +67,7 @@
import type { ToolResult, ToolSample, ToolPlugin } from "gui-chat-protocol/vue";

// Plugin configuration - cast to base ToolPlugin for generic demo usage
const currentPlugin = plugin as unknown as ToolPlugin;

Check warning on line 70 in demo/App.vue

View workflow job for this annotation

GitHub Actions / lint_test (26.x, macos-latest)

Do not use any type assertions

Check warning on line 70 in demo/App.vue

View workflow job for this annotation

GitHub Actions / lint_test (26.x, macos-latest)

Do not use any type assertions

Check warning on line 70 in demo/App.vue

View workflow job for this annotation

GitHub Actions / lint_test (26.x, windows-latest)

Do not use any type assertions

Check warning on line 70 in demo/App.vue

View workflow job for this annotation

GitHub Actions / lint_test (26.x, windows-latest)

Do not use any type assertions

Check warning on line 70 in demo/App.vue

View workflow job for this annotation

GitHub Actions / lint_test (24.x, windows-latest)

Do not use any type assertions

Check warning on line 70 in demo/App.vue

View workflow job for this annotation

GitHub Actions / lint_test (24.x, windows-latest)

Do not use any type assertions

Check warning on line 70 in demo/App.vue

View workflow job for this annotation

GitHub Actions / lint_test (24.x, macos-latest)

Do not use any type assertions

Check warning on line 70 in demo/App.vue

View workflow job for this annotation

GitHub Actions / lint_test (24.x, macos-latest)

Do not use any type assertions

Check warning on line 70 in demo/App.vue

View workflow job for this annotation

GitHub Actions / lint_test (26.x, ubuntu-latest)

Do not use any type assertions

Check warning on line 70 in demo/App.vue

View workflow job for this annotation

GitHub Actions / lint_test (26.x, ubuntu-latest)

Do not use any type assertions

Check warning on line 70 in demo/App.vue

View workflow job for this annotation

GitHub Actions / lint_test (24.x, ubuntu-latest)

Do not use any type assertions

Check warning on line 70 in demo/App.vue

View workflow job for this annotation

GitHub Actions / lint_test (24.x, ubuntu-latest)

Do not use any type assertions

// Computed properties from plugin
const pluginName = computed(() => currentPlugin.toolDefinition.name);
Expand Down
3 changes: 1 addition & 2 deletions demo/markdownArgs.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import type { MarkdownArgs } from "../src/core/types";

const isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === "object" && value !== null && !Array.isArray(value);
const isRecord = (value: unknown): value is Record<string, unknown> => typeof value === "object" && value !== null && !Array.isArray(value);

export const isMarkdownArgs = (value: unknown): value is MarkdownArgs =>
isRecord(value) &&
Expand Down
5 changes: 4 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,9 @@
"preview": "vite preview",
"test": "tsx --test tests/*.test.ts",
"quality": "scoria",
"test:coverage": "c8 yarn run test"
"test:coverage": "c8 yarn run test",
"format": "prettier --write \"{src,demo,tests}/**/*.{ts,tsx,vue,json,css}\" \"*.{ts,js,json}\"",
"format:check": "prettier --check \"{src,demo,tests}/**/*.{ts,tsx,vue,json,css}\" \"*.{ts,js,json}\""
},
"resolutions": {
"minimatch": "^10.2.4"
Expand All @@ -53,6 +55,7 @@
"eslint-plugin-vue": "^10.11.1",
"globals": "^17.13.0",
"gui-chat-protocol": "^2.3.0",
"prettier": "^3.9.10",
"scoria": "^0.4.1",
"tailwindcss": "^4.3.3",
"tsx": "^4.23.15",
Expand Down
28 changes: 8 additions & 20 deletions src/core/hostResponse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,29 +7,17 @@ export interface SaveImagesResponse {
imageUrls?: Record<string, string>;
}

const isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === "object" && value !== null;
const isRecord = (value: unknown): value is Record<string, unknown> => typeof value === "object" && value !== null;

const isOptionalString = (value: unknown): value is string | undefined =>
value === undefined || typeof value === "string";
const isOptionalString = (value: unknown): value is string | undefined => value === undefined || typeof value === "string";

const isStringRecord = (value: unknown): value is Record<string, string> =>
isRecord(value) &&
Object.values(value).every((item) => typeof item === "string");
const isStringRecord = (value: unknown): value is Record<string, string> => isRecord(value) && Object.values(value).every((item) => typeof item === "string");

export const isGeneratedImageResponse = (
value: unknown,
): value is GeneratedImageResponse =>
isRecord(value) &&
typeof value.success === "boolean" &&
isOptionalString(value.imageData);
export const isGeneratedImageResponse = (value: unknown): value is GeneratedImageResponse =>
isRecord(value) && typeof value.success === "boolean" && isOptionalString(value.imageData);

export const isSaveImagesResponse = (
value: unknown,
): value is SaveImagesResponse =>
isRecord(value) &&
(value.imageUrls === undefined || isStringRecord(value.imageUrls));
export const isSaveImagesResponse = (value: unknown): value is SaveImagesResponse =>
isRecord(value) && (value.imageUrls === undefined || isStringRecord(value.imageUrls));

/** `loadBlankImageBase64` returns the reference image; anything else is "no reference". */
export const readBlankImageBase64 = (value: unknown): string =>
typeof value === "string" ? value : "";
export const readBlankImageBase64 = (value: unknown): string => (typeof value === "string" ? value : "");
30 changes: 7 additions & 23 deletions src/core/plugin.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,12 @@
import type { ToolContext, ToolPluginCore } from "gui-chat-protocol";
import type { MarkdownArgs, MarkdownToolData, MarkdownResult } from "./types";
import {
isGeneratedImageResponse,
isSaveImagesResponse,
readBlankImageBase64,
} from "./hostResponse";
import { isGeneratedImageResponse, isSaveImagesResponse, readBlankImageBase64 } from "./hostResponse";
import { TOOL_NAME, TOOL_DEFINITION } from "./definition";

// context is nullable on purpose: hosts that run the plugin without client-side
// state (MulmoClaude's server bridge) pass an empty or missing context, and
// reading through it unguarded threw a TypeError instead of returning a result.
export const presentDocument = async (
context: ToolContext | null | undefined,
args: MarkdownArgs,
): Promise<MarkdownResult> => {
export const presentDocument = async (context: ToolContext | null | undefined, args: MarkdownArgs): Promise<MarkdownResult> => {
let { markdown } = args;
const { title, filenameHint } = args;
const app = context?.app;
Expand All @@ -26,7 +19,7 @@
// Detect all image placeholders in the format: ![prompt](__too_be_replaced_image_path__)
// Also handle variant with leading slash: ![prompt](/__too_be_replaced_image_path__)
const imageRegex = /!\[([^\]]+)\]\(\/?__too_be_replaced_image_path__\)/g;
const matches = [...markdown.matchAll(imageRegex)];

Check failure

Code scanning / CodeQL

Polynomial regular expression used on uncontrolled data High

This
regular expression
that depends on
library input
may run slow on strings starting with '![' and with many repetitions of '![\'.

if (matches.length > 0 && app?.generateImageWithBackend && app?.saveImages) {
// Generate UUID for this document
Expand All @@ -34,21 +27,15 @@
const images: Record<string, string> = {};

// Load blank image for aspect ratio reference
const blankImageBase64 = app.loadBlankImageBase64
? readBlankImageBase64(await app.loadBlankImageBase64())
: "";
const blankImageBase64 = app.loadBlankImageBase64 ? readBlankImageBase64(await app.loadBlankImageBase64()) : "";

// Generate images for each placeholder in parallel
const imagePromises = matches.map(async (match, i) => {
const prompt = `${match[1]}. Use the last image as the output dimension.`;
const imageId = `image_${i}`;

try {
const result = await app!.generateImageWithBackend!(
prompt,
blankImageBase64 ? [blankImageBase64] : [],
context,
);
const result = await app!.generateImageWithBackend!(prompt, blankImageBase64 ? [blankImageBase64] : [], context);

Check warning on line 38 in src/core/plugin.ts

View workflow job for this annotation

GitHub Actions / lint_test (26.x, macos-latest)

Forbidden non-null assertion

Check warning on line 38 in src/core/plugin.ts

View workflow job for this annotation

GitHub Actions / lint_test (26.x, macos-latest)

Forbidden non-null assertion

Check warning on line 38 in src/core/plugin.ts

View workflow job for this annotation

GitHub Actions / lint_test (26.x, windows-latest)

Forbidden non-null assertion

Check warning on line 38 in src/core/plugin.ts

View workflow job for this annotation

GitHub Actions / lint_test (26.x, windows-latest)

Forbidden non-null assertion

Check warning on line 38 in src/core/plugin.ts

View workflow job for this annotation

GitHub Actions / lint_test (24.x, windows-latest)

Forbidden non-null assertion

Check warning on line 38 in src/core/plugin.ts

View workflow job for this annotation

GitHub Actions / lint_test (24.x, windows-latest)

Forbidden non-null assertion

Check warning on line 38 in src/core/plugin.ts

View workflow job for this annotation

GitHub Actions / lint_test (24.x, macos-latest)

Forbidden non-null assertion

Check warning on line 38 in src/core/plugin.ts

View workflow job for this annotation

GitHub Actions / lint_test (24.x, macos-latest)

Forbidden non-null assertion

Check warning on line 38 in src/core/plugin.ts

View workflow job for this annotation

GitHub Actions / lint_test (26.x, ubuntu-latest)

Forbidden non-null assertion

Check warning on line 38 in src/core/plugin.ts

View workflow job for this annotation

GitHub Actions / lint_test (26.x, ubuntu-latest)

Forbidden non-null assertion

Check warning on line 38 in src/core/plugin.ts

View workflow job for this annotation

GitHub Actions / lint_test (24.x, ubuntu-latest)

Forbidden non-null assertion

Check warning on line 38 in src/core/plugin.ts

View workflow job for this annotation

GitHub Actions / lint_test (24.x, ubuntu-latest)

Forbidden non-null assertion

if (isGeneratedImageResponse(result) && result.success && result.imageData) {
images[imageId] = `data:image/png;base64,${result.imageData}`;
Expand All @@ -70,12 +57,12 @@

// Replace placeholders with actual image URLs
let imageIndex = 0;
markdown = markdown.replace(imageRegex, (match, prompt) => {
const imageId = `image_${imageIndex}`;
const imageUrl = imageUrls[imageId];
imageIndex++;
return imageUrl ? `![${prompt}](${imageUrl})` : match;
});

Check failure

Code scanning / CodeQL

Polynomial regular expression used on uncontrolled data High

This
regular expression
that depends on
library input
may run slow on strings starting with '![' and with many repetitions of '![\'.
}
} catch (error) {
console.error("Failed to save images:", error);
Expand All @@ -87,26 +74,23 @@
title,
data: { markdown, filenameHint },
uuid: docUuid,
instructions:
"Acknowledge that the document has been created and is displayed to the user.",
instructions: "Acknowledge that the document has been created and is displayed to the user.",
};
}

return {
message: `Created markdown document: ${title}`,
title,
data: { markdown, filenameHint },
instructions:
"Acknowledge that the document has been created and is displayed to the user.",
instructions: "Acknowledge that the document has been created and is displayed to the user.",
};
};

export const pluginCore: ToolPluginCore<MarkdownToolData, unknown, MarkdownArgs> = {
toolDefinition: TOOL_DEFINITION,
execute: presentDocument,
generatingMessage: "Creating document...",
waitingMessage:
"Tell the user that the document was created, will be presented to the user shortly.",
waitingMessage: "Tell the user that the document was created, will be presented to the user shortly.",
isEnabled: () => true,
backends: ["imageGen"],
};
Expand Down
24 changes: 4 additions & 20 deletions src/vue/View.vue
Original file line number Diff line number Diff line change
@@ -1,9 +1,6 @@
<template>
<div class="markdown-container">
<div
v-if="!selectedResult.data?.markdown"
class="min-h-full p-8 flex items-center justify-center"
>
<div v-if="!selectedResult.data?.markdown" class="min-h-full p-8 flex items-center justify-center">
<div class="text-gray-500">No markdown content available</div>
</div>
<template v-else>
Expand All @@ -20,27 +17,14 @@
</button>
</div>
</div>
<div
class="markdown-content prose prose-slate max-w-none"
v-html="renderedHtml"
></div>
<div class="markdown-content prose prose-slate max-w-none" v-html="renderedHtml"></div>
</div>
</div>

<details class="markdown-source">
<summary>Edit Markdown Source</summary>
<textarea
v-model="editableMarkdown"
class="markdown-editor"
spellcheck="false"
></textarea>
<button
@click="applyMarkdown"
class="apply-btn"
:disabled="!hasChanges"
>
Apply Changes
</button>
<textarea v-model="editableMarkdown" class="markdown-editor" spellcheck="false"></textarea>
<button @click="applyMarkdown" class="apply-btn" :disabled="!hasChanges">Apply Changes</button>
</details>
</template>
</div>
Expand Down Expand Up @@ -77,12 +61,12 @@

// Watch for scroll requests from viewState
watch(
() => props.selectedResult?.viewState?.scrollToAnchor as string | undefined,

Check warning on line 64 in src/vue/View.vue

View workflow job for this annotation

GitHub Actions / lint_test (26.x, macos-latest)

Do not use any type assertions

Check warning on line 64 in src/vue/View.vue

View workflow job for this annotation

GitHub Actions / lint_test (26.x, windows-latest)

Do not use any type assertions

Check warning on line 64 in src/vue/View.vue

View workflow job for this annotation

GitHub Actions / lint_test (24.x, windows-latest)

Do not use any type assertions

Check warning on line 64 in src/vue/View.vue

View workflow job for this annotation

GitHub Actions / lint_test (24.x, macos-latest)

Do not use any type assertions

Check warning on line 64 in src/vue/View.vue

View workflow job for this annotation

GitHub Actions / lint_test (26.x, ubuntu-latest)

Do not use any type assertions

Check warning on line 64 in src/vue/View.vue

View workflow job for this annotation

GitHub Actions / lint_test (24.x, ubuntu-latest)

Do not use any type assertions
(anchorId) => {
if (!anchorId) return;

// Use nextTick to ensure the DOM is updated
nextTick(() => {

Check warning on line 69 in src/vue/View.vue

View workflow job for this annotation

GitHub Actions / lint_test (26.x, macos-latest)

Promises must be awaited, end with a call to .catch, end with a call to .then with a rejection handler or be explicitly marked as ignored with the `void` operator

Check warning on line 69 in src/vue/View.vue

View workflow job for this annotation

GitHub Actions / lint_test (26.x, windows-latest)

Promises must be awaited, end with a call to .catch, end with a call to .then with a rejection handler or be explicitly marked as ignored with the `void` operator

Check warning on line 69 in src/vue/View.vue

View workflow job for this annotation

GitHub Actions / lint_test (24.x, windows-latest)

Promises must be awaited, end with a call to .catch, end with a call to .then with a rejection handler or be explicitly marked as ignored with the `void` operator

Check warning on line 69 in src/vue/View.vue

View workflow job for this annotation

GitHub Actions / lint_test (24.x, macos-latest)

Promises must be awaited, end with a call to .catch, end with a call to .then with a rejection handler or be explicitly marked as ignored with the `void` operator

Check warning on line 69 in src/vue/View.vue

View workflow job for this annotation

GitHub Actions / lint_test (26.x, ubuntu-latest)

Promises must be awaited, end with a call to .catch, end with a call to .then with a rejection handler or be explicitly marked as ignored with the `void` operator

Check warning on line 69 in src/vue/View.vue

View workflow job for this annotation

GitHub Actions / lint_test (24.x, ubuntu-latest)

Promises must be awaited, end with a call to .catch, end with a call to .then with a rejection handler or be explicitly marked as ignored with the `void` operator
const element = document.getElementById(anchorId);
if (element) {
element.scrollIntoView({ behavior: "smooth", block: "start" });
Expand Down
6 changes: 1 addition & 5 deletions src/vue/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,11 +34,7 @@ export const plugin: ToolPlugin<MarkdownToolData, unknown, MarkdownArgs> = {

export type { MarkdownToolData, MarkdownArgs, MarkdownResult } from "../core/types";

export {
TOOL_DEFINITION,
executeMarkdown,
pluginCore,
} from "../core/plugin";
export { TOOL_DEFINITION, executeMarkdown, pluginCore } from "../core/plugin";

export { samples } from "../core/samples";

Expand Down
28 changes: 5 additions & 23 deletions tests/hostResponse.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,18 +7,11 @@

import { test, describe } from "node:test";
import assert from "node:assert";
import {
isGeneratedImageResponse,
isSaveImagesResponse,
readBlankImageBase64,
} from "../src/core/hostResponse.js";
import { isGeneratedImageResponse, isSaveImagesResponse, readBlankImageBase64 } from "../src/core/hostResponse.js";

describe("isGeneratedImageResponse", () => {
test("accepts a successful response carrying image data", () => {
assert.equal(
isGeneratedImageResponse({ success: true, imageData: "base64" }),
true,
);
assert.equal(isGeneratedImageResponse({ success: true, imageData: "base64" }), true);
});

test("accepts a failure response with no image data", () => {
Expand All @@ -31,21 +24,14 @@ describe("isGeneratedImageResponse", () => {

test("rejects values that are not a response object", () => {
[null, undefined, "ok", 7].forEach((value) => {
assert.equal(
isGeneratedImageResponse(value),
false,
`should reject ${JSON.stringify(value)}`,
);
assert.equal(isGeneratedImageResponse(value), false, `should reject ${JSON.stringify(value)}`);
});
});
});

describe("isSaveImagesResponse", () => {
test("accepts a map of image id to url", () => {
assert.equal(
isSaveImagesResponse({ imageUrls: { image_0: "/a.png", image_1: "/b.png" } }),
true,
);
assert.equal(isSaveImagesResponse({ imageUrls: { image_0: "/a.png", image_1: "/b.png" } }), true);
});

test("accepts a response with no imageUrls key", () => {
Expand All @@ -58,11 +44,7 @@ describe("isSaveImagesResponse", () => {

test("rejects values that are not a response object", () => {
[null, undefined, "ok", 7].forEach((value) => {
assert.equal(
isSaveImagesResponse(value),
false,
`should reject ${JSON.stringify(value)}`,
);
assert.equal(isSaveImagesResponse(value), false, `should reject ${JSON.stringify(value)}`);
});
});
});
Expand Down
10 changes: 2 additions & 8 deletions tests/plugin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,14 +23,8 @@ const CONTEXTS: [string, unknown][] = [
describe("GUIChatPluginMarkdown - host provides no context", () => {
CONTEXTS.forEach(([label, context]) => {
test(`returns a result instead of throwing when context is ${label}`, async () => {
const result = await pluginCore.execute(
context as never,
ARGS as never,
);
assert.ok(
result && typeof result === "object",
"execute must resolve to a ToolResult rather than throw",
);
const result = await pluginCore.execute(context as never, ARGS as never);
assert.ok(result && typeof result === "object", "execute must resolve to a ToolResult rather than throw");
});
});
});
15 changes: 2 additions & 13 deletions tsconfig.eslint.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,17 +7,6 @@
"declarationDir": null,
"jsx": "react-jsx"
},
"include": [
"*.ts",
"src/**/*.ts",
"src/**/*.tsx",
"src/**/*.vue",
"demo/**/*.ts",
"demo/**/*.tsx",
"demo/**/*.vue"
],
"exclude": [
"node_modules",
"dist"
]
"include": ["*.ts", "src/**/*.ts", "src/**/*.tsx", "src/**/*.vue", "demo/**/*.ts", "demo/**/*.tsx", "demo/**/*.vue"],
"exclude": ["node_modules", "dist"]
}
3 changes: 1 addition & 2 deletions vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,7 @@ export default defineConfig({
},
name: "GUIChatPluginMarkdown",
formats: ["es", "cjs"],
fileName: (format, entryName) =>
`${entryName}.${format === "es" ? "js" : "cjs"}`,
fileName: (format, entryName) => `${entryName}.${format === "es" ? "js" : "cjs"}`,
},
rollupOptions: {
external: ["vue"],
Expand Down
5 changes: 5 additions & 0 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -3000,6 +3000,11 @@ prelude-ls@^1.2.1:
resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.2.1.tgz#debc6489d7a6e6b0e7611888cec880337d316396"
integrity sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==

prettier@^3.9.10:
version "3.9.10"
resolved "https://registry.yarnpkg.com/prettier/-/prettier-3.9.10.tgz#ac378e432f1a7381518f2e080816bbaefa38d585"
integrity sha512-skGHTH1Yb9QvaTC4hWnRTzEYtCpApF0UMBPz+t/0ZPzP8ihefo/btKd+QA7pUE0fEwA1SbS/NmhyEcstdXOIdA==

pretty-ms@^7.0.1:
version "7.0.1"
resolved "https://registry.yarnpkg.com/pretty-ms/-/pretty-ms-7.0.1.tgz#7d903eaab281f7d8e03c66f867e239dc32fb73e8"
Expand Down
Loading