From 53e4de6684630728b8792e1ca9ec47d2362171f8 Mon Sep 17 00:00:00 2001 From: weishu Date: Sat, 17 Jan 2026 18:00:08 +0800 Subject: [PATCH] feat: add file upload support --- cli/src/api/apiSession.ts | 2 + cli/src/api/types.ts | 14 +- cli/src/claude/runClaude.ts | 16 +- cli/src/modules/common/handlers/uploads.ts | 222 ++++++++++++++++++ .../modules/common/registerCommonHandlers.ts | 2 + cli/src/utils/attachmentFormatter.ts | 30 +++ server/src/sync/messageService.ts | 19 +- server/src/sync/rpcGateway.ts | 19 ++ server/src/sync/syncEngine.ts | 26 +- server/src/web/routes/messages.ts | 26 +- server/src/web/routes/sessions.ts | 85 +++++++ web/src/api/client.ts | 25 +- web/src/chat/normalizeUser.ts | 29 ++- web/src/chat/reducerTimeline.ts | 1 + web/src/chat/types.ts | 5 +- .../AssistantChat/AttachmentItem.tsx | 56 +++++ .../AssistantChat/ComposerButtons.tsx | 27 +++ .../AssistantChat/HappyComposer.tsx | 33 ++- .../messages/MessageAttachments.tsx | 71 ++++++ .../AssistantChat/messages/UserMessage.tsx | 12 +- web/src/components/SessionChat.tsx | 19 +- web/src/hooks/mutations/useSendMessage.ts | 19 +- web/src/lib/assistant-runtime.ts | 82 +++++-- web/src/lib/attachmentAdapter.ts | 180 ++++++++++++++ web/src/lib/fileAttachments.ts | 23 ++ web/src/lib/locales/en.ts | 3 +- web/src/lib/locales/zh-CN.ts | 1 + web/src/types/api.ts | 20 ++ 28 files changed, 1018 insertions(+), 49 deletions(-) create mode 100644 cli/src/modules/common/handlers/uploads.ts create mode 100644 cli/src/utils/attachmentFormatter.ts create mode 100644 web/src/components/AssistantChat/AttachmentItem.tsx create mode 100644 web/src/components/AssistantChat/messages/MessageAttachments.tsx create mode 100644 web/src/lib/attachmentAdapter.ts create mode 100644 web/src/lib/fileAttachments.ts diff --git a/cli/src/api/apiSession.ts b/cli/src/api/apiSession.ts index 803e2071..aeca16ac 100644 --- a/cli/src/api/apiSession.ts +++ b/cli/src/api/apiSession.ts @@ -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() }) } diff --git a/cli/src/api/types.ts b/cli/src/api/types.ts index 9c9dcf7a..f1abfa66 100644 --- a/cli/src/api/types.ts +++ b/cli/src/api/types.ts @@ -172,11 +172,23 @@ export const MessageMetaSchema = z.object({ export type MessageMeta = z.infer +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 + 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() diff --git a/cli/src/claude/runClaude.ts b/cli/src/claude/runClaude.ts index 262c2d82..b79dc9a9 100644 --- a/cli/src/claude/runClaude.ts +++ b/cli/src/claude/runClaude.ts @@ -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 { // 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 { 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 { 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 { allowedTools: messageAllowedTools, disallowedTools: messageDisallowedTools }; - messageQueue.push(message.content.text, enhancedMode); + messageQueue.push(formattedText, enhancedMode); logger.debugLargeJson('User message pushed to queue:', message) }); diff --git a/cli/src/modules/common/handlers/uploads.ts b/cli/src/modules/common/handlers/uploads.ts new file mode 100644 index 00000000..cd867594 --- /dev/null +++ b/cli/src/modules/common/handlers/uploads.ts @@ -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() +const uploadDirPromises = new Map>() +const uploadDirCleanupRequested = new Set() +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 { + 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 { + 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('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('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')) + } + }) +} diff --git a/cli/src/modules/common/registerCommonHandlers.ts b/cli/src/modules/common/registerCommonHandlers.ts index 33062e5f..8b815d42 100644 --- a/cli/src/modules/common/registerCommonHandlers.ts +++ b/cli/src/modules/common/registerCommonHandlers.ts @@ -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) } diff --git a/cli/src/utils/attachmentFormatter.ts b/cli/src/utils/attachmentFormatter.ts new file mode 100644 index 00000000..27af9f0a --- /dev/null +++ b/cli/src/utils/attachmentFormatter.ts @@ -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}` +} diff --git a/server/src/sync/messageService.ts b/server/src/sync/messageService.ts index 0cdf9a08..08fab90a 100644 --- a/server/src/sync/messageService.ts +++ b/server/src/sync/messageService.ts @@ -3,6 +3,15 @@ import type { Server } from 'socket.io' import type { Store } from '../store' import { EventPublisher } from './eventPublisher' +type AttachmentMetadata = { + id: string + filename: string + mimeType: string + size: number + path: string + previewUrl?: string +} + export class MessageService { constructor( private readonly store: Store, @@ -65,7 +74,12 @@ export class MessageService { async sendMessage( sessionId: string, - payload: { text: string; localId?: string | null; sentFrom?: 'telegram-bot' | 'webapp' } + payload: { + text: string + localId?: string | null + attachments?: AttachmentMetadata[] + sentFrom?: 'telegram-bot' | 'webapp' + } ): Promise { const sentFrom = payload.sentFrom ?? 'webapp' @@ -73,7 +87,8 @@ export class MessageService { role: 'user', content: { type: 'text', - text: payload.text + text: payload.text, + attachments: payload.attachments }, meta: { sentFrom diff --git a/server/src/sync/rpcGateway.ts b/server/src/sync/rpcGateway.ts index 5ca4da64..00adfc35 100644 --- a/server/src/sync/rpcGateway.ts +++ b/server/src/sync/rpcGateway.ts @@ -16,6 +16,17 @@ export type RpcReadFileResponse = { error?: string } +export type RpcUploadFileResponse = { + success: boolean + path?: string + error?: string +} + +export type RpcDeleteUploadResponse = { + success: boolean + error?: string +} + export type RpcPathExistsResponse = { exists: Record } @@ -142,6 +153,14 @@ export class RpcGateway { return await this.sessionRpc(sessionId, 'readFile', { path }) as RpcReadFileResponse } + async uploadFile(sessionId: string, filename: string, content: string, mimeType: string): Promise { + return await this.sessionRpc(sessionId, 'uploadFile', { sessionId, filename, content, mimeType }) as RpcUploadFileResponse + } + + async deleteUploadFile(sessionId: string, path: string): Promise { + return await this.sessionRpc(sessionId, 'deleteUpload', { sessionId, path }) as RpcDeleteUploadResponse + } + async runRipgrep(sessionId: string, args: string[], cwd?: string): Promise { return await this.sessionRpc(sessionId, 'ripgrep', { args, cwd }) as RpcCommandResponse } diff --git a/server/src/sync/syncEngine.ts b/server/src/sync/syncEngine.ts index 85ecb24b..65088725 100644 --- a/server/src/sync/syncEngine.ts +++ b/server/src/sync/syncEngine.ts @@ -15,13 +15,13 @@ import type { SSEManager } from '../sse/sseManager' import { EventPublisher, type SyncEventListener } from './eventPublisher' import { MachineCache, type Machine } from './machineCache' import { MessageService } from './messageService' -import { RpcGateway, type RpcCommandResponse, type RpcPathExistsResponse, type RpcReadFileResponse } from './rpcGateway' +import { RpcGateway, type RpcCommandResponse, type RpcPathExistsResponse, type RpcReadFileResponse, type RpcUploadFileResponse, type RpcDeleteUploadResponse } from './rpcGateway' import { SessionCache } from './sessionCache' export type { Session, SyncEvent } from '@hapi/protocol/types' export type { Machine } from './machineCache' export type { SyncEventListener } from './eventPublisher' -export type { RpcCommandResponse, RpcPathExistsResponse, RpcReadFileResponse } from './rpcGateway' +export type { RpcCommandResponse, RpcPathExistsResponse, RpcReadFileResponse, RpcUploadFileResponse, RpcDeleteUploadResponse } from './rpcGateway' export class SyncEngine { private readonly eventPublisher: EventPublisher @@ -189,7 +189,19 @@ export class SyncEngine { async sendMessage( sessionId: string, - payload: { text: string; localId?: string | null; sentFrom?: 'telegram-bot' | 'webapp' } + payload: { + text: string + localId?: string | null + attachments?: Array<{ + id: string + filename: string + mimeType: string + size: number + path: string + previewUrl?: string + }> + sentFrom?: 'telegram-bot' | 'webapp' + } ): Promise { await this.messageService.sendMessage(sessionId, payload) } @@ -285,6 +297,14 @@ export class SyncEngine { return await this.rpcGateway.readSessionFile(sessionId, path) } + async uploadFile(sessionId: string, filename: string, content: string, mimeType: string): Promise { + return await this.rpcGateway.uploadFile(sessionId, filename, content, mimeType) + } + + async deleteUploadFile(sessionId: string, path: string): Promise { + return await this.rpcGateway.deleteUploadFile(sessionId, path) + } + async runRipgrep(sessionId: string, args: string[], cwd?: string): Promise { return await this.rpcGateway.runRipgrep(sessionId, args, cwd) } diff --git a/server/src/web/routes/messages.ts b/server/src/web/routes/messages.ts index 0abe0c3f..f2f536b2 100644 --- a/server/src/web/routes/messages.ts +++ b/server/src/web/routes/messages.ts @@ -9,9 +9,19 @@ const querySchema = z.object({ beforeSeq: z.coerce.number().int().min(1).optional() }) +const attachmentMetadataSchema = z.object({ + id: z.string(), + filename: z.string(), + mimeType: z.string(), + size: z.number(), + path: z.string(), + previewUrl: z.string().optional() +}) + const sendMessageBodySchema = z.object({ - text: z.string().min(1), - localId: z.string().min(1).optional() + text: z.string(), + localId: z.string().min(1).optional(), + attachments: z.array(attachmentMetadataSchema).optional() }) export function createMessagesRoutes(getSyncEngine: () => SyncEngine | null): Hono { @@ -53,7 +63,17 @@ export function createMessagesRoutes(getSyncEngine: () => SyncEngine | null): Ho return c.json({ error: 'Invalid body' }, 400) } - await engine.sendMessage(sessionId, { text: parsed.data.text, localId: parsed.data.localId, sentFrom: 'webapp' }) + // Require text or attachments + if (!parsed.data.text && (!parsed.data.attachments || parsed.data.attachments.length === 0)) { + return c.json({ error: 'Message requires text or attachments' }, 400) + } + + await engine.sendMessage(sessionId, { + text: parsed.data.text, + localId: parsed.data.localId, + attachments: parsed.data.attachments, + sentFrom: 'webapp' + }) return c.json({ ok: true }) }) diff --git a/server/src/web/routes/sessions.ts b/server/src/web/routes/sessions.ts index 7978cc1a..96d93a5a 100644 --- a/server/src/web/routes/sessions.ts +++ b/server/src/web/routes/sessions.ts @@ -18,6 +18,25 @@ const renameSessionSchema = z.object({ name: z.string().min(1).max(255) }) +const uploadSchema = z.object({ + filename: z.string().min(1).max(255), + content: z.string().min(1), + mimeType: z.string().min(1).max(255) +}) + +const uploadDeleteSchema = z.object({ + path: z.string().min(1) +}) + +const MAX_UPLOAD_BYTES = 50 * 1024 * 1024 + +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 +} + export function createSessionsRoutes(getSyncEngine: () => SyncEngine | null): Hono { const app = new Hono() @@ -64,6 +83,72 @@ export function createSessionsRoutes(getSyncEngine: () => SyncEngine | null): Ho return c.json({ session: sessionResult.session }) }) + app.post('/sessions/:id/upload', async (c) => { + const engine = requireSyncEngine(c, getSyncEngine) + if (engine instanceof Response) { + return engine + } + + const sessionResult = requireSessionFromParam(c, engine, { requireActive: true }) + if (sessionResult instanceof Response) { + return sessionResult + } + + const body = await c.req.json().catch(() => null) + const parsed = uploadSchema.safeParse(body) + if (!parsed.success) { + return c.json({ error: 'Invalid body' }, 400) + } + + const estimatedBytes = estimateBase64Bytes(parsed.data.content) + if (estimatedBytes > MAX_UPLOAD_BYTES) { + return c.json({ success: false, error: 'File too large (max 50MB)' }, 413) + } + + try { + const result = await engine.uploadFile( + sessionResult.sessionId, + parsed.data.filename, + parsed.data.content, + parsed.data.mimeType + ) + return c.json(result) + } catch (error) { + return c.json({ + success: false, + error: error instanceof Error ? error.message : 'Failed to upload file' + }, 500) + } + }) + + app.post('/sessions/:id/upload/delete', async (c) => { + const engine = requireSyncEngine(c, getSyncEngine) + if (engine instanceof Response) { + return engine + } + + const sessionResult = requireSessionFromParam(c, engine, { requireActive: true }) + if (sessionResult instanceof Response) { + return sessionResult + } + + const body = await c.req.json().catch(() => null) + const parsed = uploadDeleteSchema.safeParse(body) + if (!parsed.success) { + return c.json({ error: 'Invalid body' }, 400) + } + + try { + const result = await engine.deleteUploadFile(sessionResult.sessionId, parsed.data.path) + return c.json(result) + } catch (error) { + return c.json({ + success: false, + error: error instanceof Error ? error.message : 'Failed to delete upload' + }, 500) + } + }) + app.post('/sessions/:id/abort', async (c) => { const engine = requireSyncEngine(c, getSyncEngine) if (engine instanceof Response) { diff --git a/web/src/api/client.ts b/web/src/api/client.ts index ab4c2ac3..752bb19d 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -1,5 +1,7 @@ import type { + AttachmentMetadata, AuthResponse, + DeleteUploadResponse, FileReadResponse, FileSearchResponse, GitCommandResponse, @@ -13,6 +15,7 @@ import type { PushVapidPublicKeyResponse, SlashCommandsResponse, SpawnResponse, + UploadFileResponse, VisibilityPayload, SessionResponse, SessionsResponse @@ -235,10 +238,28 @@ export class ApiClient { return await this.request(`/api/sessions/${encodeURIComponent(sessionId)}/file?${params.toString()}`) } - async sendMessage(sessionId: string, text: string, localId?: string | null): Promise { + async uploadFile(sessionId: string, filename: string, content: string, mimeType: string): Promise { + return await this.request(`/api/sessions/${encodeURIComponent(sessionId)}/upload`, { + method: 'POST', + body: JSON.stringify({ filename, content, mimeType }) + }) + } + + async deleteUploadFile(sessionId: string, path: string): Promise { + return await this.request(`/api/sessions/${encodeURIComponent(sessionId)}/upload/delete`, { + method: 'POST', + body: JSON.stringify({ path }) + }) + } + + async sendMessage(sessionId: string, text: string, localId?: string | null, attachments?: AttachmentMetadata[]): Promise { await this.request(`/api/sessions/${encodeURIComponent(sessionId)}/messages`, { method: 'POST', - body: JSON.stringify({ text, localId: localId ?? undefined }) + body: JSON.stringify({ + text, + localId: localId ?? undefined, + attachments: attachments ?? undefined + }) }) } diff --git a/web/src/chat/normalizeUser.ts b/web/src/chat/normalizeUser.ts index 919e8745..023fa7a6 100644 --- a/web/src/chat/normalizeUser.ts +++ b/web/src/chat/normalizeUser.ts @@ -1,6 +1,32 @@ import type { NormalizedMessage } from '@/chat/types' +import type { AttachmentMetadata } from '@/types/api' import { isObject } from '@/chat/normalizeUtils' +function parseAttachments(raw: unknown): AttachmentMetadata[] | undefined { + if (!Array.isArray(raw)) return undefined + const attachments: AttachmentMetadata[] = [] + for (const item of raw) { + if ( + isObject(item) && + typeof item.id === 'string' && + typeof item.filename === 'string' && + typeof item.mimeType === 'string' && + typeof item.size === 'number' && + typeof item.path === 'string' + ) { + attachments.push({ + id: item.id, + filename: item.filename, + mimeType: item.mimeType, + size: item.size, + path: item.path, + previewUrl: typeof item.previewUrl === 'string' ? item.previewUrl : undefined + }) + } + } + return attachments.length > 0 ? attachments : undefined +} + export function normalizeUserRecord( messageId: string, localId: string | null, @@ -21,12 +47,13 @@ export function normalizeUserRecord( } if (isObject(content) && content.type === 'text' && typeof content.text === 'string') { + const attachments = parseAttachments(content.attachments) return { id: messageId, localId, createdAt, role: 'user', - content: { type: 'text', text: content.text }, + content: { type: 'text', text: content.text, attachments }, isSidechain: false, meta } diff --git a/web/src/chat/reducerTimeline.ts b/web/src/chat/reducerTimeline.ts index 28079eb6..22c9e765 100644 --- a/web/src/chat/reducerTimeline.ts +++ b/web/src/chat/reducerTimeline.ts @@ -64,6 +64,7 @@ export function reduceTimeline( localId: msg.localId, createdAt: msg.createdAt, text: msg.content.text, + attachments: msg.content.attachments, status: msg.status, originalText: msg.originalText, meta: msg.meta diff --git a/web/src/chat/types.ts b/web/src/chat/types.ts index 84d204b4..fd17d2c7 100644 --- a/web/src/chat/types.ts +++ b/web/src/chat/types.ts @@ -1,4 +1,4 @@ -import type { MessageStatus } from '@/types/api' +import type { AttachmentMetadata, MessageStatus } from '@/types/api' export type UsageData = { input_tokens: number @@ -66,7 +66,7 @@ export type NormalizedAgentContent = export type NormalizedMessage = ({ role: 'user' - content: { type: 'text'; text: string } + content: { type: 'text'; text: string; attachments?: AttachmentMetadata[] } } | { role: 'agent' content: NormalizedAgentContent[] @@ -116,6 +116,7 @@ export type UserTextBlock = { localId: string | null createdAt: number text: string + attachments?: AttachmentMetadata[] status?: MessageStatus originalText?: string meta?: unknown diff --git a/web/src/components/AssistantChat/AttachmentItem.tsx b/web/src/components/AssistantChat/AttachmentItem.tsx new file mode 100644 index 00000000..a4209fe3 --- /dev/null +++ b/web/src/components/AssistantChat/AttachmentItem.tsx @@ -0,0 +1,56 @@ +import { AttachmentPrimitive, useThreadComposerAttachment } from '@assistant-ui/react' +import { Spinner } from '@/components/Spinner' + +function ErrorIcon() { + return ( + + + + + + ) +} + +function RemoveIcon() { + return ( + + + + + ) +} + +export function AttachmentItem() { + const { name, status } = useThreadComposerAttachment() + const isUploading = status.type === 'running' + const isError = status.type === 'incomplete' + + return ( + + {isUploading ? : null} + {isError ? ( + + + + ) : null} + {name} + + + + + ) +} diff --git a/web/src/components/AssistantChat/ComposerButtons.tsx b/web/src/components/AssistantChat/ComposerButtons.tsx index 7d2a084f..3c983a58 100644 --- a/web/src/components/AssistantChat/ComposerButtons.tsx +++ b/web/src/components/AssistantChat/ComposerButtons.tsx @@ -59,6 +59,24 @@ function TerminalIcon() { ) } +function AttachmentIcon() { + return ( + + + + ) +} + function AbortIcon(props: { spinning: boolean }) { if (props.spinning) { return ( @@ -132,6 +150,15 @@ export function ComposerButtons(props: { return (
+ + + + {props.showSettingsButton ? (