From d4925719fc91084970745e9c4a9b13f0de4cb9c1 Mon Sep 17 00:00:00 2001 From: hailin Date: Sat, 10 Jan 2026 05:34:41 -0800 Subject: [PATCH] feat(multimodal): add file upload and image support for chat - Add MinIO object storage to docker-compose infrastructure - Create file-service microservice for upload management with presigned URLs - Add files table to database schema - Update nginx and Kong for MinIO proxy routes - Implement file upload UI in chat InputArea with drag-and-drop - Add attachment preview in MessageBubble component - Update conversation-service to handle multimodal messages - Add Claude Vision API integration for image analysis Co-Authored-By: Claude Opus 4.5 --- .claude/settings.local.json | 3 +- docker-compose.yml | 83 ++- infrastructure/minio/README.md | 58 ++ infrastructure/minio/init-buckets.sh | 71 +++ kong/kong.yml | 24 + nginx/conf.d/default.conf | 25 + .../src/conversation/conversation.gateway.ts | 14 +- .../src/conversation/conversation.service.ts | 37 +- .../src/domain/entities/message.entity.ts | 1 + .../claude/claude-agent.service.ts | 106 +++- packages/services/file-service/Dockerfile | 74 +++ packages/services/file-service/nest-cli.json | 8 + packages/services/file-service/package.json | 48 ++ .../services/file-service/src/app.module.ts | 43 ++ .../src/domain/entities/file.entity.ts | 79 +++ .../src/file/dto/upload-file.dto.ts | 38 ++ .../file-service/src/file/file.controller.ts | 114 ++++ .../file-service/src/file/file.module.ts | 21 + .../file-service/src/file/file.service.ts | 320 ++++++++++ .../src/health/health.controller.ts | 9 + .../file-service/src/health/health.module.ts | 7 + packages/services/file-service/src/main.ts | 34 + .../file-service/src/minio/minio.module.ts | 11 + .../file-service/src/minio/minio.service.ts | 140 +++++ packages/services/file-service/tsconfig.json | 26 + .../presentation/components/ChatWindow.tsx | 19 +- .../presentation/components/InputArea.tsx | 243 +++++++- .../presentation/components/MessageBubble.tsx | 174 ++++-- .../chat/presentation/hooks/useChat.ts | 203 +++++- .../chat/presentation/stores/chatStore.ts | 11 + .../src/shared/hooks/useFileUpload.ts | 185 ++++++ .../src/shared/services/fileService.ts | 304 +++++++++ pnpm-lock.yaml | 590 +++++++++++++++++- scripts/init-db.sql | 54 ++ 34 files changed, 3046 insertions(+), 131 deletions(-) create mode 100644 infrastructure/minio/README.md create mode 100644 infrastructure/minio/init-buckets.sh create mode 100644 packages/services/file-service/Dockerfile create mode 100644 packages/services/file-service/nest-cli.json create mode 100644 packages/services/file-service/package.json create mode 100644 packages/services/file-service/src/app.module.ts create mode 100644 packages/services/file-service/src/domain/entities/file.entity.ts create mode 100644 packages/services/file-service/src/file/dto/upload-file.dto.ts create mode 100644 packages/services/file-service/src/file/file.controller.ts create mode 100644 packages/services/file-service/src/file/file.module.ts create mode 100644 packages/services/file-service/src/file/file.service.ts create mode 100644 packages/services/file-service/src/health/health.controller.ts create mode 100644 packages/services/file-service/src/health/health.module.ts create mode 100644 packages/services/file-service/src/main.ts create mode 100644 packages/services/file-service/src/minio/minio.module.ts create mode 100644 packages/services/file-service/src/minio/minio.service.ts create mode 100644 packages/services/file-service/tsconfig.json create mode 100644 packages/web-client/src/shared/hooks/useFileUpload.ts create mode 100644 packages/web-client/src/shared/services/fileService.ts diff --git a/.claude/settings.local.json b/.claude/settings.local.json index f30b18e..f92271b 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -18,7 +18,8 @@ "Bash(echo \"No health endpoint\" curl -s https://iconsulting.szaiai.com/api/v1/users/profile -H \"x-user-id: test\")", "Bash(scp:*)", "Bash(timeout 20 cat:*)", - "Bash(npm run build:*)" + "Bash(npm run build:*)", + "Bash(pnpm run build:*)" ] } } diff --git a/docker-compose.yml b/docker-compose.yml index 7b65e76..a21352b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -2,9 +2,9 @@ # iConsulting Docker Compose 配置 # # 服务架构: -# - 基础设施: PostgreSQL, Redis, Neo4j +# - 基础设施: PostgreSQL, Redis, Neo4j, MinIO # - API网关: Kong -# - 后端服务: conversation, user, payment, knowledge, evolution +# - 后端服务: conversation, user, payment, knowledge, evolution, file # - 前端服务: nginx (托管 web-client 和 admin-client) # # 网络配置: @@ -80,6 +80,43 @@ services: networks: - iconsulting-network + minio: + image: minio/minio:latest + container_name: iconsulting-minio + restart: unless-stopped + command: server /data --console-address ":9001" + environment: + MINIO_ROOT_USER: ${MINIO_ROOT_USER:-minioadmin} + MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:-minioadmin123} + ports: + - "9000:9000" # API + - "9001:9001" # Console + volumes: + - minio_data:/data + healthcheck: + test: ["CMD", "mc", "ready", "local"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 10s + networks: + - iconsulting-network + + minio-init: + image: minio/mc:latest + container_name: iconsulting-minio-init + depends_on: + minio: + condition: service_healthy + environment: + MINIO_ROOT_USER: ${MINIO_ROOT_USER:-minioadmin} + MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:-minioadmin123} + volumes: + - ./infrastructure/minio/init-buckets.sh:/init-buckets.sh:ro + entrypoint: ["/bin/sh", "/init-buckets.sh"] + networks: + - iconsulting-network + #============================================================================= # Kong API 网关 (DB-less 模式) #============================================================================= @@ -297,6 +334,46 @@ services: networks: - iconsulting-network + file-service: + build: + context: . + dockerfile: packages/services/file-service/Dockerfile + container_name: iconsulting-file + restart: unless-stopped + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + minio: + condition: service_healthy + environment: + NODE_ENV: production + PORT: 3006 + POSTGRES_HOST: postgres + POSTGRES_PORT: 5432 + POSTGRES_USER: ${POSTGRES_USER:-postgres} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres} + POSTGRES_DB: ${POSTGRES_DB:-iconsulting} + DATABASE_URL: postgresql://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-postgres}@postgres:5432/${POSTGRES_DB:-iconsulting} + REDIS_URL: redis://:${REDIS_PASSWORD:-redis123}@redis:6379 + MINIO_ENDPOINT: minio + MINIO_PORT: 9000 + MINIO_ACCESS_KEY: ${MINIO_ROOT_USER:-minioadmin} + MINIO_SECRET_KEY: ${MINIO_ROOT_PASSWORD:-minioadmin123} + MINIO_BUCKET: iconsulting + MINIO_USE_SSL: "false" + ports: + - "3006:3006" + healthcheck: + test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://127.0.0.1:3006/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 40s + networks: + - iconsulting-network + #============================================================================= # 前端 Nginx #============================================================================= @@ -349,3 +426,5 @@ volumes: driver: local neo4j_logs: driver: local + minio_data: + driver: local diff --git a/infrastructure/minio/README.md b/infrastructure/minio/README.md new file mode 100644 index 0000000..eb01b34 --- /dev/null +++ b/infrastructure/minio/README.md @@ -0,0 +1,58 @@ +# MinIO 对象存储 + +iConsulting 项目的文件存储基础设施。 + +## 概述 + +MinIO 是一个高性能的 S3 兼容对象存储服务,用于存储: +- 用户上传的图片 +- PDF 文档 +- 其他附件文件 + +## 访问地址 + +- **API 端点**: http://localhost:9000 +- **管理控制台**: http://localhost:9001 + +## 默认凭据 + +``` +用户名: minioadmin +密码: minioadmin123 +``` + +> 生产环境请修改 `.env` 中的 `MINIO_ROOT_USER` 和 `MINIO_ROOT_PASSWORD` + +## Bucket 结构 + +``` +iconsulting/ +├── uploads/ # 用户上传的原始文件 +│ ├── images/ # 图片文件 +│ ├── documents/ # PDF/文档 +│ └── temp/ # 临时文件 +├── processed/ # 处理后的文件 +│ ├── thumbnails/ # 缩略图 +│ └── extracted/ # 提取的内容 +└── exports/ # 导出文件 +``` + +## 文件命名规则 + +``` +{bucket}/{category}/{userId}/{year}/{month}/{uuid}.{ext} + +示例: uploads/images/user123/2025/01/550e8400-e29b-41d4-a716-446655440000.jpg +``` + +## 生命周期策略 + +- `temp/` 目录: 24小时后自动删除 +- `processed/thumbnails/`: 30天后自动删除 +- 其他文件: 永久保留 + +## 安全配置 + +1. 所有 bucket 默认私有 +2. 通过预签名 URL 提供临时访问 +3. 支持服务端加密 (SSE) diff --git a/infrastructure/minio/init-buckets.sh b/infrastructure/minio/init-buckets.sh new file mode 100644 index 0000000..9a96de8 --- /dev/null +++ b/infrastructure/minio/init-buckets.sh @@ -0,0 +1,71 @@ +#!/bin/sh +# MinIO Bucket 初始化脚本 +# 在 MinIO 启动后执行,创建必要的 bucket 和策略 + +set -e + +# 等待 MinIO 启动 +echo "Waiting for MinIO to start..." +until mc alias set myminio http://minio:9000 ${MINIO_ROOT_USER} ${MINIO_ROOT_PASSWORD} > /dev/null 2>&1; do + echo "MinIO is unavailable - sleeping" + sleep 2 +done + +echo "MinIO is up - creating buckets" + +# 创建主 bucket +mc mb --ignore-existing myminio/iconsulting + +# 设置 bucket 策略为私有 +mc anonymous set none myminio/iconsulting + +# 创建目录结构(通过创建空对象) +echo "Creating directory structure..." + +# uploads 目录 +mc cp /dev/null myminio/iconsulting/uploads/images/.keep 2>/dev/null || true +mc cp /dev/null myminio/iconsulting/uploads/documents/.keep 2>/dev/null || true +mc cp /dev/null myminio/iconsulting/uploads/temp/.keep 2>/dev/null || true + +# processed 目录 +mc cp /dev/null myminio/iconsulting/processed/thumbnails/.keep 2>/dev/null || true +mc cp /dev/null myminio/iconsulting/processed/extracted/.keep 2>/dev/null || true + +# exports 目录 +mc cp /dev/null myminio/iconsulting/exports/.keep 2>/dev/null || true + +# 设置生命周期规则 - temp 目录 24小时后删除 +echo "Setting lifecycle rules..." +cat > /tmp/lifecycle.json << 'EOF' +{ + "Rules": [ + { + "ID": "temp-cleanup", + "Status": "Enabled", + "Filter": { + "Prefix": "uploads/temp/" + }, + "Expiration": { + "Days": 1 + } + }, + { + "ID": "thumbnails-cleanup", + "Status": "Enabled", + "Filter": { + "Prefix": "processed/thumbnails/" + }, + "Expiration": { + "Days": 30 + } + } + ] +} +EOF + +mc ilm import myminio/iconsulting < /tmp/lifecycle.json || echo "Lifecycle rules may already exist" + +echo "MinIO initialization completed!" +echo "Bucket: iconsulting" +echo "API: http://minio:9000" +echo "Console: http://minio:9001" diff --git a/kong/kong.yml b/kong/kong.yml index a9c8be5..554edc4 100644 --- a/kong/kong.yml +++ b/kong/kong.yml @@ -10,6 +10,7 @@ # - knowledge-service: 知识库服务 (3003) # - conversation-service: 对话服务 (3004) # - evolution-service: 进化服务 (3005) +# - file-service: 文件管理服务 (3006) # #=============================================================================== @@ -143,6 +144,29 @@ services: - DELETE - OPTIONS + #----------------------------------------------------------------------------- + # File Service - 文件管理服务 + #----------------------------------------------------------------------------- + - name: file-service + url: http://file-service:3006 + connect_timeout: 60000 + write_timeout: 300000 + read_timeout: 300000 + retries: 2 + routes: + - name: file-routes + paths: + - /api/v1/files + strip_path: false + preserve_host: true + methods: + - GET + - POST + - PUT + - PATCH + - DELETE + - OPTIONS + #=============================================================================== # 全局插件配置 #=============================================================================== diff --git a/nginx/conf.d/default.conf b/nginx/conf.d/default.conf index 24ef532..e73043b 100644 --- a/nginx/conf.d/default.conf +++ b/nginx/conf.d/default.conf @@ -9,6 +9,7 @@ # /admin -> admin-client (管理后台) # /api/v1/* -> Kong API Gateway # /ws/* -> WebSocket (conversation-service) +# /storage/* -> MinIO (文件存储) # #=============================================================================== @@ -90,6 +91,30 @@ server { proxy_read_timeout 7d; } + # MinIO 文件存储代理 (用于访问预签名 URL) + location /storage/ { + set $minio_upstream minio:9000; + rewrite ^/storage/(.*)$ /$1 break; + proxy_pass http://$minio_upstream; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + # 上传超时设置 + proxy_connect_timeout 300s; + proxy_send_timeout 300s; + proxy_read_timeout 300s; + + # 允许大文件上传 + client_max_body_size 100M; + + # 缓冲设置 + proxy_buffering off; + proxy_request_buffering off; + } + # 禁止访问隐藏文件 location ~ /\. { deny all; diff --git a/packages/services/conversation-service/src/conversation/conversation.gateway.ts b/packages/services/conversation-service/src/conversation/conversation.gateway.ts index 0430e58..c56349e 100644 --- a/packages/services/conversation-service/src/conversation/conversation.gateway.ts +++ b/packages/services/conversation-service/src/conversation/conversation.gateway.ts @@ -10,9 +10,20 @@ import { import { Server, Socket } from 'socket.io'; import { ConversationService } from './conversation.service'; +interface FileAttachment { + id: string; + originalName: string; + mimeType: string; + type: 'image' | 'document' | 'audio' | 'video' | 'other'; + size: number; + downloadUrl?: string; + thumbnailUrl?: string; +} + interface SendMessagePayload { conversationId: string; content: string; + attachments?: FileAttachment[]; } @WebSocketGateway({ @@ -71,7 +82,7 @@ export class ConversationGateway return; } - const { conversationId, content } = payload; + const { conversationId, content, attachments } = payload; if (!conversationId || !content) { client.emit('error', { message: 'conversationId and content are required' }); @@ -92,6 +103,7 @@ export class ConversationGateway conversationId, userId, content, + attachments, })) { if (chunk.type === 'text' && chunk.content) { client.emit('stream_chunk', { diff --git a/packages/services/conversation-service/src/conversation/conversation.service.ts b/packages/services/conversation-service/src/conversation/conversation.service.ts index eba93a7..021c6e4 100644 --- a/packages/services/conversation-service/src/conversation/conversation.service.ts +++ b/packages/services/conversation-service/src/conversation/conversation.service.ts @@ -21,10 +21,21 @@ export interface CreateConversationDto { title?: string; } +export interface FileAttachment { + id: string; + originalName: string; + mimeType: string; + type: 'image' | 'document' | 'audio' | 'video' | 'other'; + size: number; + downloadUrl?: string; + thumbnailUrl?: string; +} + export interface SendMessageDto { conversationId: string; userId: string; content: string; + attachments?: FileAttachment[]; } @Injectable() @@ -105,12 +116,14 @@ export class ConversationService { throw new Error('Conversation is not active'); } - // Save user message + // Save user message with attachments if present + const hasAttachments = dto.attachments && dto.attachments.length > 0; const userMessage = this.messageRepo.create({ conversationId: dto.conversationId, role: MessageRole.USER, - type: MessageType.TEXT, + type: hasAttachments ? MessageType.TEXT_WITH_ATTACHMENTS : MessageType.TEXT, content: dto.content, + metadata: hasAttachments ? { attachments: dto.attachments } : undefined, }); await this.messageRepo.save(userMessage); @@ -121,24 +134,32 @@ export class ConversationService { take: 20, // Last 20 messages for context }); - // Build context + // Build context with support for multimodal messages const context: ConversationContext = { userId: dto.userId, conversationId: dto.conversationId, - previousMessages: previousMessages.map((m) => ({ - role: m.role as 'user' | 'assistant', - content: m.content, - })), + previousMessages: previousMessages.map((m) => { + const msg: { role: 'user' | 'assistant'; content: string; attachments?: FileAttachment[] } = { + role: m.role as 'user' | 'assistant', + content: m.content, + }; + // Include attachments from metadata if present + if (m.metadata?.attachments) { + msg.attachments = m.metadata.attachments as FileAttachment[]; + } + return msg; + }), }; // Collect full response for saving let fullResponse = ''; const toolCalls: Array<{ name: string; input: unknown; result: unknown }> = []; - // Stream response from Claude + // Stream response from Claude (with attachments for multimodal support) for await (const chunk of this.claudeAgentService.sendMessage( dto.content, context, + dto.attachments, )) { if (chunk.type === 'text' && chunk.content) { fullResponse += chunk.content; diff --git a/packages/services/conversation-service/src/domain/entities/message.entity.ts b/packages/services/conversation-service/src/domain/entities/message.entity.ts index 9a4b6ab..555869c 100644 --- a/packages/services/conversation-service/src/domain/entities/message.entity.ts +++ b/packages/services/conversation-service/src/domain/entities/message.entity.ts @@ -16,6 +16,7 @@ export enum MessageRole { export enum MessageType { TEXT = 'TEXT', + TEXT_WITH_ATTACHMENTS = 'TEXT_WITH_ATTACHMENTS', TOOL_CALL = 'TOOL_CALL', TOOL_RESULT = 'TOOL_RESULT', PAYMENT_REQUEST = 'PAYMENT_REQUEST', diff --git a/packages/services/conversation-service/src/infrastructure/claude/claude-agent.service.ts b/packages/services/conversation-service/src/infrastructure/claude/claude-agent.service.ts index dfbee50..80de01f 100644 --- a/packages/services/conversation-service/src/infrastructure/claude/claude-agent.service.ts +++ b/packages/services/conversation-service/src/infrastructure/claude/claude-agent.service.ts @@ -4,6 +4,16 @@ import Anthropic from '@anthropic-ai/sdk'; import { ImmigrationToolsService } from './tools/immigration-tools.service'; import { buildSystemPrompt, SystemPromptConfig } from './prompts/system-prompt'; +export interface FileAttachment { + id: string; + originalName: string; + mimeType: string; + type: 'image' | 'document' | 'audio' | 'video' | 'other'; + size: number; + downloadUrl?: string; + thumbnailUrl?: string; +} + export interface ConversationContext { userId: string; conversationId: string; @@ -11,6 +21,7 @@ export interface ConversationContext { previousMessages?: Array<{ role: 'user' | 'assistant'; content: string; + attachments?: FileAttachment[]; }>; } @@ -68,13 +79,71 @@ export class ClaudeAgentService implements OnModuleInit { }; } + /** + * Build multimodal content blocks for Claude Vision API + */ + private async buildMultimodalContent( + text: string, + attachments?: FileAttachment[], + ): Promise { + const content: Anthropic.ContentBlockParam[] = []; + + // Add image attachments first (Claude processes images before text) + if (attachments && attachments.length > 0) { + for (const attachment of attachments) { + if (attachment.type === 'image' && attachment.downloadUrl) { + try { + // Fetch the image and convert to base64 + const response = await fetch(attachment.downloadUrl); + if (response.ok) { + const buffer = await response.arrayBuffer(); + const base64Data = Buffer.from(buffer).toString('base64'); + + // Determine media type + const mediaType = attachment.mimeType as 'image/jpeg' | 'image/png' | 'image/gif' | 'image/webp'; + + content.push({ + type: 'image', + source: { + type: 'base64', + media_type: mediaType, + data: base64Data, + }, + }); + } + } catch (error) { + console.error(`Failed to fetch image ${attachment.originalName}:`, error); + } + } else if (attachment.type === 'document') { + // For documents, add a text reference + content.push({ + type: 'text', + text: `[Attached document: ${attachment.originalName}]`, + }); + } + } + } + + // Add the text message + if (text) { + content.push({ + type: 'text', + text, + }); + } + + return content; + } + /** * Send a message and get streaming response with tool loop support * Uses Prompt Caching to reduce costs (~90% savings on cached system prompt) + * Supports multimodal messages with image attachments */ async *sendMessage( message: string, context: ConversationContext, + attachments?: FileAttachment[], ): AsyncGenerator { const tools = this.immigrationToolsService.getTools(); const systemPrompt = buildSystemPrompt(this.systemPromptConfig); @@ -82,21 +151,38 @@ export class ClaudeAgentService implements OnModuleInit { // Build messages array const messages: Anthropic.MessageParam[] = []; - // Add previous messages if any + // Add previous messages if any (with multimodal support) if (context.previousMessages) { for (const msg of context.previousMessages) { - messages.push({ - role: msg.role, - content: msg.content, - }); + if (msg.attachments && msg.attachments.length > 0 && msg.role === 'user') { + // Build multimodal content for messages with attachments + const multimodalContent = await this.buildMultimodalContent(msg.content, msg.attachments); + messages.push({ + role: msg.role, + content: multimodalContent, + }); + } else { + messages.push({ + role: msg.role, + content: msg.content, + }); + } } } - // Add current message - messages.push({ - role: 'user', - content: message, - }); + // Add current message (with multimodal support) + if (attachments && attachments.length > 0) { + const multimodalContent = await this.buildMultimodalContent(message, attachments); + messages.push({ + role: 'user', + content: multimodalContent, + }); + } else { + messages.push({ + role: 'user', + content: message, + }); + } // Tool loop - continue until we get a final response (no tool use) const maxIterations = 10; // Safety limit diff --git a/packages/services/file-service/Dockerfile b/packages/services/file-service/Dockerfile new file mode 100644 index 0000000..4e27277 --- /dev/null +++ b/packages/services/file-service/Dockerfile @@ -0,0 +1,74 @@ +# =========================================== +# iConsulting File Service Dockerfile +# =========================================== + +# 构建阶段 +FROM node:20-alpine AS builder + +WORKDIR /app + +# 安装 pnpm +RUN corepack enable && corepack prepare pnpm@latest --activate + +# 复制 workspace 配置 +COPY pnpm-workspace.yaml package.json pnpm-lock.yaml ./ +COPY packages/shared/package.json ./packages/shared/ +COPY packages/services/file-service/package.json ./packages/services/file-service/ + +# 安装依赖 +RUN pnpm install --frozen-lockfile + +# 复制源代码 +COPY packages/shared ./packages/shared +COPY packages/services/file-service ./packages/services/file-service +COPY tsconfig.base.json ./ + +# 构建 shared +RUN pnpm --filter @iconsulting/shared build + +# 构建服务 +RUN pnpm --filter @iconsulting/file-service build + +# 运行阶段 +FROM node:20-alpine AS runner + +WORKDIR /app + +# 安装 sharp 需要的依赖 +RUN apk add --no-cache vips-dev + +# 创建非 root 用户 +RUN addgroup --system --gid 1001 nodejs && \ + adduser --system --uid 1001 nestjs + +# 复制构建产物和依赖配置 +COPY --from=builder /app/packages/services/file-service/dist ./dist +COPY --from=builder /app/packages/services/file-service/package.json ./ + +# 复制 shared 包的构建产物 (因为依赖 workspace:*) +COPY --from=builder /app/packages/shared/dist ./node_modules/@iconsulting/shared/dist +COPY --from=builder /app/packages/shared/package.json ./node_modules/@iconsulting/shared/ + +# 移除 workspace: 协议依赖并安装生产依赖 +RUN apk add --no-cache jq python3 make g++ && \ + jq 'del(.dependencies["@iconsulting/shared"])' package.json > package.tmp.json && \ + mv package.tmp.json package.json && \ + npm install --omit=dev && \ + apk del python3 make g++ + +# 设置环境变量 +ENV NODE_ENV=production +ENV PORT=3006 + +# 切换用户 +USER nestjs + +# 暴露端口 +EXPOSE 3006 + +# 健康检查 +HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ + CMD wget --no-verbose --tries=1 --spider http://localhost:3006/health || exit 1 + +# 启动服务 +CMD ["node", "dist/main.js"] diff --git a/packages/services/file-service/nest-cli.json b/packages/services/file-service/nest-cli.json new file mode 100644 index 0000000..f9aa683 --- /dev/null +++ b/packages/services/file-service/nest-cli.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://json.schemastore.org/nest-cli", + "collection": "@nestjs/schematics", + "sourceRoot": "src", + "compilerOptions": { + "deleteOutDir": true + } +} diff --git a/packages/services/file-service/package.json b/packages/services/file-service/package.json new file mode 100644 index 0000000..80eea3d --- /dev/null +++ b/packages/services/file-service/package.json @@ -0,0 +1,48 @@ +{ + "name": "@iconsulting/file-service", + "version": "1.0.0", + "description": "File service for managing file uploads with MinIO storage", + "main": "dist/main.js", + "scripts": { + "build": "nest build", + "dev": "nest start --watch", + "start": "node dist/main.js", + "start:debug": "nest start --debug --watch", + "lint": "eslint \"{src,apps,libs,test}/**/*.ts\"", + "test": "jest", + "test:watch": "jest --watch", + "test:cov": "jest --coverage" + }, + "dependencies": { + "@nestjs/common": "^10.0.0", + "@nestjs/config": "^3.2.0", + "@nestjs/core": "^10.0.0", + "@nestjs/platform-express": "^10.0.0", + "@nestjs/typeorm": "^10.0.0", + "typeorm": "^0.3.19", + "pg": "^8.11.0", + "ioredis": "^5.3.0", + "minio": "^8.0.0", + "multer": "^1.4.5-lts.1", + "sharp": "^0.33.0", + "mime-types": "^2.1.35", + "rxjs": "^7.8.0", + "class-validator": "^0.14.0", + "class-transformer": "^0.5.1", + "uuid": "^9.0.0", + "@iconsulting/shared": "workspace:*" + }, + "devDependencies": { + "@nestjs/cli": "^10.0.0", + "@nestjs/testing": "^10.0.0", + "@types/express": "^4.17.21", + "@types/jest": "^29.5.0", + "@types/mime-types": "^2.1.4", + "@types/multer": "^1.4.11", + "@types/node": "^20.10.0", + "@types/uuid": "^9.0.0", + "jest": "^29.7.0", + "ts-jest": "^29.1.0", + "typescript": "^5.3.0" + } +} diff --git a/packages/services/file-service/src/app.module.ts b/packages/services/file-service/src/app.module.ts new file mode 100644 index 0000000..45ed978 --- /dev/null +++ b/packages/services/file-service/src/app.module.ts @@ -0,0 +1,43 @@ +import { Module } from '@nestjs/common'; +import { ConfigModule, ConfigService } from '@nestjs/config'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { HealthModule } from './health/health.module'; +import { FileModule } from './file/file.module'; +import { MinioModule } from './minio/minio.module'; + +@Module({ + imports: [ + // 配置模块 + ConfigModule.forRoot({ + isGlobal: true, + envFilePath: ['.env.local', '.env'], + }), + + // 数据库连接 + TypeOrmModule.forRootAsync({ + imports: [ConfigModule], + inject: [ConfigService], + useFactory: (config: ConfigService) => ({ + type: 'postgres', + host: config.get('POSTGRES_HOST', 'localhost'), + port: config.get('POSTGRES_PORT', 5432), + username: config.get('POSTGRES_USER', 'postgres'), + password: config.get('POSTGRES_PASSWORD'), + database: config.get('POSTGRES_DB', 'iconsulting'), + autoLoadEntities: true, + synchronize: config.get('NODE_ENV') !== 'production', + logging: config.get('NODE_ENV') === 'development', + }), + }), + + // Health check + HealthModule, + + // MinIO storage + MinioModule, + + // 功能模块 + FileModule, + ], +}) +export class AppModule {} diff --git a/packages/services/file-service/src/domain/entities/file.entity.ts b/packages/services/file-service/src/domain/entities/file.entity.ts new file mode 100644 index 0000000..40a799f --- /dev/null +++ b/packages/services/file-service/src/domain/entities/file.entity.ts @@ -0,0 +1,79 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, +} from 'typeorm'; + +export enum FileType { + IMAGE = 'image', + DOCUMENT = 'document', + AUDIO = 'audio', + VIDEO = 'video', + OTHER = 'other', +} + +export enum FileStatus { + UPLOADING = 'uploading', + PROCESSING = 'processing', + READY = 'ready', + FAILED = 'failed', + DELETED = 'deleted', +} + +@Entity('files') +@Index(['userId', 'createdAt']) +@Index(['conversationId', 'createdAt']) +export class FileEntity { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'user_id' }) + @Index() + userId: string; + + @Column({ name: 'conversation_id', nullable: true }) + @Index() + conversationId: string | null; + + @Column({ name: 'original_name' }) + originalName: string; + + @Column({ name: 'storage_path' }) + storagePath: string; + + @Column({ name: 'mime_type' }) + mimeType: string; + + @Column({ type: 'enum', enum: FileType }) + type: FileType; + + @Column({ type: 'bigint' }) + size: number; + + @Column({ type: 'enum', enum: FileStatus, default: FileStatus.UPLOADING }) + status: FileStatus; + + @Column({ name: 'thumbnail_path', nullable: true }) + thumbnailPath: string | null; + + @Column({ type: 'jsonb', nullable: true }) + metadata: Record | null; + + @Column({ name: 'extracted_text', type: 'text', nullable: true }) + extractedText: string | null; + + @Column({ name: 'error_message', nullable: true }) + errorMessage: string | null; + + @CreateDateColumn({ name: 'created_at' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at' }) + updatedAt: Date; + + @Column({ name: 'deleted_at', nullable: true }) + deletedAt: Date | null; +} diff --git a/packages/services/file-service/src/file/dto/upload-file.dto.ts b/packages/services/file-service/src/file/dto/upload-file.dto.ts new file mode 100644 index 0000000..cd8ad58 --- /dev/null +++ b/packages/services/file-service/src/file/dto/upload-file.dto.ts @@ -0,0 +1,38 @@ +import { IsOptional, IsString, IsUUID } from 'class-validator'; + +export class UploadFileDto { + @IsOptional() + @IsUUID() + conversationId?: string; +} + +export class FileResponseDto { + id: string; + originalName: string; + mimeType: string; + type: string; + size: number; + status: string; + thumbnailUrl?: string; + downloadUrl?: string; + createdAt: Date; +} + +export class PresignedUrlDto { + @IsString() + fileName: string; + + @IsString() + mimeType: string; + + @IsOptional() + @IsUUID() + conversationId?: string; +} + +export class PresignedUrlResponseDto { + uploadUrl: string; + fileId: string; + objectName: string; + expiresIn: number; +} diff --git a/packages/services/file-service/src/file/file.controller.ts b/packages/services/file-service/src/file/file.controller.ts new file mode 100644 index 0000000..c492df9 --- /dev/null +++ b/packages/services/file-service/src/file/file.controller.ts @@ -0,0 +1,114 @@ +import { + Controller, + Post, + Get, + Delete, + Param, + Body, + Query, + UploadedFile, + UseInterceptors, + Headers, + ParseUUIDPipe, + HttpCode, + HttpStatus, +} from '@nestjs/common'; +import { FileInterceptor } from '@nestjs/platform-express'; +import { FileService } from './file.service'; +import { + UploadFileDto, + PresignedUrlDto, + FileResponseDto, + PresignedUrlResponseDto, +} from './dto/upload-file.dto'; + +@Controller('files') +export class FileController { + constructor(private readonly fileService: FileService) {} + + /** + * 获取预签名上传 URL (用于大文件直传) + */ + @Post('presigned-url') + async getPresignedUrl( + @Headers('x-user-id') userId: string, + @Body() dto: PresignedUrlDto, + ): Promise { + return this.fileService.getPresignedUploadUrl(userId, dto); + } + + /** + * 确认上传完成 + */ + @Post(':id/confirm') + async confirmUpload( + @Headers('x-user-id') userId: string, + @Param('id', ParseUUIDPipe) fileId: string, + @Body('size') size: number, + ): Promise { + return this.fileService.confirmUpload(userId, fileId, size); + } + + /** + * 直接上传文件 (适用于小文件 < 10MB) + */ + @Post('upload') + @UseInterceptors( + FileInterceptor('file', { + limits: { fileSize: 10 * 1024 * 1024 }, // 10MB limit for direct upload + }), + ) + async uploadFile( + @Headers('x-user-id') userId: string, + @UploadedFile() file: Express.Multer.File, + @Body() dto: UploadFileDto, + ): Promise { + return this.fileService.uploadFile(userId, file, dto.conversationId); + } + + /** + * 获取文件信息 + */ + @Get(':id') + async getFile( + @Headers('x-user-id') userId: string, + @Param('id', ParseUUIDPipe) fileId: string, + ): Promise { + return this.fileService.getFile(userId, fileId); + } + + /** + * 获取文件下载 URL + */ + @Get(':id/download-url') + async getDownloadUrl( + @Headers('x-user-id') userId: string, + @Param('id', ParseUUIDPipe) fileId: string, + ): Promise<{ url: string }> { + const url = await this.fileService.getDownloadUrl(userId, fileId); + return { url }; + } + + /** + * 获取用户的所有文件 + */ + @Get() + async getUserFiles( + @Headers('x-user-id') userId: string, + @Query('conversationId') conversationId?: string, + ): Promise { + return this.fileService.getUserFiles(userId, conversationId); + } + + /** + * 删除文件 + */ + @Delete(':id') + @HttpCode(HttpStatus.NO_CONTENT) + async deleteFile( + @Headers('x-user-id') userId: string, + @Param('id', ParseUUIDPipe) fileId: string, + ): Promise { + return this.fileService.deleteFile(userId, fileId); + } +} diff --git a/packages/services/file-service/src/file/file.module.ts b/packages/services/file-service/src/file/file.module.ts new file mode 100644 index 0000000..ab16cd6 --- /dev/null +++ b/packages/services/file-service/src/file/file.module.ts @@ -0,0 +1,21 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { MulterModule } from '@nestjs/platform-express'; +import { FileController } from './file.controller'; +import { FileService } from './file.service'; +import { FileEntity } from '../domain/entities/file.entity'; + +@Module({ + imports: [ + TypeOrmModule.forFeature([FileEntity]), + MulterModule.register({ + limits: { + fileSize: 10 * 1024 * 1024, // 10MB for direct upload + }, + }), + ], + controllers: [FileController], + providers: [FileService], + exports: [FileService], +}) +export class FileModule {} diff --git a/packages/services/file-service/src/file/file.service.ts b/packages/services/file-service/src/file/file.service.ts new file mode 100644 index 0000000..f1d41fc --- /dev/null +++ b/packages/services/file-service/src/file/file.service.ts @@ -0,0 +1,320 @@ +import { + Injectable, + Logger, + NotFoundException, + BadRequestException, +} from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { v4 as uuidv4 } from 'uuid'; +import * as mimeTypes from 'mime-types'; +import { FileEntity, FileType, FileStatus } from '../domain/entities/file.entity'; +import { MinioService } from '../minio/minio.service'; +import { + FileResponseDto, + PresignedUrlDto, + PresignedUrlResponseDto, +} from './dto/upload-file.dto'; + +// 允许的文件类型 +const ALLOWED_IMAGE_TYPES = [ + 'image/jpeg', + 'image/png', + 'image/gif', + 'image/webp', + 'image/svg+xml', +]; + +const ALLOWED_DOCUMENT_TYPES = [ + 'application/pdf', + 'application/msword', + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + 'application/vnd.ms-excel', + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + 'text/plain', + 'text/csv', + 'text/markdown', +]; + +const MAX_FILE_SIZE = 50 * 1024 * 1024; // 50MB + +@Injectable() +export class FileService { + private readonly logger = new Logger(FileService.name); + + constructor( + @InjectRepository(FileEntity) + private readonly fileRepository: Repository, + private readonly minioService: MinioService, + ) {} + + /** + * 获取预签名上传 URL + */ + async getPresignedUploadUrl( + userId: string, + dto: PresignedUrlDto, + ): Promise { + const { fileName, mimeType, conversationId } = dto; + + // 验证文件类型 + const fileType = this.getFileType(mimeType); + if (fileType === FileType.OTHER) { + throw new BadRequestException( + 'Unsupported file type. Only images and documents are allowed.', + ); + } + + // 生成唯一文件 ID 和存储路径 + const fileId = uuidv4(); + const extension = mimeTypes.extension(mimeType) || 'bin'; + const date = new Date(); + const datePath = `${date.getFullYear()}/${String(date.getMonth() + 1).padStart(2, '0')}/${String(date.getDate()).padStart(2, '0')}`; + const objectName = `uploads/${fileType}s/${datePath}/${userId}/${fileId}.${extension}`; + + // 创建文件记录 (状态为 uploading) + const file = this.fileRepository.create({ + id: fileId, + userId, + conversationId: conversationId || null, + originalName: fileName, + storagePath: objectName, + mimeType, + type: fileType, + size: 0, + status: FileStatus.UPLOADING, + }); + + await this.fileRepository.save(file); + + // 获取预签名 URL (有效期 1 小时) + const expiresIn = 3600; + const uploadUrl = await this.minioService.getPresignedPutUrl( + objectName, + expiresIn, + ); + + return { + uploadUrl, + fileId, + objectName, + expiresIn, + }; + } + + /** + * 确认上传完成 + */ + async confirmUpload( + userId: string, + fileId: string, + fileSize: number, + ): Promise { + const file = await this.fileRepository.findOne({ + where: { id: fileId, userId }, + }); + + if (!file) { + throw new NotFoundException('File not found'); + } + + if (file.status !== FileStatus.UPLOADING) { + throw new BadRequestException('File upload already confirmed'); + } + + if (fileSize > MAX_FILE_SIZE) { + file.status = FileStatus.FAILED; + file.errorMessage = 'File size exceeds maximum limit'; + await this.fileRepository.save(file); + throw new BadRequestException('File size exceeds 50MB limit'); + } + + // 更新文件状态 + file.size = fileSize; + file.status = FileStatus.READY; + await this.fileRepository.save(file); + + // TODO: 触发后台处理 (生成缩略图、提取文本等) + + return this.toResponseDto(file); + } + + /** + * 直接上传文件 (适用于小文件) + */ + async uploadFile( + userId: string, + file: Express.Multer.File, + conversationId?: string, + ): Promise { + const { originalname, mimetype, buffer, size } = file; + + // 验证文件大小 + if (size > MAX_FILE_SIZE) { + throw new BadRequestException('File size exceeds 50MB limit'); + } + + // 验证文件类型 + const fileType = this.getFileType(mimetype); + if (fileType === FileType.OTHER) { + throw new BadRequestException( + 'Unsupported file type. Only images and documents are allowed.', + ); + } + + // 生成存储路径 + const fileId = uuidv4(); + const extension = mimeTypes.extension(mimetype) || 'bin'; + const date = new Date(); + const datePath = `${date.getFullYear()}/${String(date.getMonth() + 1).padStart(2, '0')}/${String(date.getDate()).padStart(2, '0')}`; + const objectName = `uploads/${fileType}s/${datePath}/${userId}/${fileId}.${extension}`; + + // 上传到 MinIO + await this.minioService.uploadFile(objectName, buffer, mimetype, { + 'x-amz-meta-original-name': encodeURIComponent(originalname), + 'x-amz-meta-user-id': userId, + }); + + // 创建文件记录 + const fileEntity = this.fileRepository.create({ + id: fileId, + userId, + conversationId: conversationId || null, + originalName: originalname, + storagePath: objectName, + mimeType: mimetype, + type: fileType, + size, + status: FileStatus.READY, + }); + + await this.fileRepository.save(fileEntity); + + this.logger.log(`File uploaded: ${fileId} by user ${userId}`); + + return this.toResponseDto(fileEntity); + } + + /** + * 获取文件信息 + */ + async getFile(userId: string, fileId: string): Promise { + const file = await this.fileRepository.findOne({ + where: { id: fileId, userId, status: FileStatus.READY }, + }); + + if (!file) { + throw new NotFoundException('File not found'); + } + + return this.toResponseDto(file); + } + + /** + * 获取文件下载 URL + */ + async getDownloadUrl(userId: string, fileId: string): Promise { + const file = await this.fileRepository.findOne({ + where: { id: fileId, userId, status: FileStatus.READY }, + }); + + if (!file) { + throw new NotFoundException('File not found'); + } + + return this.minioService.getPresignedUrl(file.storagePath, 3600); + } + + /** + * 获取用户的所有文件 + */ + async getUserFiles( + userId: string, + conversationId?: string, + ): Promise { + const where: Record = { + userId, + status: FileStatus.READY, + }; + + if (conversationId) { + where.conversationId = conversationId; + } + + const files = await this.fileRepository.find({ + where, + order: { createdAt: 'DESC' }, + }); + + return Promise.all(files.map((f) => this.toResponseDto(f))); + } + + /** + * 删除文件 (软删除) + */ + async deleteFile(userId: string, fileId: string): Promise { + const file = await this.fileRepository.findOne({ + where: { id: fileId, userId }, + }); + + if (!file) { + throw new NotFoundException('File not found'); + } + + file.status = FileStatus.DELETED; + file.deletedAt = new Date(); + await this.fileRepository.save(file); + + this.logger.log(`File deleted: ${fileId} by user ${userId}`); + } + + /** + * 确定文件类型 + */ + private getFileType(mimeType: string): FileType { + if (ALLOWED_IMAGE_TYPES.includes(mimeType)) { + return FileType.IMAGE; + } + if (ALLOWED_DOCUMENT_TYPES.includes(mimeType)) { + return FileType.DOCUMENT; + } + if (mimeType.startsWith('audio/')) { + return FileType.AUDIO; + } + if (mimeType.startsWith('video/')) { + return FileType.VIDEO; + } + return FileType.OTHER; + } + + /** + * 转换为响应 DTO + */ + private async toResponseDto(file: FileEntity): Promise { + const dto: FileResponseDto = { + id: file.id, + originalName: file.originalName, + mimeType: file.mimeType, + type: file.type, + size: Number(file.size), + status: file.status, + createdAt: file.createdAt, + }; + + if (file.status === FileStatus.READY) { + dto.downloadUrl = await this.minioService.getPresignedUrl( + file.storagePath, + 3600, + ); + + if (file.thumbnailPath) { + dto.thumbnailUrl = await this.minioService.getPresignedUrl( + file.thumbnailPath, + 3600, + ); + } + } + + return dto; + } +} diff --git a/packages/services/file-service/src/health/health.controller.ts b/packages/services/file-service/src/health/health.controller.ts new file mode 100644 index 0000000..540379a --- /dev/null +++ b/packages/services/file-service/src/health/health.controller.ts @@ -0,0 +1,9 @@ +import { Controller, Get } from '@nestjs/common'; + +@Controller() +export class HealthController { + @Get('health') + health() { + return { status: 'ok', timestamp: new Date().toISOString() }; + } +} diff --git a/packages/services/file-service/src/health/health.module.ts b/packages/services/file-service/src/health/health.module.ts new file mode 100644 index 0000000..7476abe --- /dev/null +++ b/packages/services/file-service/src/health/health.module.ts @@ -0,0 +1,7 @@ +import { Module } from '@nestjs/common'; +import { HealthController } from './health.controller'; + +@Module({ + controllers: [HealthController], +}) +export class HealthModule {} diff --git a/packages/services/file-service/src/main.ts b/packages/services/file-service/src/main.ts new file mode 100644 index 0000000..d7bfca0 --- /dev/null +++ b/packages/services/file-service/src/main.ts @@ -0,0 +1,34 @@ +import { NestFactory } from '@nestjs/core'; +import { ValidationPipe } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { AppModule } from './app.module'; + +async function bootstrap() { + const app = await NestFactory.create(AppModule); + + // Global validation pipe + app.useGlobalPipes( + new ValidationPipe({ + whitelist: true, + forbidNonWhitelisted: true, + transform: true, + }), + ); + + // Enable CORS + app.enableCors({ + origin: process.env.CORS_ORIGINS?.split(',') || ['http://localhost:5173'], + credentials: true, + }); + + // API prefix + app.setGlobalPrefix('api/v1', { exclude: ['health'] }); + + const configService = app.get(ConfigService); + const port = configService.get('PORT') || 3006; + + await app.listen(port); + console.log(`File Service is running on port ${port}`); +} + +bootstrap(); diff --git a/packages/services/file-service/src/minio/minio.module.ts b/packages/services/file-service/src/minio/minio.module.ts new file mode 100644 index 0000000..2bb45ca --- /dev/null +++ b/packages/services/file-service/src/minio/minio.module.ts @@ -0,0 +1,11 @@ +import { Module, Global } from '@nestjs/common'; +import { ConfigModule } from '@nestjs/config'; +import { MinioService } from './minio.service'; + +@Global() +@Module({ + imports: [ConfigModule], + providers: [MinioService], + exports: [MinioService], +}) +export class MinioModule {} diff --git a/packages/services/file-service/src/minio/minio.service.ts b/packages/services/file-service/src/minio/minio.service.ts new file mode 100644 index 0000000..7053425 --- /dev/null +++ b/packages/services/file-service/src/minio/minio.service.ts @@ -0,0 +1,140 @@ +import { Injectable, OnModuleInit, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import * as Minio from 'minio'; + +@Injectable() +export class MinioService implements OnModuleInit { + private readonly logger = new Logger(MinioService.name); + private client: Minio.Client; + private bucketName: string; + + constructor(private readonly configService: ConfigService) { + this.client = new Minio.Client({ + endPoint: this.configService.get('MINIO_ENDPOINT', 'minio'), + port: this.configService.get('MINIO_PORT', 9000), + useSSL: this.configService.get('MINIO_USE_SSL', 'false') === 'true', + accessKey: this.configService.get('MINIO_ACCESS_KEY', 'minioadmin'), + secretKey: this.configService.get('MINIO_SECRET_KEY', 'minioadmin123'), + }); + this.bucketName = this.configService.get('MINIO_BUCKET', 'iconsulting'); + } + + async onModuleInit() { + try { + const exists = await this.client.bucketExists(this.bucketName); + if (!exists) { + await this.client.makeBucket(this.bucketName); + this.logger.log(`Bucket ${this.bucketName} created successfully`); + } else { + this.logger.log(`Bucket ${this.bucketName} already exists`); + } + } catch (error) { + this.logger.error(`Failed to initialize MinIO bucket: ${error instanceof Error ? error.message : 'Unknown error'}`); + } + } + + /** + * 上传文件到 MinIO + */ + async uploadFile( + objectName: string, + buffer: Buffer, + contentType: string, + metadata?: Record, + ): Promise { + await this.client.putObject( + this.bucketName, + objectName, + buffer, + buffer.length, + { + 'Content-Type': contentType, + ...metadata, + }, + ); + return objectName; + } + + /** + * 获取预签名下载 URL + */ + async getPresignedUrl( + objectName: string, + expirySeconds: number = 3600, + ): Promise { + return this.client.presignedGetObject( + this.bucketName, + objectName, + expirySeconds, + ); + } + + /** + * 获取预签名上传 URL + */ + async getPresignedPutUrl( + objectName: string, + expirySeconds: number = 3600, + ): Promise { + return this.client.presignedPutObject( + this.bucketName, + objectName, + expirySeconds, + ); + } + + /** + * 删除文件 + */ + async deleteFile(objectName: string): Promise { + await this.client.removeObject(this.bucketName, objectName); + } + + /** + * 获取文件信息 + */ + async getFileInfo(objectName: string): Promise { + return this.client.statObject(this.bucketName, objectName); + } + + /** + * 下载文件为 Buffer + */ + async downloadFile(objectName: string): Promise { + const stream = await this.client.getObject(this.bucketName, objectName); + const chunks: Buffer[] = []; + + return new Promise((resolve, reject) => { + stream.on('data', (chunk: Buffer) => chunks.push(chunk)); + stream.on('error', reject); + stream.on('end', () => resolve(Buffer.concat(chunks))); + }); + } + + /** + * 列出指定前缀的文件 + */ + async listFiles(prefix: string): Promise { + const objects: Minio.BucketItem[] = []; + const stream = this.client.listObjects(this.bucketName, prefix, true); + + return new Promise((resolve, reject) => { + stream.on('data', (obj: Minio.BucketItem) => objects.push(obj)); + stream.on('error', reject); + stream.on('end', () => resolve(objects)); + }); + } + + /** + * 复制文件 + */ + async copyFile(sourceObject: string, destObject: string): Promise { + const conds = new Minio.CopyConditions(); + await this.client.copyObject( + this.bucketName, + destObject, + `/${this.bucketName}/${sourceObject}`, + conds, + ); + } +} diff --git a/packages/services/file-service/tsconfig.json b/packages/services/file-service/tsconfig.json new file mode 100644 index 0000000..7b4cba9 --- /dev/null +++ b/packages/services/file-service/tsconfig.json @@ -0,0 +1,26 @@ +{ + "compilerOptions": { + "module": "commonjs", + "moduleResolution": "node", + "declaration": true, + "removeComments": true, + "emitDecoratorMetadata": true, + "experimentalDecorators": true, + "allowSyntheticDefaultImports": true, + "esModuleInterop": true, + "target": "ES2021", + "sourceMap": true, + "outDir": "./dist", + "baseUrl": "./", + "incremental": true, + "skipLibCheck": true, + "strict": true, + "strictPropertyInitialization": false, + "noImplicitAny": true, + "strictBindCallApply": true, + "forceConsistentCasingInFileNames": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/web-client/src/features/chat/presentation/components/ChatWindow.tsx b/packages/web-client/src/features/chat/presentation/components/ChatWindow.tsx index 80a1b4a..dd5249a 100644 --- a/packages/web-client/src/features/chat/presentation/components/ChatWindow.tsx +++ b/packages/web-client/src/features/chat/presentation/components/ChatWindow.tsx @@ -9,7 +9,14 @@ import { MessageSquare, Menu } from 'lucide-react'; export function ChatWindow() { const messagesEndRef = useRef(null); const { messages, currentConversationId, isStreaming, streamContent, sidebarOpen, toggleSidebar } = useChatStore(); - const { sendMessage } = useChat(); + const { + sendMessage, + pendingFiles, + uploadProgress, + isUploading, + addFiles, + removeFile, + } = useChat(); // Auto-scroll to bottom useEffect(() => { @@ -76,7 +83,15 @@ export function ChatWindow() { {/* Input area */}
- +
diff --git a/packages/web-client/src/features/chat/presentation/components/InputArea.tsx b/packages/web-client/src/features/chat/presentation/components/InputArea.tsx index 54f3d09..73157ff 100644 --- a/packages/web-client/src/features/chat/presentation/components/InputArea.tsx +++ b/packages/web-client/src/features/chat/presentation/components/InputArea.tsx @@ -1,15 +1,56 @@ -import { useState, useRef, useEffect, KeyboardEvent } from 'react'; -import { Send } from 'lucide-react'; +import { useState, useRef, useEffect, KeyboardEvent, ChangeEvent } from 'react'; +import { Send, Paperclip, X, Image, FileText, Loader2 } from 'lucide-react'; import { clsx } from 'clsx'; +import { FileAttachment } from '../stores/chatStore'; -interface InputAreaProps { - onSend: (message: string) => void; - disabled?: boolean; +interface PendingFile { + id: string; + file: File; + preview?: string; + type: 'image' | 'document' | 'other'; } -export function InputArea({ onSend, disabled }: InputAreaProps) { +interface InputAreaProps { + onSend: (message: string, attachments?: FileAttachment[]) => void; + disabled?: boolean; + onFilesSelected?: (files: File[]) => void; + pendingFiles?: PendingFile[]; + onRemoveFile?: (id: string) => void; + isUploading?: boolean; + uploadProgress?: Record; +} + +// 允许上传的文件类型 +const ALLOWED_TYPES = [ + 'image/jpeg', + 'image/png', + 'image/gif', + 'image/webp', + 'image/svg+xml', + 'application/pdf', + 'application/msword', + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + 'application/vnd.ms-excel', + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + 'text/plain', + 'text/csv', + 'text/markdown', +]; + +const MAX_FILE_SIZE = 50 * 1024 * 1024; // 50MB + +export function InputArea({ + onSend, + disabled, + onFilesSelected, + pendingFiles = [], + onRemoveFile, + isUploading, + uploadProgress = {}, +}: InputAreaProps) { const [message, setMessage] = useState(''); const textareaRef = useRef(null); + const fileInputRef = useRef(null); // Auto-resize textarea useEffect(() => { @@ -22,7 +63,7 @@ export function InputArea({ onSend, disabled }: InputAreaProps) { const handleSubmit = () => { const trimmedMessage = message.trim(); - if (trimmedMessage && !disabled) { + if ((trimmedMessage || pendingFiles.length > 0) && !disabled && !isUploading) { onSend(trimmedMessage); setMessage(''); } @@ -35,38 +76,176 @@ export function InputArea({ onSend, disabled }: InputAreaProps) { } }; + const handleFileSelect = (e: ChangeEvent) => { + const files = e.target.files; + if (!files || files.length === 0) return; + + const validFiles: File[] = []; + Array.from(files).forEach((file) => { + if (!ALLOWED_TYPES.includes(file.type)) { + console.warn(`不支持的文件类型: ${file.type}`); + return; + } + if (file.size > MAX_FILE_SIZE) { + console.warn(`文件过大: ${file.name}`); + return; + } + validFiles.push(file); + }); + + if (validFiles.length > 0) { + onFilesSelected?.(validFiles); + } + + // 清空 input 以允许重复选择同一文件 + e.target.value = ''; + }; + + const openFilePicker = () => { + fileInputRef.current?.click(); + }; + + const formatFileSize = (bytes: number) => { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; + }; + return ( -
-
-