Skip to content

Commit 5f1d900

Browse files
Merge pull request #1289 from claude-code-best/feature/artifact-support-md
feat: artifact 支持直接上传 md 文件;消除 system prompt 中的 finger print
2 parents 0c6c71a + 3edb134 commit 5f1d900

12 files changed

Lines changed: 485 additions & 51 deletions

File tree

bun.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/builtin-tools/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
"./utils": "./src/utils.ts"
1212
},
1313
"dependencies": {
14-
"@claude-code-best/agent-tools": "workspace:*"
14+
"@claude-code-best/agent-tools": "workspace:*",
15+
"marked": "^17.0.6"
1516
}
1617
}

packages/builtin-tools/src/tools/ArtifactTool/ArtifactTool.ts

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,13 +10,16 @@ import {
1010
} from './prompt.js'
1111
import { getArtifactsToken, getUploadUrl } from './config.js'
1212
import { uploadArtifact } from './client.js'
13+
import { markdownToHtml } from './markdown.js'
1314
import { renderToolResultMessage } from './UI.js'
1415

1516
const inputSchema = lazySchema(() =>
1617
z.strictObject({
1718
file_path: z
1819
.string()
19-
.describe('Absolute path to a local HTML file to upload.'),
20+
.describe(
21+
'Absolute path to a local HTML (.html/.htm) or Markdown (.md/.markdown) file to upload. Markdown is converted to styled HTML before upload.',
22+
),
2023
hash: z
2124
.string()
2225
.regex(/^[A-Za-z0-9_-]{1,128}$/, 'must match ^[A-Za-z0-9_-]{1,128}$')
@@ -47,7 +50,7 @@ export type ArtifactOutput = z.infer<OutputSchema>
4750
export const ArtifactTool = buildTool({
4851
name: ARTIFACT_TOOL_NAME,
4952
searchHint:
50-
'upload html artifact share url cloud publish progress report public link',
53+
'upload html markdown artifact share url cloud publish progress report public link',
5154
maxResultSizeChars: 2_000,
5255
shouldDefer: true,
5356
strict: true,
@@ -146,9 +149,9 @@ export const ArtifactTool = buildTool({
146149
}
147150
}
148151

149-
let html: string
152+
let rawContent: string
150153
try {
151-
html = await readFile(file_path, 'utf8')
154+
rawContent = await readFile(file_path, 'utf8')
152155
} catch {
153156
return {
154157
data: {
@@ -160,6 +163,23 @@ export const ArtifactTool = buildTool({
160163
}
161164
}
162165

166+
const lowerPath = file_path.toLowerCase()
167+
let html: string
168+
if (lowerPath.endsWith('.html') || lowerPath.endsWith('.htm')) {
169+
html = rawContent
170+
} else if (lowerPath.endsWith('.md') || lowerPath.endsWith('.markdown')) {
171+
html = markdownToHtml(rawContent, file_path)
172+
} else {
173+
return {
174+
data: {
175+
id: '',
176+
url: '',
177+
expiresAt: '',
178+
error: `Unsupported file extension. Accepted: .html, .htm, .md, .markdown — got: ${file_path}`,
179+
},
180+
}
181+
}
182+
163183
try {
164184
const result = await uploadArtifact({
165185
html,

packages/builtin-tools/src/tools/ArtifactTool/__tests__/ArtifactTool.test.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ import { ArtifactTool } from '../ArtifactTool.js'
66

77
const TEST_DIR = join(tmpdir(), 'artifact-tool-test')
88
const TEST_FILE = join(TEST_DIR, 'report.html')
9+
const MD_FILE = join(TEST_DIR, 'report.md')
10+
const TXT_FILE = join(TEST_DIR, 'notes.txt')
911
const MISSING_FILE = join(TEST_DIR, 'does-not-exist.html')
1012
const DIR_AS_FILE = TEST_DIR
1113

@@ -26,6 +28,12 @@ describe('ArtifactTool.call', () => {
2628
beforeEach(() => {
2729
mkdirSync(TEST_DIR, { recursive: true })
2830
writeFileSync(TEST_FILE, '<h1>test report</h1>', 'utf8')
31+
writeFileSync(
32+
MD_FILE,
33+
'# MD Report\n\n| a | b |\n| - | - |\n| 1 | 2 |\n',
34+
'utf8',
35+
)
36+
writeFileSync(TXT_FILE, 'plain text content', 'utf8')
2937
process.env.CLAUDE_ARTIFACTS_TOKEN = 'test-token'
3038
process.env.CLAUDE_ARTIFACTS_URL = 'https://example.test'
3139
})
@@ -109,4 +117,48 @@ describe('ArtifactTool.call', () => {
109117
'payload_too_large',
110118
)
111119
})
120+
121+
test('converts .md file to styled HTML before upload', async () => {
122+
const fetchMock = mockFetchSuccess({
123+
id: 'md-id',
124+
url: 'https://example.test/7d/md-id.html',
125+
expiresAt: '2026-06-27T10:00:00.000Z',
126+
})
127+
globalThis.fetch = fetchMock
128+
129+
const result = await ArtifactTool.call({ file_path: MD_FILE, ttl: 7 })
130+
131+
expect(result.data).toMatchObject({ id: 'md-id' })
132+
expect((result.data as { error?: string }).error).toBeUndefined()
133+
134+
const calls = (
135+
fetchMock as unknown as {
136+
mock: { calls: [string | URL | Request, RequestInit | undefined][] }
137+
}
138+
).mock.calls
139+
expect(calls.length).toBe(1)
140+
const body = String(calls[0][1]?.body)
141+
expect(body.startsWith('<!DOCTYPE html>')).toBe(true)
142+
expect(body).toContain('<h1>MD Report</h1>')
143+
expect(body).toContain('<table>')
144+
expect(body).toContain('<title>MD Report</title>')
145+
// Content-Type header must remain text/html — the Worker only accepts HTML.
146+
expect(calls[0][1]?.headers).toMatchObject({ 'Content-Type': 'text/html' })
147+
})
148+
149+
test('rejects unsupported extension without calling fetch', async () => {
150+
let fetchCalled = false
151+
globalThis.fetch = mock(() => {
152+
fetchCalled = true
153+
return Promise.resolve(new Response('{}'))
154+
}) as unknown as typeof fetch
155+
156+
const result = await ArtifactTool.call({ file_path: TXT_FILE, ttl: 7 })
157+
158+
expect(fetchCalled).toBe(false)
159+
expect((result.data as { error?: string }).error).toContain(
160+
'Unsupported file extension',
161+
)
162+
expect((result.data as { error?: string }).error).toContain('.md')
163+
})
112164
})
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
import { describe, expect, test } from 'bun:test'
2+
import { markdownToHtml } from '../markdown.js'
3+
4+
describe('markdownToHtml', () => {
5+
test('wraps body in a full HTML document', () => {
6+
const out = markdownToHtml('# Hello')
7+
expect(out.startsWith('<!DOCTYPE html>')).toBe(true)
8+
expect(out).toContain('<html')
9+
expect(out).toContain('</html>')
10+
expect(out).toContain('<style>')
11+
})
12+
13+
test('extracts H1 as <title>', () => {
14+
const out = markdownToHtml('# Hello World\n\nbody')
15+
expect(out).toContain('<title>Hello World</title>')
16+
expect(out).toContain('<h1>Hello World</h1>')
17+
})
18+
19+
test('renders GFM tables', () => {
20+
const md = ['| A | B |', '| - | - |', '| 1 | 2 |'].join('\n')
21+
const out = markdownToHtml(md)
22+
expect(out).toContain('<table>')
23+
expect(out).toContain('<th>A</th>')
24+
expect(out).toContain('<td>1</td>')
25+
})
26+
27+
test('preserves fenced code language class', () => {
28+
const md = '```ts\nconst x = 1\n```'
29+
const out = markdownToHtml(md)
30+
expect(out).toContain('class="language-ts"')
31+
expect(out).toContain('<pre>')
32+
})
33+
34+
test('passes through inline raw HTML', () => {
35+
const out = markdownToHtml('<strong>raw</strong>')
36+
expect(out).toContain('<strong>raw</strong>')
37+
})
38+
39+
test('renders blockquotes', () => {
40+
const out = markdownToHtml('> quoted')
41+
expect(out).toContain('<blockquote>')
42+
})
43+
44+
test('falls back to basename when no H1 present', () => {
45+
const out = markdownToHtml('just body text', '/tmp/My Report.md')
46+
expect(out).toContain('<title>My Report</title>')
47+
})
48+
49+
test('falls back to default when no H1 and no filename', () => {
50+
const out = markdownToHtml('just body text')
51+
expect(out).toContain('<title>Artifact</title>')
52+
})
53+
54+
test('strips .markdown suffix in fallback title', () => {
55+
const out = markdownToHtml('body', '/x/foo.markdown')
56+
expect(out).toContain('<title>foo</title>')
57+
})
58+
59+
test('escapes HTML in title to prevent injection', () => {
60+
const out = markdownToHtml('# Title <script>alert(1)</script>')
61+
// Title tag must not contain a literal <script>
62+
const titleMatch = out.match(/<title>([\s\S]*?)<\/title>/)
63+
expect(titleMatch).not.toBeNull()
64+
expect(titleMatch![1]).not.toContain('<script>')
65+
expect(titleMatch![1]).toContain('&lt;script&gt;')
66+
})
67+
68+
test('embeds highlight.js and mermaid via unpkg CDN', () => {
69+
const out = markdownToHtml('body')
70+
expect(out).toContain(
71+
'https://unpkg.com/@highlightjs/cdn-assets@11.10.0/highlight.min.js',
72+
)
73+
expect(out).toContain(
74+
'https://unpkg.com/@highlightjs/cdn-assets@11.10.0/styles/github.min.css',
75+
)
76+
expect(out).toContain('https://unpkg.com/mermaid@11/dist/mermaid.min.js')
77+
// Init script must run after both libraries load.
78+
expect(out).toContain('hljs.highlightAll()')
79+
expect(out).toContain('mermaid.initialize(')
80+
expect(out).toContain('language-mermaid')
81+
const mermaidIdx = out.indexOf('mermaid@11/dist/mermaid.min.js')
82+
const hljsIdx = out.indexOf('highlight.min.js')
83+
const initIdx = out.indexOf('hljs.highlightAll')
84+
expect(mermaidIdx).toBeGreaterThan(0)
85+
expect(hljsIdx).toBeGreaterThan(mermaidIdx)
86+
expect(initIdx).toBeGreaterThan(hljsIdx)
87+
})
88+
})

0 commit comments

Comments
 (0)