From 7af6dda9cca9c74034cc95c5edd744eeec1a5afd Mon Sep 17 00:00:00 2001 From: claude-code-best Date: Thu, 9 Jul 2026 10:36:33 +0800 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20artifact=20=E5=B7=A5=E5=85=B7?= =?UTF-8?q?=E6=94=AF=E6=8C=81=20Markdown=20=E4=B8=8A=E4=BC=A0=E3=80=81?= =?UTF-8?q?=E4=BB=A3=E7=A0=81=E9=AB=98=E4=BA=AE=E4=B8=8E=20mermaid?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 按扩展名分发:.md/.markdown 用 marked 转 HTML 后上传,.html/.htm 直传, 其他返回错误。转换产物通过 unpkg CDN 引入 highlight.js (github 主题) 和 mermaid v11,初始化脚本先把 language-mermaid 块提升为
避免被 highlight.js 当 JS 误染,再初始化 mermaid,最后 hljs.highlightAll()。 后端 Worker 不变,仍只接 text/html,转换在前端工具内完成。 Co-Authored-By: glm-5.2 --- bun.lock | 1 + packages/builtin-tools/package.json | 3 +- .../src/tools/ArtifactTool/ArtifactTool.ts | 28 ++- .../__tests__/ArtifactTool.test.ts | 52 +++++ .../ArtifactTool/__tests__/markdown.test.ts | 88 +++++++++ .../ArtifactTool/examples/full-feature.md | 178 ++++++++++++++++++ .../src/tools/ArtifactTool/markdown.ts | 98 ++++++++++ .../src/tools/ArtifactTool/prompt.ts | 10 +- src/skills/bundled/useArtifacts.ts | 35 +++- 9 files changed, 479 insertions(+), 14 deletions(-) create mode 100644 packages/builtin-tools/src/tools/ArtifactTool/__tests__/markdown.test.ts create mode 100644 packages/builtin-tools/src/tools/ArtifactTool/examples/full-feature.md create mode 100644 packages/builtin-tools/src/tools/ArtifactTool/markdown.ts diff --git a/bun.lock b/bun.lock index bd57bddb1d..0f238022a5 100644 --- a/bun.lock +++ b/bun.lock @@ -237,6 +237,7 @@ "version": "1.0.0", "dependencies": { "@claude-code-best/agent-tools": "workspace:*", + "marked": "^17.0.6", }, }, "packages/cloud-artifacts": { diff --git a/packages/builtin-tools/package.json b/packages/builtin-tools/package.json index 88dac6d49a..dec40e5330 100644 --- a/packages/builtin-tools/package.json +++ b/packages/builtin-tools/package.json @@ -11,6 +11,7 @@ "./utils": "./src/utils.ts" }, "dependencies": { - "@claude-code-best/agent-tools": "workspace:*" + "@claude-code-best/agent-tools": "workspace:*", + "marked": "^17.0.6" } } diff --git a/packages/builtin-tools/src/tools/ArtifactTool/ArtifactTool.ts b/packages/builtin-tools/src/tools/ArtifactTool/ArtifactTool.ts index f3c2604598..3114287b54 100644 --- a/packages/builtin-tools/src/tools/ArtifactTool/ArtifactTool.ts +++ b/packages/builtin-tools/src/tools/ArtifactTool/ArtifactTool.ts @@ -10,13 +10,16 @@ import { } from './prompt.js' import { getArtifactsToken, getUploadUrl } from './config.js' import { uploadArtifact } from './client.js' +import { markdownToHtml } from './markdown.js' import { renderToolResultMessage } from './UI.js' const inputSchema = lazySchema(() => z.strictObject({ file_path: z .string() - .describe('Absolute path to a local HTML file to upload.'), + .describe( + 'Absolute path to a local HTML (.html/.htm) or Markdown (.md/.markdown) file to upload. Markdown is converted to styled HTML before upload.', + ), hash: z .string() .regex(/^[A-Za-z0-9_-]{1,128}$/, 'must match ^[A-Za-z0-9_-]{1,128}$') @@ -47,7 +50,7 @@ export type ArtifactOutput = z.infer export const ArtifactTool = buildTool({ name: ARTIFACT_TOOL_NAME, searchHint: - 'upload html artifact share url cloud publish progress report public link', + 'upload html markdown artifact share url cloud publish progress report public link', maxResultSizeChars: 2_000, shouldDefer: true, strict: true, @@ -146,9 +149,9 @@ export const ArtifactTool = buildTool({ } } - let html: string + let rawContent: string try { - html = await readFile(file_path, 'utf8') + rawContent = await readFile(file_path, 'utf8') } catch { return { data: { @@ -160,6 +163,23 @@ export const ArtifactTool = buildTool({ } } + const lowerPath = file_path.toLowerCase() + let html: string + if (lowerPath.endsWith('.html') || lowerPath.endsWith('.htm')) { + html = rawContent + } else if (lowerPath.endsWith('.md') || lowerPath.endsWith('.markdown')) { + html = markdownToHtml(rawContent, file_path) + } else { + return { + data: { + id: '', + url: '', + expiresAt: '', + error: `Unsupported file extension. Accepted: .html, .htm, .md, .markdown — got: ${file_path}`, + }, + } + } + try { const result = await uploadArtifact({ html, diff --git a/packages/builtin-tools/src/tools/ArtifactTool/__tests__/ArtifactTool.test.ts b/packages/builtin-tools/src/tools/ArtifactTool/__tests__/ArtifactTool.test.ts index d8d00f7309..28020740f2 100644 --- a/packages/builtin-tools/src/tools/ArtifactTool/__tests__/ArtifactTool.test.ts +++ b/packages/builtin-tools/src/tools/ArtifactTool/__tests__/ArtifactTool.test.ts @@ -6,6 +6,8 @@ import { ArtifactTool } from '../ArtifactTool.js' const TEST_DIR = join(tmpdir(), 'artifact-tool-test') const TEST_FILE = join(TEST_DIR, 'report.html') +const MD_FILE = join(TEST_DIR, 'report.md') +const TXT_FILE = join(TEST_DIR, 'notes.txt') const MISSING_FILE = join(TEST_DIR, 'does-not-exist.html') const DIR_AS_FILE = TEST_DIR @@ -26,6 +28,12 @@ describe('ArtifactTool.call', () => { beforeEach(() => { mkdirSync(TEST_DIR, { recursive: true }) writeFileSync(TEST_FILE, '

test report

', 'utf8') + writeFileSync( + MD_FILE, + '# MD Report\n\n| a | b |\n| - | - |\n| 1 | 2 |\n', + 'utf8', + ) + writeFileSync(TXT_FILE, 'plain text content', 'utf8') process.env.CLAUDE_ARTIFACTS_TOKEN = 'test-token' process.env.CLAUDE_ARTIFACTS_URL = 'https://example.test' }) @@ -109,4 +117,48 @@ describe('ArtifactTool.call', () => { 'payload_too_large', ) }) + + test('converts .md file to styled HTML before upload', async () => { + const fetchMock = mockFetchSuccess({ + id: 'md-id', + url: 'https://example.test/7d/md-id.html', + expiresAt: '2026-06-27T10:00:00.000Z', + }) + globalThis.fetch = fetchMock + + const result = await ArtifactTool.call({ file_path: MD_FILE, ttl: 7 }) + + expect(result.data).toMatchObject({ id: 'md-id' }) + expect((result.data as { error?: string }).error).toBeUndefined() + + const calls = ( + fetchMock as unknown as { + mock: { calls: [string | URL | Request, RequestInit | undefined][] } + } + ).mock.calls + expect(calls.length).toBe(1) + const body = String(calls[0][1]?.body) + expect(body.startsWith('')).toBe(true) + expect(body).toContain('

MD Report

') + expect(body).toContain('') + expect(body).toContain('MD Report') + // Content-Type header must remain text/html — the Worker only accepts HTML. + expect(calls[0][1]?.headers).toMatchObject({ 'Content-Type': 'text/html' }) + }) + + test('rejects unsupported extension without calling fetch', async () => { + let fetchCalled = false + globalThis.fetch = mock(() => { + fetchCalled = true + return Promise.resolve(new Response('{}')) + }) as unknown as typeof fetch + + const result = await ArtifactTool.call({ file_path: TXT_FILE, ttl: 7 }) + + expect(fetchCalled).toBe(false) + expect((result.data as { error?: string }).error).toContain( + 'Unsupported file extension', + ) + expect((result.data as { error?: string }).error).toContain('.md') + }) }) diff --git a/packages/builtin-tools/src/tools/ArtifactTool/__tests__/markdown.test.ts b/packages/builtin-tools/src/tools/ArtifactTool/__tests__/markdown.test.ts new file mode 100644 index 0000000000..c07d79fdf3 --- /dev/null +++ b/packages/builtin-tools/src/tools/ArtifactTool/__tests__/markdown.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, test } from 'bun:test' +import { markdownToHtml } from '../markdown.js' + +describe('markdownToHtml', () => { + test('wraps body in a full HTML document', () => { + const out = markdownToHtml('# Hello') + expect(out.startsWith('')).toBe(true) + expect(out).toContain('') + expect(out).toContain(' + + +${body} + + + + +` +} + +function escapeHtml(s: string): string { + return s.replace( + /[&<>"]/g, + c => + ({ + '&': '&', + '<': '<', + '>': '>', + '"': '"', + })[c] as string, + ) +} diff --git a/packages/builtin-tools/src/tools/ArtifactTool/prompt.ts b/packages/builtin-tools/src/tools/ArtifactTool/prompt.ts index ab0ebe5f13..b65e003c43 100644 --- a/packages/builtin-tools/src/tools/ArtifactTool/prompt.ts +++ b/packages/builtin-tools/src/tools/ArtifactTool/prompt.ts @@ -1,14 +1,14 @@ export const ARTIFACT_TOOL_NAME = 'artifact' export async function describeArtifactTool(): Promise { - return 'Upload an HTML file to the cloud-artifacts hosting service and get back a public URL. Pass `hash` to overwrite a previously-uploaded artifact (keeps URL stable).' + return 'Upload an HTML or Markdown file to the cloud-artifacts hosting service and get back a public URL. Markdown files are converted to styled HTML before upload. Pass `hash` to overwrite a previously-uploaded artifact (keeps URL stable).' } export async function getArtifactToolPrompt(): Promise { - return `Upload an HTML file to a public hosting service and return a shareable URL plus an internal \`id\` (the "hash"). + return `Upload an HTML or Markdown file to a public hosting service and return a shareable URL plus an internal \`id\` (the "hash"). ## Inputs -- \`file_path\` (required): absolute path to a local HTML file. +- \`file_path\` (required): absolute path to a local HTML (\`.html\`/\`.htm\`) or Markdown (\`.md\`/\`.markdown\`) file. Markdown is converted to a styled HTML document before upload — just author plain Markdown (headings, lists, GFM tables, fenced code blocks, blockquotes) and the tool wraps it in a page with a neutral stylesheet. - \`hash\` (optional): if provided, overwrites the artifact with the same hash (URL stays the same). If omitted, a new random id is generated. - \`ttl\` (optional, default \`7\`): artifact lifetime in days. Must be \`7\` or \`30\`. @@ -16,10 +16,10 @@ export async function getArtifactToolPrompt(): Promise { \`{ id, url, expiresAt }\` — \`id\` is the hash (save it for future overwrite calls), \`url\` is publicly accessible. ## Workflow -1. Use the Write tool to create a local HTML file. +1. Use the Write tool to create a local \`.html\` or \`.md\` file. 2. Call this tool with its \`file_path\`. 3. If iterating on the same artifact, pass back the \`id\` returned from the first call as \`hash\` so the URL stays stable. ## Errors -The tool surfaces backend error codes verbatim (e.g. \`payload_too_large\`, \`unauthorized\`). If the file does not exist or is not a regular file, the tool returns an \`error\` field without making an HTTP request.` +The tool surfaces backend error codes verbatim (e.g. \`payload_too_large\`, \`unauthorized\`). If the file does not exist, is not a regular file, or has an unsupported extension, the tool returns an \`error\` field without making an HTTP request. Accepted extensions: \`.html\`, \`.htm\`, \`.md\`, \`.markdown\`.` } diff --git a/src/skills/bundled/useArtifacts.ts b/src/skills/bundled/useArtifacts.ts index 4fb819fb2d..021ed8b38b 100644 --- a/src/skills/bundled/useArtifacts.ts +++ b/src/skills/bundled/useArtifacts.ts @@ -36,20 +36,25 @@ Artifacts are public HTML pages you upload to a hosting service. They have stabl **First upload (creates a new artifact):** \`\`\` -1. Use the Write tool to write HTML to a local file (location is your choice). +1. Use the Write tool to write HTML (.html) or Markdown (.md) to a local file (location is your choice). 2. SearchExtraTools({ query: "select:artifact" }) // loads the tool schema -3. ExecuteExtraTool({ tool_name: "artifact", params: { file_path: ".html" } }) +3. ExecuteExtraTool({ tool_name: "artifact", params: { file_path: ".html|.md" } }) 4. Save the returned \`id\` from the tool result — this is the hash. \`\`\` **Subsequent updates (overwrites in place, URL stays stable):** \`\`\` -1. Update the local HTML file. -2. ExecuteExtraTool({ tool_name: "artifact", params: { file_path: ".html", hash: "" } }) +1. Update the local file. +2. ExecuteExtraTool({ tool_name: "artifact", params: { file_path: ".html|.md", hash: "" } }) \`\`\` The URL returned on every call is the same when you pass the same \`hash\`. The user can open it at any time to see the latest version. +## Choosing HTML vs Markdown + +- **Markdown (.md)** — preferred for text-heavy reports, design docs, research notes. The tool converts it to a styled HTML page (GFM tables, fenced code blocks, blockquotes, headings). You write content, not chrome. +- **HTML (.html)** — preferred when you need bespoke layout, custom CSS, embedded SVG, or interactive \`