mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-05 06:24:37 +00:00
feat: add file upload support
This commit is contained in:
@@ -24,6 +24,7 @@ import type {
|
||||
import { AgentStateSchema, CliMessagesResponseSchema, MetadataSchema, UserMessageSchema } from './types'
|
||||
import { RpcHandlerManager } from './rpc/RpcHandlerManager'
|
||||
import { registerCommonHandlers } from '../modules/common/registerCommonHandlers'
|
||||
import { cleanupUploadDir } from '../modules/common/handlers/uploads'
|
||||
import { TerminalManager } from '@/terminal/TerminalManager'
|
||||
import {
|
||||
TerminalClosePayloadSchema,
|
||||
@@ -450,6 +451,7 @@ export class ApiSessionClient extends EventEmitter {
|
||||
}
|
||||
|
||||
sendSessionDeath(): void {
|
||||
void cleanupUploadDir(this.sessionId)
|
||||
this.socket.emit('session-end', { sid: this.sessionId, time: Date.now() })
|
||||
}
|
||||
|
||||
|
||||
+13
-1
@@ -172,11 +172,23 @@ export const MessageMetaSchema = z.object({
|
||||
|
||||
export type MessageMeta = z.infer<typeof MessageMetaSchema>
|
||||
|
||||
export const AttachmentMetadataSchema = z.object({
|
||||
id: z.string(),
|
||||
filename: z.string(),
|
||||
mimeType: z.string(),
|
||||
size: z.number(),
|
||||
path: z.string(),
|
||||
previewUrl: z.string().optional()
|
||||
})
|
||||
|
||||
export type AttachmentMetadata = z.infer<typeof AttachmentMetadataSchema>
|
||||
|
||||
export const UserMessageSchema = z.object({
|
||||
role: z.literal('user'),
|
||||
content: z.object({
|
||||
type: z.literal('text'),
|
||||
text: z.string()
|
||||
text: z.string(),
|
||||
attachments: z.array(AttachmentMetadataSchema).optional()
|
||||
}),
|
||||
localKey: z.string().optional(),
|
||||
meta: MessageMetaSchema.optional()
|
||||
|
||||
@@ -16,6 +16,7 @@ import { bootstrapSession } from '@/agent/sessionFactory';
|
||||
import { createModeChangeHandler, createRunnerLifecycle, setControlledByUser } from '@/agent/runnerLifecycle';
|
||||
import { isModelModeAllowedForFlavor, isPermissionModeAllowedForFlavor } from '@hapi/protocol';
|
||||
import { ModelModeSchema, PermissionModeSchema } from '@hapi/protocol/schemas';
|
||||
import { formatMessageWithAttachments } from '@/utils/attachmentFormatter';
|
||||
|
||||
export interface StartOptions {
|
||||
model?: string
|
||||
@@ -218,6 +219,9 @@ export async function runClaude(options: StartOptions = {}): Promise<void> {
|
||||
// Check for special commands before processing
|
||||
const specialCommand = parseSpecialCommand(message.content.text);
|
||||
|
||||
// Format message text with attachments for Claude
|
||||
const formattedText = formatMessageWithAttachments(message.content.text, message.content.attachments);
|
||||
|
||||
if (specialCommand.type === 'compact') {
|
||||
logger.debug('[start] Detected /compact command');
|
||||
const enhancedMode: EnhancedMode = {
|
||||
@@ -229,7 +233,9 @@ export async function runClaude(options: StartOptions = {}): Promise<void> {
|
||||
allowedTools: messageAllowedTools,
|
||||
disallowedTools: messageDisallowedTools
|
||||
};
|
||||
messageQueue.pushIsolateAndClear(specialCommand.originalMessage || message.content.text, enhancedMode);
|
||||
// Use raw text only, ignore attachments for special commands
|
||||
const commandText = specialCommand.originalMessage || message.content.text;
|
||||
messageQueue.pushIsolateAndClear(commandText, enhancedMode);
|
||||
logger.debugLargeJson('[start] /compact command pushed to queue:', message);
|
||||
return;
|
||||
}
|
||||
@@ -245,8 +251,10 @@ export async function runClaude(options: StartOptions = {}): Promise<void> {
|
||||
allowedTools: messageAllowedTools,
|
||||
disallowedTools: messageDisallowedTools
|
||||
};
|
||||
messageQueue.pushIsolateAndClear(specialCommand.originalMessage || message.content.text, enhancedMode);
|
||||
logger.debugLargeJson('[start] /compact command pushed to queue:', message);
|
||||
// Use raw text only, ignore attachments for special commands
|
||||
const commandText = specialCommand.originalMessage || message.content.text;
|
||||
messageQueue.pushIsolateAndClear(commandText, enhancedMode);
|
||||
logger.debugLargeJson('[start] /clear command pushed to queue:', message);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -260,7 +268,7 @@ export async function runClaude(options: StartOptions = {}): Promise<void> {
|
||||
allowedTools: messageAllowedTools,
|
||||
disallowedTools: messageDisallowedTools
|
||||
};
|
||||
messageQueue.push(message.content.text, enhancedMode);
|
||||
messageQueue.push(formattedText, enhancedMode);
|
||||
logger.debugLargeJson('User message pushed to queue:', message)
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
import { logger } from '@/ui/logger'
|
||||
import { mkdtemp, rm, writeFile } from 'fs/promises'
|
||||
import { join, resolve, sep } from 'path'
|
||||
import { tmpdir } from 'os'
|
||||
import { rmSync } from 'node:fs'
|
||||
import type { RpcHandlerManager } from '@/api/rpc/RpcHandlerManager'
|
||||
import { getErrorMessage, rpcError } from '../rpcResponses'
|
||||
|
||||
interface UploadFileRequest {
|
||||
sessionId?: string
|
||||
filename: string
|
||||
content: string // base64 encoded
|
||||
mimeType: string
|
||||
}
|
||||
|
||||
interface UploadFileResponse {
|
||||
success: boolean
|
||||
path?: string
|
||||
error?: string
|
||||
}
|
||||
|
||||
interface DeleteUploadRequest {
|
||||
sessionId?: string
|
||||
path: string
|
||||
}
|
||||
|
||||
interface DeleteUploadResponse {
|
||||
success: boolean
|
||||
error?: string
|
||||
}
|
||||
|
||||
const uploadDirs = new Map<string, string>()
|
||||
const uploadDirPromises = new Map<string, Promise<string>>()
|
||||
const uploadDirCleanupRequested = new Set<string>()
|
||||
let cleanupRegistered = false
|
||||
const MAX_UPLOAD_BYTES = 50 * 1024 * 1024
|
||||
|
||||
function sanitizeFilename(filename: string): string {
|
||||
// Remove path separators and limit length
|
||||
const sanitized = filename
|
||||
.replace(/[/\\]/g, '_')
|
||||
.replace(/\.\./g, '_')
|
||||
.replace(/\s+/g, '_')
|
||||
.slice(0, 255)
|
||||
|
||||
// If filename is empty after sanitization, use a default
|
||||
return sanitized || 'upload'
|
||||
}
|
||||
|
||||
function getSessionKey(sessionId?: string): string {
|
||||
const trimmed = sessionId?.trim()
|
||||
return trimmed ? trimmed : 'unknown'
|
||||
}
|
||||
|
||||
function estimateBase64Bytes(base64: string): number {
|
||||
const len = base64.length
|
||||
if (len === 0) return 0
|
||||
const padding = base64.endsWith('==') ? 2 : base64.endsWith('=') ? 1 : 0
|
||||
return Math.floor((len * 3) / 4) - padding
|
||||
}
|
||||
|
||||
async function getOrCreateUploadDir(sessionId?: string): Promise<string> {
|
||||
const sessionKey = getSessionKey(sessionId)
|
||||
const existing = uploadDirs.get(sessionKey)
|
||||
if (existing) {
|
||||
return existing
|
||||
}
|
||||
|
||||
const inflight = uploadDirPromises.get(sessionKey)
|
||||
if (inflight) {
|
||||
return await inflight
|
||||
}
|
||||
|
||||
const safeKey = sanitizeFilename(sessionKey)
|
||||
const creation = (async () => {
|
||||
try {
|
||||
const dir = await mkdtemp(join(tmpdir(), `hapi-uploads-${safeKey}-`))
|
||||
if (uploadDirCleanupRequested.has(sessionKey)) {
|
||||
try {
|
||||
await rm(dir, { recursive: true, force: true })
|
||||
} catch (error) {
|
||||
logger.debug('Failed to cleanup upload directory after cancel:', error)
|
||||
}
|
||||
throw new Error('Upload directory cleanup requested')
|
||||
}
|
||||
uploadDirs.set(sessionKey, dir)
|
||||
return dir
|
||||
} finally {
|
||||
uploadDirPromises.delete(sessionKey)
|
||||
}
|
||||
})()
|
||||
uploadDirPromises.set(sessionKey, creation)
|
||||
return await creation
|
||||
}
|
||||
|
||||
export async function cleanupUploadDir(sessionId?: string): Promise<void> {
|
||||
const sessionKey = getSessionKey(sessionId)
|
||||
uploadDirCleanupRequested.add(sessionKey)
|
||||
|
||||
try {
|
||||
const inflight = uploadDirPromises.get(sessionKey)
|
||||
if (inflight) {
|
||||
try {
|
||||
await inflight
|
||||
} catch {
|
||||
// ignore inflight errors
|
||||
}
|
||||
}
|
||||
|
||||
const dir = uploadDirs.get(sessionKey)
|
||||
uploadDirs.delete(sessionKey)
|
||||
uploadDirPromises.delete(sessionKey)
|
||||
|
||||
if (!dir) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await rm(dir, { recursive: true, force: true })
|
||||
} catch (error) {
|
||||
logger.debug('Failed to cleanup upload directory:', error)
|
||||
}
|
||||
} finally {
|
||||
uploadDirCleanupRequested.delete(sessionKey)
|
||||
}
|
||||
}
|
||||
|
||||
function cleanupUploadDirsSync(): void {
|
||||
const dirs = Array.from(uploadDirs.values())
|
||||
uploadDirs.clear()
|
||||
uploadDirPromises.clear()
|
||||
uploadDirCleanupRequested.clear()
|
||||
|
||||
for (const dir of dirs) {
|
||||
try {
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
} catch (error) {
|
||||
logger.debug('Failed to cleanup upload directory on exit:', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function isPathWithinUploadDir(path: string, sessionId?: string): boolean {
|
||||
const sessionKey = getSessionKey(sessionId)
|
||||
const resolvedPath = resolve(path)
|
||||
const activeDir = uploadDirs.get(sessionKey)
|
||||
if (activeDir) {
|
||||
const resolvedDir = resolve(activeDir)
|
||||
const dirPrefix = resolvedDir.endsWith(sep) ? resolvedDir : `${resolvedDir}${sep}`
|
||||
return resolvedPath.startsWith(dirPrefix)
|
||||
}
|
||||
|
||||
const safeKey = sanitizeFilename(sessionKey)
|
||||
const resolvedPrefix = resolve(tmpdir(), `hapi-uploads-${safeKey}-`)
|
||||
return resolvedPath.startsWith(resolvedPrefix)
|
||||
}
|
||||
|
||||
export function registerUploadHandlers(rpcHandlerManager: RpcHandlerManager): void {
|
||||
if (!cleanupRegistered) {
|
||||
cleanupRegistered = true
|
||||
process.once('exit', cleanupUploadDirsSync)
|
||||
}
|
||||
|
||||
rpcHandlerManager.registerHandler<UploadFileRequest, UploadFileResponse>('uploadFile', async (data) => {
|
||||
logger.debug('Upload file request:', data.filename, 'mimeType:', data.mimeType)
|
||||
|
||||
if (!data.filename) {
|
||||
return rpcError('Filename is required')
|
||||
}
|
||||
|
||||
if (!data.content) {
|
||||
return rpcError('Content is required')
|
||||
}
|
||||
|
||||
try {
|
||||
const estimatedBytes = estimateBase64Bytes(data.content)
|
||||
if (estimatedBytes > MAX_UPLOAD_BYTES) {
|
||||
return rpcError('File too large (max 50MB)')
|
||||
}
|
||||
|
||||
const dir = await getOrCreateUploadDir(data.sessionId)
|
||||
const sanitizedFilename = sanitizeFilename(data.filename)
|
||||
|
||||
// Add timestamp to avoid collisions
|
||||
const timestamp = Date.now()
|
||||
const uniqueFilename = `${timestamp}-${sanitizedFilename}`
|
||||
const filePath = join(dir, uniqueFilename)
|
||||
|
||||
// Decode base64 content and write to file
|
||||
const buffer = Buffer.from(data.content, 'base64')
|
||||
if (buffer.length > MAX_UPLOAD_BYTES) {
|
||||
return rpcError('File too large (max 50MB)')
|
||||
}
|
||||
await writeFile(filePath, buffer)
|
||||
|
||||
logger.debug('File uploaded successfully:', filePath)
|
||||
return { success: true, path: filePath }
|
||||
} catch (error) {
|
||||
logger.debug('Failed to upload file:', error)
|
||||
return rpcError(getErrorMessage(error, 'Failed to upload file'))
|
||||
}
|
||||
})
|
||||
|
||||
rpcHandlerManager.registerHandler<DeleteUploadRequest, DeleteUploadResponse>('deleteUpload', async (data) => {
|
||||
const path = data?.path?.trim()
|
||||
if (!path) {
|
||||
return rpcError('Path is required')
|
||||
}
|
||||
|
||||
if (!isPathWithinUploadDir(path, data.sessionId)) {
|
||||
return rpcError('Invalid upload path')
|
||||
}
|
||||
|
||||
try {
|
||||
await rm(path, { force: true })
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
logger.debug('Failed to delete upload file:', error)
|
||||
return rpcError(getErrorMessage(error, 'Failed to delete upload file'))
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { registerFileHandlers } from './handlers/files'
|
||||
import { registerGitHandlers } from './handlers/git'
|
||||
import { registerRipgrepHandlers } from './handlers/ripgrep'
|
||||
import { registerSlashCommandHandlers } from './handlers/slashCommands'
|
||||
import { registerUploadHandlers } from './handlers/uploads'
|
||||
|
||||
export function registerCommonHandlers(rpcHandlerManager: RpcHandlerManager, workingDirectory: string): void {
|
||||
registerBashHandlers(rpcHandlerManager, workingDirectory)
|
||||
@@ -15,4 +16,5 @@ export function registerCommonHandlers(rpcHandlerManager: RpcHandlerManager, wor
|
||||
registerDifftasticHandlers(rpcHandlerManager, workingDirectory)
|
||||
registerSlashCommandHandlers(rpcHandlerManager)
|
||||
registerGitHandlers(rpcHandlerManager, workingDirectory)
|
||||
registerUploadHandlers(rpcHandlerManager)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { AttachmentMetadata } from '@/api/types'
|
||||
|
||||
/**
|
||||
* Formats attachments for Claude by converting them to @path references.
|
||||
* Claude understands the @path format for file references.
|
||||
*/
|
||||
export function formatAttachmentsForClaude(attachments: AttachmentMetadata[] | undefined): string {
|
||||
if (!attachments || attachments.length === 0) {
|
||||
return ''
|
||||
}
|
||||
return attachments.map(a => `@${a.path}`).join(' ')
|
||||
}
|
||||
|
||||
/**
|
||||
* Combines text and formatted attachments into a single prompt string.
|
||||
* Attachments are formatted as @path references and prepended to the text.
|
||||
*/
|
||||
export function formatMessageWithAttachments(
|
||||
text: string,
|
||||
attachments: AttachmentMetadata[] | undefined
|
||||
): string {
|
||||
const attachmentText = formatAttachmentsForClaude(attachments)
|
||||
if (!attachmentText) {
|
||||
return text
|
||||
}
|
||||
if (!text) {
|
||||
return attachmentText
|
||||
}
|
||||
return `${attachmentText}\n\n${text}`
|
||||
}
|
||||
Reference in New Issue
Block a user