mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-21 15:55:50 +00:00
feat(artifact): add buildTool definition with file validation
Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>
This commit is contained in:
175
packages/builtin-tools/src/tools/ArtifactTool/ArtifactTool.ts
Normal file
175
packages/builtin-tools/src/tools/ArtifactTool/ArtifactTool.ts
Normal file
@@ -0,0 +1,175 @@
|
||||
import { stat, readFile } from 'fs/promises'
|
||||
import { z } from 'zod/v4'
|
||||
import type { ToolResultBlockParam } from 'src/Tool.js'
|
||||
import { buildTool } from 'src/Tool.js'
|
||||
import { lazySchema } from 'src/utils/lazySchema.js'
|
||||
import {
|
||||
ARTIFACT_TOOL_NAME,
|
||||
describeArtifactTool,
|
||||
getArtifactToolPrompt,
|
||||
} from './prompt.js'
|
||||
import { getArtifactsToken, getUploadUrl } from './config.js'
|
||||
import { uploadArtifact } from './client.js'
|
||||
|
||||
const inputSchema = lazySchema(() =>
|
||||
z.strictObject({
|
||||
file_path: z
|
||||
.string()
|
||||
.describe('Absolute path to a local HTML file to upload.'),
|
||||
hash: z
|
||||
.string()
|
||||
.regex(/^[A-Za-z0-9_-]{1,128}$/, 'must match ^[A-Za-z0-9_-]{1,128}$')
|
||||
.optional()
|
||||
.describe(
|
||||
'If provided, overwrites the existing artifact with this hash (URL stays stable). If omitted, a new random id is generated.',
|
||||
),
|
||||
ttl: z
|
||||
.union([z.literal(7), z.literal(30)])
|
||||
.default(7)
|
||||
.describe('Lifetime in days. Must be 7 or 30. Default 7.'),
|
||||
}),
|
||||
)
|
||||
type InputSchema = ReturnType<typeof inputSchema>
|
||||
type ArtifactInput = z.infer<InputSchema>
|
||||
|
||||
const outputSchema = lazySchema(() =>
|
||||
z.object({
|
||||
id: z.string(),
|
||||
url: z.string(),
|
||||
expiresAt: z.string(),
|
||||
}),
|
||||
)
|
||||
type OutputSchema = ReturnType<typeof outputSchema>
|
||||
type ArtifactOutput = z.infer<OutputSchema>
|
||||
type ArtifactErrorOutput = ArtifactOutput & { error?: string }
|
||||
|
||||
export const ArtifactTool = buildTool({
|
||||
name: ARTIFACT_TOOL_NAME,
|
||||
searchHint:
|
||||
'upload html artifact share url cloud publish progress report public link',
|
||||
maxResultSizeChars: 2_000,
|
||||
shouldDefer: true,
|
||||
strict: true,
|
||||
|
||||
get inputSchema(): InputSchema {
|
||||
return inputSchema()
|
||||
},
|
||||
get outputSchema(): OutputSchema {
|
||||
return outputSchema()
|
||||
},
|
||||
|
||||
async description() {
|
||||
return describeArtifactTool()
|
||||
},
|
||||
async prompt() {
|
||||
return getArtifactToolPrompt()
|
||||
},
|
||||
|
||||
isEnabled() {
|
||||
return true
|
||||
},
|
||||
isConcurrencySafe() {
|
||||
return false
|
||||
},
|
||||
isReadOnly() {
|
||||
return false
|
||||
},
|
||||
requiresUserInteraction() {
|
||||
return true
|
||||
},
|
||||
userFacingName() {
|
||||
return 'Artifact'
|
||||
},
|
||||
|
||||
renderToolUseMessage(input: Partial<ArtifactInput>) {
|
||||
const hashPart = input.hash ? ` (hash=${input.hash})` : ''
|
||||
return `Upload artifact: ${input.file_path ?? '...'}${hashPart}`
|
||||
},
|
||||
|
||||
mapToolResultToToolResultBlockParam(
|
||||
content: ArtifactErrorOutput,
|
||||
toolUseID: string,
|
||||
): ToolResultBlockParam {
|
||||
if (content.error) {
|
||||
return {
|
||||
tool_use_id: toolUseID,
|
||||
type: 'tool_result',
|
||||
is_error: true,
|
||||
content: content.error,
|
||||
}
|
||||
}
|
||||
return {
|
||||
tool_use_id: toolUseID,
|
||||
type: 'tool_result',
|
||||
content: `Artifact uploaded: ${content.url} (id: ${content.id}, expires: ${content.expiresAt})`,
|
||||
}
|
||||
},
|
||||
|
||||
async call(input: ArtifactInput) {
|
||||
const { file_path, hash, ttl } = input
|
||||
|
||||
let size: number
|
||||
try {
|
||||
const fileStat = await stat(file_path)
|
||||
if (!fileStat.isFile()) {
|
||||
return {
|
||||
data: {
|
||||
id: '',
|
||||
url: '',
|
||||
expiresAt: '',
|
||||
error: `Path is not a regular file: ${file_path}`,
|
||||
},
|
||||
}
|
||||
}
|
||||
size = fileStat.size
|
||||
} catch {
|
||||
return {
|
||||
data: {
|
||||
id: '',
|
||||
url: '',
|
||||
expiresAt: '',
|
||||
error: `File does not exist or is not readable: ${file_path}`,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
if (size > 10 * 1024 * 1024) {
|
||||
return {
|
||||
data: {
|
||||
id: '',
|
||||
url: '',
|
||||
expiresAt: '',
|
||||
error: `File is ${size} bytes; backend limit is 10MB.`,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
let html: string
|
||||
try {
|
||||
html = await readFile(file_path, 'utf8')
|
||||
} catch {
|
||||
return {
|
||||
data: {
|
||||
id: '',
|
||||
url: '',
|
||||
expiresAt: '',
|
||||
error: `Failed to read file: ${file_path}`,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await uploadArtifact({
|
||||
html,
|
||||
token: getArtifactsToken(),
|
||||
uploadUrl: getUploadUrl(),
|
||||
hash,
|
||||
ttl,
|
||||
})
|
||||
return { data: result }
|
||||
} catch (e) {
|
||||
const message = e instanceof Error ? e.message : String(e)
|
||||
return { data: { id: '', url: '', expiresAt: '', error: message } }
|
||||
}
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user