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 <noreply@anthropic.com>
This commit is contained in:
parent
7adbaaa871
commit
d4925719fc
|
|
@ -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:*)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -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"
|
||||
|
|
@ -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
|
||||
|
||||
#===============================================================================
|
||||
# 全局插件配置
|
||||
#===============================================================================
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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', {
|
||||
|
|
|
|||
|
|
@ -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) => ({
|
||||
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;
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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<Anthropic.ContentBlockParam[]> {
|
||||
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<StreamChunk> {
|
||||
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) {
|
||||
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
|
||||
// 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
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"$schema": "https://json.schemastore.org/nest-cli",
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src",
|
||||
"compilerOptions": {
|
||||
"deleteOutDir": true
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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<number>('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 {}
|
||||
|
|
@ -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<string, unknown> | 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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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<PresignedUrlResponseDto> {
|
||||
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<FileResponseDto> {
|
||||
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<FileResponseDto> {
|
||||
return this.fileService.uploadFile(userId, file, dto.conversationId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取文件信息
|
||||
*/
|
||||
@Get(':id')
|
||||
async getFile(
|
||||
@Headers('x-user-id') userId: string,
|
||||
@Param('id', ParseUUIDPipe) fileId: string,
|
||||
): Promise<FileResponseDto> {
|
||||
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<FileResponseDto[]> {
|
||||
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<void> {
|
||||
return this.fileService.deleteFile(userId, fileId);
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {}
|
||||
|
|
@ -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<FileEntity>,
|
||||
private readonly minioService: MinioService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 获取预签名上传 URL
|
||||
*/
|
||||
async getPresignedUploadUrl(
|
||||
userId: string,
|
||||
dto: PresignedUrlDto,
|
||||
): Promise<PresignedUrlResponseDto> {
|
||||
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<FileResponseDto> {
|
||||
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<FileResponseDto> {
|
||||
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<FileResponseDto> {
|
||||
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<string> {
|
||||
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<FileResponseDto[]> {
|
||||
const where: Record<string, unknown> = {
|
||||
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<void> {
|
||||
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<FileResponseDto> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
import { Controller, Get } from '@nestjs/common';
|
||||
|
||||
@Controller()
|
||||
export class HealthController {
|
||||
@Get('health')
|
||||
health() {
|
||||
return { status: 'ok', timestamp: new Date().toISOString() };
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { HealthController } from './health.controller';
|
||||
|
||||
@Module({
|
||||
controllers: [HealthController],
|
||||
})
|
||||
export class HealthModule {}
|
||||
|
|
@ -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<number>('PORT') || 3006;
|
||||
|
||||
await app.listen(port);
|
||||
console.log(`File Service is running on port ${port}`);
|
||||
}
|
||||
|
||||
bootstrap();
|
||||
|
|
@ -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 {}
|
||||
|
|
@ -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<number>('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<string, string>,
|
||||
): Promise<string> {
|
||||
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<string> {
|
||||
return this.client.presignedGetObject(
|
||||
this.bucketName,
|
||||
objectName,
|
||||
expirySeconds,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取预签名上传 URL
|
||||
*/
|
||||
async getPresignedPutUrl(
|
||||
objectName: string,
|
||||
expirySeconds: number = 3600,
|
||||
): Promise<string> {
|
||||
return this.client.presignedPutObject(
|
||||
this.bucketName,
|
||||
objectName,
|
||||
expirySeconds,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除文件
|
||||
*/
|
||||
async deleteFile(objectName: string): Promise<void> {
|
||||
await this.client.removeObject(this.bucketName, objectName);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取文件信息
|
||||
*/
|
||||
async getFileInfo(objectName: string): Promise<Minio.BucketItemStat> {
|
||||
return this.client.statObject(this.bucketName, objectName);
|
||||
}
|
||||
|
||||
/**
|
||||
* 下载文件为 Buffer
|
||||
*/
|
||||
async downloadFile(objectName: string): Promise<Buffer> {
|
||||
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<Minio.BucketItem[]> {
|
||||
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<void> {
|
||||
const conds = new Minio.CopyConditions();
|
||||
await this.client.copyObject(
|
||||
this.bucketName,
|
||||
destObject,
|
||||
`/${this.bucketName}/${sourceObject}`,
|
||||
conds,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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"]
|
||||
}
|
||||
|
|
@ -9,7 +9,14 @@ import { MessageSquare, Menu } from 'lucide-react';
|
|||
export function ChatWindow() {
|
||||
const messagesEndRef = useRef<HTMLDivElement>(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 */}
|
||||
<div className="border-t border-secondary-200 bg-white p-4">
|
||||
<div className="max-w-3xl mx-auto">
|
||||
<InputArea onSend={sendMessage} disabled={isStreaming} />
|
||||
<InputArea
|
||||
onSend={sendMessage}
|
||||
disabled={isStreaming}
|
||||
onFilesSelected={addFiles}
|
||||
pendingFiles={pendingFiles}
|
||||
onRemoveFile={removeFile}
|
||||
isUploading={isUploading}
|
||||
uploadProgress={uploadProgress}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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<string, { progress: number; status: string }>;
|
||||
}
|
||||
|
||||
// 允许上传的文件类型
|
||||
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<HTMLTextAreaElement>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(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,16 +76,144 @@ export function InputArea({ onSend, disabled }: InputAreaProps) {
|
|||
}
|
||||
};
|
||||
|
||||
const handleFileSelect = (e: ChangeEvent<HTMLInputElement>) => {
|
||||
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 (
|
||||
<div className="space-y-3">
|
||||
{/* 文件预览区域 */}
|
||||
{pendingFiles.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2 px-1">
|
||||
{pendingFiles.map((pf) => (
|
||||
<div
|
||||
key={pf.id}
|
||||
className="relative group flex items-center gap-2 px-3 py-2 bg-secondary-50 rounded-lg border border-secondary-200"
|
||||
>
|
||||
{/* 文件图标或预览 */}
|
||||
{pf.type === 'image' && pf.preview ? (
|
||||
<img
|
||||
src={pf.preview}
|
||||
alt={pf.file.name}
|
||||
className="w-10 h-10 object-cover rounded"
|
||||
/>
|
||||
) : pf.type === 'image' ? (
|
||||
<Image className="w-5 h-5 text-blue-500" />
|
||||
) : (
|
||||
<FileText className="w-5 h-5 text-orange-500" />
|
||||
)}
|
||||
|
||||
{/* 文件信息 */}
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm text-secondary-700 max-w-[150px] truncate">
|
||||
{pf.file.name}
|
||||
</span>
|
||||
<span className="text-xs text-secondary-400">
|
||||
{formatFileSize(pf.file.size)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 上传进度 */}
|
||||
{uploadProgress[pf.id] && (
|
||||
<div className="ml-2">
|
||||
{uploadProgress[pf.id].status === 'uploading' ? (
|
||||
<div className="flex items-center gap-1">
|
||||
<Loader2 className="w-4 h-4 animate-spin text-primary-500" />
|
||||
<span className="text-xs text-primary-500">
|
||||
{uploadProgress[pf.id].progress}%
|
||||
</span>
|
||||
</div>
|
||||
) : uploadProgress[pf.id].status === 'error' ? (
|
||||
<span className="text-xs text-red-500">失败</span>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 删除按钮 */}
|
||||
{!isUploading && (
|
||||
<button
|
||||
onClick={() => onRemoveFile?.(pf.id)}
|
||||
className="absolute -top-1 -right-1 p-0.5 bg-secondary-200 rounded-full opacity-0 group-hover:opacity-100 transition-opacity hover:bg-secondary-300"
|
||||
>
|
||||
<X className="w-3 h-3 text-secondary-600" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 输入区域 */}
|
||||
<div className="flex items-end gap-3">
|
||||
{/* 文件上传按钮 */}
|
||||
<button
|
||||
onClick={openFilePicker}
|
||||
disabled={disabled || isUploading}
|
||||
className={clsx(
|
||||
'p-3 rounded-xl transition-all',
|
||||
disabled || isUploading
|
||||
? 'bg-secondary-100 text-secondary-400 cursor-not-allowed'
|
||||
: 'bg-secondary-100 text-secondary-600 hover:bg-secondary-200',
|
||||
)}
|
||||
title="添加图片或文件"
|
||||
>
|
||||
<Paperclip className="w-5 h-5" />
|
||||
</button>
|
||||
|
||||
{/* 隐藏的文件输入 */}
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
accept={ALLOWED_TYPES.join(',')}
|
||||
onChange={handleFileSelect}
|
||||
className="hidden"
|
||||
/>
|
||||
|
||||
{/* 文本输入 */}
|
||||
<div className="flex-1 relative">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={message}
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="输入您的问题..."
|
||||
disabled={disabled}
|
||||
placeholder={
|
||||
pendingFiles.length > 0
|
||||
? '添加说明文字(可选),或直接发送...'
|
||||
: '输入您的问题...'
|
||||
}
|
||||
disabled={disabled || isUploading}
|
||||
rows={1}
|
||||
className={clsx(
|
||||
'w-full resize-none rounded-xl border border-secondary-200 bg-white px-4 py-3 pr-12',
|
||||
|
|
@ -56,17 +225,27 @@ export function InputArea({ onSend, disabled }: InputAreaProps) {
|
|||
/>
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={disabled || !message.trim()}
|
||||
disabled={disabled || isUploading || (!message.trim() && pendingFiles.length === 0)}
|
||||
className={clsx(
|
||||
'absolute right-2 bottom-2 p-2 rounded-lg transition-all',
|
||||
message.trim() && !disabled
|
||||
(message.trim() || pendingFiles.length > 0) && !disabled && !isUploading
|
||||
? 'bg-primary-600 text-white hover:bg-primary-700'
|
||||
: 'bg-secondary-100 text-secondary-400 cursor-not-allowed',
|
||||
)}
|
||||
>
|
||||
{isUploading ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<Send className="w-4 h-4" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 提示文字 */}
|
||||
<p className="text-xs text-secondary-400 text-center">
|
||||
支持图片 (JPEG, PNG, GIF) 和文档 (PDF, Word, Excel, 文本),最大 50MB
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,12 +1,14 @@
|
|||
import { clsx } from 'clsx';
|
||||
import { User, Bot } from 'lucide-react';
|
||||
import { User, Bot, Image, FileText, Download, ExternalLink } from 'lucide-react';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import { FileAttachment } from '../stores/chatStore';
|
||||
|
||||
interface Message {
|
||||
id: string;
|
||||
role: 'user' | 'assistant' | 'system';
|
||||
content: string;
|
||||
createdAt: string;
|
||||
attachments?: FileAttachment[];
|
||||
metadata?: {
|
||||
toolCalls?: Array<{
|
||||
name: string;
|
||||
|
|
@ -22,6 +24,7 @@ interface MessageBubbleProps {
|
|||
|
||||
export function MessageBubble({ message, isStreaming }: MessageBubbleProps) {
|
||||
const isUser = message.role === 'user';
|
||||
const hasAttachments = message.attachments && message.attachments.length > 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
|
|
@ -45,9 +48,21 @@ export function MessageBubble({ message, isStreaming }: MessageBubbleProps) {
|
|||
</div>
|
||||
|
||||
{/* Message content */}
|
||||
<div className="max-w-[80%] space-y-2">
|
||||
{/* 文件附件 */}
|
||||
{hasAttachments && (
|
||||
<div className={clsx('flex flex-wrap gap-2', isUser ? 'justify-end' : 'justify-start')}>
|
||||
{message.attachments!.map((attachment) => (
|
||||
<AttachmentPreview key={attachment.id} attachment={attachment} isUser={isUser} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 文本内容 */}
|
||||
{message.content && (
|
||||
<div
|
||||
className={clsx(
|
||||
'max-w-[80%] rounded-2xl px-4 py-3',
|
||||
'rounded-2xl px-4 py-3',
|
||||
isUser
|
||||
? 'bg-primary-600 text-white rounded-tr-sm'
|
||||
: 'bg-secondary-100 text-secondary-900 rounded-tl-sm',
|
||||
|
|
@ -87,7 +102,86 @@ export function MessageBubble({ message, isStreaming }: MessageBubbleProps) {
|
|||
<ToolCallResult key={index} toolCall={toolCall} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AttachmentPreview({
|
||||
attachment,
|
||||
isUser,
|
||||
}: {
|
||||
attachment: FileAttachment;
|
||||
isUser: boolean;
|
||||
}) {
|
||||
const isImage = attachment.type === 'image';
|
||||
|
||||
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`;
|
||||
};
|
||||
|
||||
if (isImage && (attachment.downloadUrl || attachment.thumbnailUrl)) {
|
||||
return (
|
||||
<a
|
||||
href={attachment.downloadUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="block group relative"
|
||||
>
|
||||
<img
|
||||
src={attachment.thumbnailUrl || attachment.downloadUrl}
|
||||
alt={attachment.originalName}
|
||||
className={clsx(
|
||||
'max-w-[200px] max-h-[200px] rounded-lg object-cover',
|
||||
'border-2 transition-all',
|
||||
isUser ? 'border-primary-400' : 'border-secondary-200',
|
||||
'group-hover:opacity-90',
|
||||
)}
|
||||
/>
|
||||
<div className="absolute inset-0 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity bg-black/30 rounded-lg">
|
||||
<ExternalLink className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<a
|
||||
href={attachment.downloadUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={clsx(
|
||||
'flex items-center gap-3 px-3 py-2 rounded-lg transition-all',
|
||||
isUser
|
||||
? 'bg-primary-500 hover:bg-primary-400'
|
||||
: 'bg-white border border-secondary-200 hover:border-secondary-300',
|
||||
)}
|
||||
>
|
||||
{isImage ? (
|
||||
<Image className={clsx('w-8 h-8', isUser ? 'text-white/80' : 'text-blue-500')} />
|
||||
) : (
|
||||
<FileText className={clsx('w-8 h-8', isUser ? 'text-white/80' : 'text-orange-500')} />
|
||||
)}
|
||||
|
||||
<div className="flex flex-col">
|
||||
<span
|
||||
className={clsx(
|
||||
'text-sm max-w-[150px] truncate',
|
||||
isUser ? 'text-white' : 'text-secondary-700',
|
||||
)}
|
||||
>
|
||||
{attachment.originalName}
|
||||
</span>
|
||||
<span className={clsx('text-xs', isUser ? 'text-white/70' : 'text-secondary-400')}>
|
||||
{formatFileSize(attachment.size)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Download className={clsx('w-4 h-4 ml-1', isUser ? 'text-white/70' : 'text-secondary-400')} />
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,11 +1,20 @@
|
|||
import { useCallback, useEffect, useRef } from 'react';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { io, Socket } from 'socket.io-client';
|
||||
import { useChatStore, Message } from '../stores/chatStore';
|
||||
import { useChatStore, Message, FileAttachment } from '../stores/chatStore';
|
||||
import { uploadFile, getFileType, validateFile } from '@/shared/services/fileService';
|
||||
import type { FileInfo } from '@/shared/services/fileService';
|
||||
|
||||
// Singleton socket instance to prevent multiple connections
|
||||
let globalSocket: Socket | null = null;
|
||||
let currentUserId: string | null = null;
|
||||
|
||||
interface PendingFile {
|
||||
id: string;
|
||||
file: File;
|
||||
preview?: string;
|
||||
type: 'image' | 'document' | 'other';
|
||||
}
|
||||
|
||||
export function useChat() {
|
||||
const socketRef = useRef<Socket | null>(null);
|
||||
const {
|
||||
|
|
@ -20,6 +29,11 @@ export function useChat() {
|
|||
setConnected,
|
||||
} = useChatStore();
|
||||
|
||||
// 文件上传状态
|
||||
const [pendingFiles, setPendingFiles] = useState<PendingFile[]>([]);
|
||||
const [uploadProgress, setUploadProgress] = useState<Record<string, { progress: number; status: string }>>({});
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
|
||||
// Initialize WebSocket connection (singleton pattern)
|
||||
useEffect(() => {
|
||||
if (!userId) return;
|
||||
|
|
@ -112,43 +126,115 @@ export function useChat() {
|
|||
// Don't disconnect on cleanup - keep singleton alive
|
||||
}, [userId]);
|
||||
|
||||
// Send message
|
||||
const sendMessage = useCallback(
|
||||
async (content: string) => {
|
||||
if (!userId || !content.trim()) return;
|
||||
// 添加待上传文件
|
||||
const addFiles = useCallback((files: File[]) => {
|
||||
const newFiles: PendingFile[] = [];
|
||||
|
||||
let conversationId = currentConversationId;
|
||||
|
||||
// Create new conversation if needed
|
||||
if (!conversationId) {
|
||||
conversationId = await createNewConversation();
|
||||
if (!conversationId) return;
|
||||
files.forEach((file) => {
|
||||
const validation = validateFile(file);
|
||||
if (!validation.valid) {
|
||||
console.error(validation.error);
|
||||
return;
|
||||
}
|
||||
|
||||
// Add user message immediately
|
||||
const userMessage: Message = {
|
||||
id: `msg_${Date.now()}`,
|
||||
role: 'user',
|
||||
content: content.trim(),
|
||||
createdAt: new Date().toISOString(),
|
||||
const id = `pending_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||
const type = getFileType(file.type);
|
||||
|
||||
const pendingFile: PendingFile = {
|
||||
id,
|
||||
file,
|
||||
type,
|
||||
};
|
||||
addMessage(conversationId, userMessage);
|
||||
|
||||
// Send via WebSocket (use globalSocket for reliability)
|
||||
const socket = globalSocket || socketRef.current;
|
||||
if (socket?.connected) {
|
||||
console.log('Sending message via WebSocket:', { conversationId, content: content.trim() });
|
||||
socket.emit('message', {
|
||||
conversationId,
|
||||
content: content.trim(),
|
||||
});
|
||||
} else {
|
||||
console.error('WebSocket not connected, cannot send message. Socket state:', socket?.connected);
|
||||
// 为图片生成预览
|
||||
if (type === 'image') {
|
||||
pendingFile.preview = URL.createObjectURL(file);
|
||||
}
|
||||
|
||||
newFiles.push(pendingFile);
|
||||
});
|
||||
|
||||
setPendingFiles((prev) => [...prev, ...newFiles]);
|
||||
}, []);
|
||||
|
||||
// 移除待上传文件
|
||||
const removeFile = useCallback((id: string) => {
|
||||
setPendingFiles((prev) => {
|
||||
const file = prev.find((f) => f.id === id);
|
||||
if (file?.preview) {
|
||||
URL.revokeObjectURL(file.preview);
|
||||
}
|
||||
return prev.filter((f) => f.id !== id);
|
||||
});
|
||||
setUploadProgress((prev) => {
|
||||
const newProgress = { ...prev };
|
||||
delete newProgress[id];
|
||||
return newProgress;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// 上传文件并获取附件信息
|
||||
const uploadFiles = useCallback(async (conversationId: string): Promise<FileAttachment[]> => {
|
||||
if (!userId || pendingFiles.length === 0) return [];
|
||||
|
||||
setIsUploading(true);
|
||||
const attachments: FileAttachment[] = [];
|
||||
|
||||
for (const pf of pendingFiles) {
|
||||
setUploadProgress((prev) => ({
|
||||
...prev,
|
||||
[pf.id]: { progress: 0, status: 'uploading' },
|
||||
}));
|
||||
|
||||
try {
|
||||
const fileInfo: FileInfo = await uploadFile(
|
||||
pf.file,
|
||||
userId,
|
||||
conversationId,
|
||||
(progress) => {
|
||||
setUploadProgress((prev) => ({
|
||||
...prev,
|
||||
[pf.id]: { progress, status: 'uploading' },
|
||||
}));
|
||||
},
|
||||
[userId, currentConversationId, addMessage],
|
||||
);
|
||||
|
||||
setUploadProgress((prev) => ({
|
||||
...prev,
|
||||
[pf.id]: { progress: 100, status: 'success' },
|
||||
}));
|
||||
|
||||
attachments.push({
|
||||
id: fileInfo.id,
|
||||
originalName: fileInfo.originalName,
|
||||
mimeType: fileInfo.mimeType,
|
||||
type: fileInfo.type,
|
||||
size: fileInfo.size,
|
||||
downloadUrl: fileInfo.downloadUrl,
|
||||
thumbnailUrl: fileInfo.thumbnailUrl,
|
||||
});
|
||||
|
||||
// 清理预览
|
||||
if (pf.preview) {
|
||||
URL.revokeObjectURL(pf.preview);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('File upload failed:', error);
|
||||
setUploadProgress((prev) => ({
|
||||
...prev,
|
||||
[pf.id]: { progress: 0, status: 'error' },
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
// 清空待上传列表
|
||||
setPendingFiles([]);
|
||||
setUploadProgress({});
|
||||
setIsUploading(false);
|
||||
|
||||
return attachments;
|
||||
}, [userId, pendingFiles]);
|
||||
|
||||
// Create new conversation
|
||||
const createNewConversation = async (): Promise<string | null> => {
|
||||
if (!userId) return null;
|
||||
|
|
@ -182,6 +268,52 @@ export function useChat() {
|
|||
return null;
|
||||
};
|
||||
|
||||
// Send message (with optional attachments)
|
||||
const sendMessage = useCallback(
|
||||
async (content: string) => {
|
||||
if (!userId) return;
|
||||
if (!content.trim() && pendingFiles.length === 0) return;
|
||||
|
||||
let conversationId = currentConversationId;
|
||||
|
||||
// Create new conversation if needed
|
||||
if (!conversationId) {
|
||||
conversationId = await createNewConversation();
|
||||
if (!conversationId) return;
|
||||
}
|
||||
|
||||
// 先上传文件
|
||||
let attachments: FileAttachment[] = [];
|
||||
if (pendingFiles.length > 0) {
|
||||
attachments = await uploadFiles(conversationId);
|
||||
}
|
||||
|
||||
// Add user message immediately
|
||||
const userMessage: Message = {
|
||||
id: `msg_${Date.now()}`,
|
||||
role: 'user',
|
||||
content: content.trim(),
|
||||
createdAt: new Date().toISOString(),
|
||||
attachments: attachments.length > 0 ? attachments : undefined,
|
||||
};
|
||||
addMessage(conversationId, userMessage);
|
||||
|
||||
// Send via WebSocket (use globalSocket for reliability)
|
||||
const socket = globalSocket || socketRef.current;
|
||||
if (socket?.connected) {
|
||||
console.log('Sending message via WebSocket:', { conversationId, content: content.trim(), attachments });
|
||||
socket.emit('message', {
|
||||
conversationId,
|
||||
content: content.trim(),
|
||||
attachments: attachments.length > 0 ? attachments : undefined,
|
||||
});
|
||||
} else {
|
||||
console.error('WebSocket not connected, cannot send message. Socket state:', socket?.connected);
|
||||
}
|
||||
},
|
||||
[userId, currentConversationId, addMessage, pendingFiles, uploadFiles],
|
||||
);
|
||||
|
||||
const createConversation = useCallback(async () => {
|
||||
return createNewConversation();
|
||||
}, [userId]);
|
||||
|
|
@ -189,5 +321,12 @@ export function useChat() {
|
|||
return {
|
||||
sendMessage,
|
||||
createConversation,
|
||||
// 文件上传相关
|
||||
pendingFiles,
|
||||
uploadProgress,
|
||||
isUploading,
|
||||
addFiles,
|
||||
removeFile,
|
||||
hasPendingFiles: pendingFiles.length > 0,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,21 @@
|
|||
import { create } from 'zustand';
|
||||
|
||||
export interface FileAttachment {
|
||||
id: string;
|
||||
originalName: string;
|
||||
mimeType: string;
|
||||
type: 'image' | 'document' | 'audio' | 'video' | 'other';
|
||||
size: number;
|
||||
downloadUrl?: string;
|
||||
thumbnailUrl?: string;
|
||||
}
|
||||
|
||||
export interface Message {
|
||||
id: string;
|
||||
role: 'user' | 'assistant' | 'system';
|
||||
content: string;
|
||||
createdAt: string;
|
||||
attachments?: FileAttachment[];
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,185 @@
|
|||
import { useState, useCallback } from 'react';
|
||||
import {
|
||||
FileInfo,
|
||||
UploadProgress,
|
||||
uploadFile,
|
||||
validateFile,
|
||||
getFileType,
|
||||
} from '../services/fileService';
|
||||
|
||||
interface UseFileUploadOptions {
|
||||
userId: string | null;
|
||||
conversationId?: string | null;
|
||||
onUploadComplete?: (file: FileInfo) => void;
|
||||
onUploadError?: (error: string, fileName: string) => void;
|
||||
}
|
||||
|
||||
interface PendingFile {
|
||||
id: string;
|
||||
file: File;
|
||||
preview?: string;
|
||||
type: 'image' | 'document' | 'other';
|
||||
}
|
||||
|
||||
export function useFileUpload({
|
||||
userId,
|
||||
conversationId,
|
||||
onUploadComplete,
|
||||
onUploadError,
|
||||
}: UseFileUploadOptions) {
|
||||
const [pendingFiles, setPendingFiles] = useState<PendingFile[]>([]);
|
||||
const [uploadProgress, setUploadProgress] = useState<Record<string, UploadProgress>>({});
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
|
||||
// 添加待上传文件
|
||||
const addFiles = useCallback((files: FileList | File[]) => {
|
||||
const newFiles: PendingFile[] = [];
|
||||
|
||||
Array.from(files).forEach((file) => {
|
||||
const validation = validateFile(file);
|
||||
if (!validation.valid) {
|
||||
onUploadError?.(validation.error!, file.name);
|
||||
return;
|
||||
}
|
||||
|
||||
const id = `pending_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||
const type = getFileType(file.type);
|
||||
|
||||
const pendingFile: PendingFile = {
|
||||
id,
|
||||
file,
|
||||
type,
|
||||
};
|
||||
|
||||
// 为图片生成预览
|
||||
if (type === 'image') {
|
||||
pendingFile.preview = URL.createObjectURL(file);
|
||||
}
|
||||
|
||||
newFiles.push(pendingFile);
|
||||
});
|
||||
|
||||
setPendingFiles((prev) => [...prev, ...newFiles]);
|
||||
}, [onUploadError]);
|
||||
|
||||
// 移除待上传文件
|
||||
const removeFile = useCallback((id: string) => {
|
||||
setPendingFiles((prev) => {
|
||||
const file = prev.find((f) => f.id === id);
|
||||
if (file?.preview) {
|
||||
URL.revokeObjectURL(file.preview);
|
||||
}
|
||||
return prev.filter((f) => f.id !== id);
|
||||
});
|
||||
setUploadProgress((prev) => {
|
||||
const { [id]: _, ...rest } = prev;
|
||||
return rest;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// 清空所有待上传文件
|
||||
const clearFiles = useCallback(() => {
|
||||
pendingFiles.forEach((f) => {
|
||||
if (f.preview) {
|
||||
URL.revokeObjectURL(f.preview);
|
||||
}
|
||||
});
|
||||
setPendingFiles([]);
|
||||
setUploadProgress({});
|
||||
}, [pendingFiles]);
|
||||
|
||||
// 上传单个文件
|
||||
const uploadSingleFile = useCallback(
|
||||
async (pendingFile: PendingFile): Promise<FileInfo | null> => {
|
||||
if (!userId) return null;
|
||||
|
||||
setUploadProgress((prev) => ({
|
||||
...prev,
|
||||
[pendingFile.id]: {
|
||||
fileId: pendingFile.id,
|
||||
fileName: pendingFile.file.name,
|
||||
progress: 0,
|
||||
status: 'uploading',
|
||||
},
|
||||
}));
|
||||
|
||||
try {
|
||||
const fileInfo = await uploadFile(
|
||||
pendingFile.file,
|
||||
userId,
|
||||
conversationId || undefined,
|
||||
(progress) => {
|
||||
setUploadProgress((prev) => ({
|
||||
...prev,
|
||||
[pendingFile.id]: {
|
||||
...prev[pendingFile.id],
|
||||
progress,
|
||||
},
|
||||
}));
|
||||
},
|
||||
);
|
||||
|
||||
setUploadProgress((prev) => ({
|
||||
...prev,
|
||||
[pendingFile.id]: {
|
||||
...prev[pendingFile.id],
|
||||
progress: 100,
|
||||
status: 'success',
|
||||
},
|
||||
}));
|
||||
|
||||
onUploadComplete?.(fileInfo);
|
||||
return fileInfo;
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : '上传失败';
|
||||
setUploadProgress((prev) => ({
|
||||
...prev,
|
||||
[pendingFile.id]: {
|
||||
...prev[pendingFile.id],
|
||||
status: 'error',
|
||||
error: errorMessage,
|
||||
},
|
||||
}));
|
||||
onUploadError?.(errorMessage, pendingFile.file.name);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
[userId, conversationId, onUploadComplete, onUploadError],
|
||||
);
|
||||
|
||||
// 上传所有待上传文件
|
||||
const uploadAllFiles = useCallback(async (): Promise<FileInfo[]> => {
|
||||
if (!userId || pendingFiles.length === 0) return [];
|
||||
|
||||
setIsUploading(true);
|
||||
const uploadedFiles: FileInfo[] = [];
|
||||
|
||||
for (const pendingFile of pendingFiles) {
|
||||
const fileInfo = await uploadSingleFile(pendingFile);
|
||||
if (fileInfo) {
|
||||
uploadedFiles.push(fileInfo);
|
||||
}
|
||||
}
|
||||
|
||||
// 清理已成功上传的文件
|
||||
const successIds = Object.entries(uploadProgress)
|
||||
.filter(([_, p]) => p.status === 'success')
|
||||
.map(([id]) => id);
|
||||
|
||||
setPendingFiles((prev) => prev.filter((f) => !successIds.includes(f.id)));
|
||||
|
||||
setIsUploading(false);
|
||||
return uploadedFiles;
|
||||
}, [userId, pendingFiles, uploadProgress, uploadSingleFile]);
|
||||
|
||||
return {
|
||||
pendingFiles,
|
||||
uploadProgress,
|
||||
isUploading,
|
||||
addFiles,
|
||||
removeFile,
|
||||
clearFiles,
|
||||
uploadAllFiles,
|
||||
hasPendingFiles: pendingFiles.length > 0,
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,304 @@
|
|||
/**
|
||||
* 文件上传服务
|
||||
* 处理与 file-service 的所有文件相关 API 调用
|
||||
*/
|
||||
|
||||
export interface FileInfo {
|
||||
id: string;
|
||||
originalName: string;
|
||||
mimeType: string;
|
||||
type: 'image' | 'document' | 'audio' | 'video' | 'other';
|
||||
size: number;
|
||||
status: 'uploading' | 'processing' | 'ready' | 'failed' | 'deleted';
|
||||
thumbnailUrl?: string;
|
||||
downloadUrl?: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface PresignedUrlResponse {
|
||||
uploadUrl: string;
|
||||
fileId: string;
|
||||
objectName: string;
|
||||
expiresIn: number;
|
||||
}
|
||||
|
||||
export interface UploadProgress {
|
||||
fileId: string;
|
||||
fileName: string;
|
||||
progress: number;
|
||||
status: 'uploading' | 'success' | 'error';
|
||||
error?: string;
|
||||
}
|
||||
|
||||
// 允许上传的文件类型
|
||||
export const ALLOWED_FILE_TYPES = {
|
||||
image: ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/svg+xml'],
|
||||
document: [
|
||||
'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',
|
||||
],
|
||||
};
|
||||
|
||||
export const MAX_FILE_SIZE = 50 * 1024 * 1024; // 50MB
|
||||
export const MAX_DIRECT_UPLOAD_SIZE = 10 * 1024 * 1024; // 10MB
|
||||
|
||||
/**
|
||||
* 验证文件类型和大小
|
||||
*/
|
||||
export function validateFile(file: File): { valid: boolean; error?: string } {
|
||||
const allAllowedTypes = [...ALLOWED_FILE_TYPES.image, ...ALLOWED_FILE_TYPES.document];
|
||||
|
||||
if (!allAllowedTypes.includes(file.type)) {
|
||||
return {
|
||||
valid: false,
|
||||
error: `不支持的文件类型: ${file.type}。支持的类型: 图片 (JPEG, PNG, GIF, WebP, SVG) 和文档 (PDF, Word, Excel, 文本)`,
|
||||
};
|
||||
}
|
||||
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
return {
|
||||
valid: false,
|
||||
error: `文件大小超过限制。最大允许: ${MAX_FILE_SIZE / 1024 / 1024}MB`,
|
||||
};
|
||||
}
|
||||
|
||||
return { valid: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断文件类型
|
||||
*/
|
||||
export function getFileType(mimeType: string): 'image' | 'document' | 'other' {
|
||||
if (ALLOWED_FILE_TYPES.image.includes(mimeType)) return 'image';
|
||||
if (ALLOWED_FILE_TYPES.document.includes(mimeType)) return 'document';
|
||||
return 'other';
|
||||
}
|
||||
|
||||
/**
|
||||
* 直接上传文件 (适用于小文件)
|
||||
*/
|
||||
export async function uploadFileDirect(
|
||||
file: File,
|
||||
userId: string,
|
||||
conversationId?: string,
|
||||
onProgress?: (progress: number) => void,
|
||||
): Promise<FileInfo> {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
if (conversationId) {
|
||||
formData.append('conversationId', conversationId);
|
||||
}
|
||||
|
||||
// 使用 XMLHttpRequest 来获取上传进度
|
||||
return new Promise((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest();
|
||||
|
||||
xhr.upload.addEventListener('progress', (event) => {
|
||||
if (event.lengthComputable && onProgress) {
|
||||
const progress = Math.round((event.loaded / event.total) * 100);
|
||||
onProgress(progress);
|
||||
}
|
||||
});
|
||||
|
||||
xhr.addEventListener('load', () => {
|
||||
if (xhr.status >= 200 && xhr.status < 300) {
|
||||
const response = JSON.parse(xhr.responseText);
|
||||
resolve(response);
|
||||
} else {
|
||||
reject(new Error(`上传失败: ${xhr.statusText}`));
|
||||
}
|
||||
});
|
||||
|
||||
xhr.addEventListener('error', () => {
|
||||
reject(new Error('网络错误,上传失败'));
|
||||
});
|
||||
|
||||
xhr.open('POST', '/api/v1/files/upload');
|
||||
xhr.setRequestHeader('x-user-id', userId);
|
||||
xhr.send(formData);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取预签名上传 URL (适用于大文件)
|
||||
*/
|
||||
export async function getPresignedUploadUrl(
|
||||
fileName: string,
|
||||
mimeType: string,
|
||||
userId: string,
|
||||
conversationId?: string,
|
||||
): Promise<PresignedUrlResponse> {
|
||||
const response = await fetch('/api/v1/files/presigned-url', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-user-id': userId,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
fileName,
|
||||
mimeType,
|
||||
conversationId,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('获取上传地址失败');
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用预签名 URL 上传文件
|
||||
*/
|
||||
export async function uploadFileWithPresignedUrl(
|
||||
file: File,
|
||||
uploadUrl: string,
|
||||
onProgress?: (progress: number) => void,
|
||||
): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest();
|
||||
|
||||
xhr.upload.addEventListener('progress', (event) => {
|
||||
if (event.lengthComputable && onProgress) {
|
||||
const progress = Math.round((event.loaded / event.total) * 100);
|
||||
onProgress(progress);
|
||||
}
|
||||
});
|
||||
|
||||
xhr.addEventListener('load', () => {
|
||||
if (xhr.status >= 200 && xhr.status < 300) {
|
||||
resolve();
|
||||
} else {
|
||||
reject(new Error(`上传失败: ${xhr.statusText}`));
|
||||
}
|
||||
});
|
||||
|
||||
xhr.addEventListener('error', () => {
|
||||
reject(new Error('网络错误,上传失败'));
|
||||
});
|
||||
|
||||
xhr.open('PUT', uploadUrl);
|
||||
xhr.setRequestHeader('Content-Type', file.type);
|
||||
xhr.send(file);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 确认上传完成
|
||||
*/
|
||||
export async function confirmUpload(
|
||||
fileId: string,
|
||||
fileSize: number,
|
||||
userId: string,
|
||||
): Promise<FileInfo> {
|
||||
const response = await fetch(`/api/v1/files/${fileId}/confirm`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-user-id': userId,
|
||||
},
|
||||
body: JSON.stringify({ size: fileSize }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('确认上传失败');
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传文件(自动选择直接上传或预签名上传)
|
||||
*/
|
||||
export async function uploadFile(
|
||||
file: File,
|
||||
userId: string,
|
||||
conversationId?: string,
|
||||
onProgress?: (progress: number) => void,
|
||||
): Promise<FileInfo> {
|
||||
// 小文件直接上传
|
||||
if (file.size <= MAX_DIRECT_UPLOAD_SIZE) {
|
||||
return uploadFileDirect(file, userId, conversationId, onProgress);
|
||||
}
|
||||
|
||||
// 大文件使用预签名 URL
|
||||
const presignedData = await getPresignedUploadUrl(
|
||||
file.name,
|
||||
file.type,
|
||||
userId,
|
||||
conversationId,
|
||||
);
|
||||
|
||||
await uploadFileWithPresignedUrl(file, presignedData.uploadUrl, onProgress);
|
||||
|
||||
return confirmUpload(presignedData.fileId, file.size, userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取文件下载 URL
|
||||
*/
|
||||
export async function getFileDownloadUrl(
|
||||
fileId: string,
|
||||
userId: string,
|
||||
): Promise<string> {
|
||||
const response = await fetch(`/api/v1/files/${fileId}/download-url`, {
|
||||
headers: {
|
||||
'x-user-id': userId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('获取下载地址失败');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return data.url;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户的文件列表
|
||||
*/
|
||||
export async function getUserFiles(
|
||||
userId: string,
|
||||
conversationId?: string,
|
||||
): Promise<FileInfo[]> {
|
||||
const params = new URLSearchParams();
|
||||
if (conversationId) {
|
||||
params.append('conversationId', conversationId);
|
||||
}
|
||||
|
||||
const response = await fetch(`/api/v1/files?${params}`, {
|
||||
headers: {
|
||||
'x-user-id': userId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('获取文件列表失败');
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除文件
|
||||
*/
|
||||
export async function deleteFile(fileId: string, userId: string): Promise<void> {
|
||||
const response = await fetch(`/api/v1/files/${fileId}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'x-user-id': userId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('删除文件失败');
|
||||
}
|
||||
}
|
||||
590
pnpm-lock.yaml
590
pnpm-lock.yaml
|
|
@ -270,6 +270,94 @@ importers:
|
|||
specifier: ^5.3.3
|
||||
version: 5.9.3
|
||||
|
||||
packages/services/file-service:
|
||||
dependencies:
|
||||
'@iconsulting/shared':
|
||||
specifier: workspace:*
|
||||
version: link:../../shared
|
||||
'@nestjs/common':
|
||||
specifier: ^10.0.0
|
||||
version: 10.4.21(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||
'@nestjs/config':
|
||||
specifier: ^3.2.0
|
||||
version: 3.3.0(@nestjs/common@10.4.21)(rxjs@7.8.2)
|
||||
'@nestjs/core':
|
||||
specifier: ^10.0.0
|
||||
version: 10.4.21(@nestjs/common@10.4.21)(@nestjs/platform-express@10.4.21)(@nestjs/websockets@10.4.21)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||
'@nestjs/platform-express':
|
||||
specifier: ^10.0.0
|
||||
version: 10.4.21(@nestjs/common@10.4.21)(@nestjs/core@10.4.21)
|
||||
'@nestjs/typeorm':
|
||||
specifier: ^10.0.0
|
||||
version: 10.0.2(@nestjs/common@10.4.21)(@nestjs/core@10.4.21)(reflect-metadata@0.2.2)(rxjs@7.8.2)(typeorm@0.3.28)
|
||||
class-transformer:
|
||||
specifier: ^0.5.1
|
||||
version: 0.5.1
|
||||
class-validator:
|
||||
specifier: ^0.14.0
|
||||
version: 0.14.3
|
||||
ioredis:
|
||||
specifier: ^5.3.0
|
||||
version: 5.9.1
|
||||
mime-types:
|
||||
specifier: ^2.1.35
|
||||
version: 2.1.35
|
||||
minio:
|
||||
specifier: ^8.0.0
|
||||
version: 8.0.6
|
||||
multer:
|
||||
specifier: ^1.4.5-lts.1
|
||||
version: 1.4.5-lts.2
|
||||
pg:
|
||||
specifier: ^8.11.0
|
||||
version: 8.16.3
|
||||
rxjs:
|
||||
specifier: ^7.8.0
|
||||
version: 7.8.2
|
||||
sharp:
|
||||
specifier: ^0.33.0
|
||||
version: 0.33.5
|
||||
typeorm:
|
||||
specifier: ^0.3.19
|
||||
version: 0.3.28(ioredis@5.9.1)(pg@8.16.3)
|
||||
uuid:
|
||||
specifier: ^9.0.0
|
||||
version: 9.0.1
|
||||
devDependencies:
|
||||
'@nestjs/cli':
|
||||
specifier: ^10.0.0
|
||||
version: 10.4.9
|
||||
'@nestjs/testing':
|
||||
specifier: ^10.0.0
|
||||
version: 10.4.21(@nestjs/common@10.4.21)(@nestjs/core@10.4.21)(@nestjs/platform-express@10.4.21)
|
||||
'@types/express':
|
||||
specifier: ^4.17.21
|
||||
version: 4.17.25
|
||||
'@types/jest':
|
||||
specifier: ^29.5.0
|
||||
version: 29.5.14
|
||||
'@types/mime-types':
|
||||
specifier: ^2.1.4
|
||||
version: 2.1.4
|
||||
'@types/multer':
|
||||
specifier: ^1.4.11
|
||||
version: 1.4.13
|
||||
'@types/node':
|
||||
specifier: ^20.10.0
|
||||
version: 20.19.27
|
||||
'@types/uuid':
|
||||
specifier: ^9.0.0
|
||||
version: 9.0.8
|
||||
jest:
|
||||
specifier: ^29.7.0
|
||||
version: 29.7.0(@types/node@20.19.27)(ts-node@10.9.2)
|
||||
ts-jest:
|
||||
specifier: ^29.1.0
|
||||
version: 29.4.6(@babel/core@7.28.5)(jest@29.7.0)(typescript@5.9.3)
|
||||
typescript:
|
||||
specifier: ^5.3.0
|
||||
version: 5.9.3
|
||||
|
||||
packages/services/knowledge-service:
|
||||
dependencies:
|
||||
'@anthropic-ai/sdk':
|
||||
|
|
@ -395,7 +483,7 @@ importers:
|
|||
version: 14.25.0
|
||||
typeorm:
|
||||
specifier: ^0.3.19
|
||||
version: 0.3.28(pg@8.16.3)(ts-node@10.9.2)
|
||||
version: 0.3.28(ioredis@5.9.1)(pg@8.16.3)
|
||||
uuid:
|
||||
specifier: ^9.0.0
|
||||
version: 9.0.1
|
||||
|
|
@ -1077,6 +1165,14 @@ packages:
|
|||
dependencies:
|
||||
'@jridgewell/trace-mapping': 0.3.9
|
||||
|
||||
/@emnapi/runtime@1.8.1:
|
||||
resolution: {integrity: sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==}
|
||||
requiresBuild: true
|
||||
dependencies:
|
||||
tslib: 2.8.1
|
||||
dev: false
|
||||
optional: true
|
||||
|
||||
/@emotion/hash@0.8.0:
|
||||
resolution: {integrity: sha512-kBJtf7PH6aWwZ6fka3zQ0p6SBYzx4fl1LoZXE2RrnYST9Xljm7WfKJrU4g/Xr3Beg72MLrp1AWNUmuYJTL7Cow==}
|
||||
dev: false
|
||||
|
|
@ -1392,6 +1488,186 @@ packages:
|
|||
deprecated: Use @eslint/object-schema instead
|
||||
dev: true
|
||||
|
||||
/@img/sharp-darwin-arm64@0.33.5:
|
||||
resolution: {integrity: sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
requiresBuild: true
|
||||
optionalDependencies:
|
||||
'@img/sharp-libvips-darwin-arm64': 1.0.4
|
||||
dev: false
|
||||
optional: true
|
||||
|
||||
/@img/sharp-darwin-x64@0.33.5:
|
||||
resolution: {integrity: sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
requiresBuild: true
|
||||
optionalDependencies:
|
||||
'@img/sharp-libvips-darwin-x64': 1.0.4
|
||||
dev: false
|
||||
optional: true
|
||||
|
||||
/@img/sharp-libvips-darwin-arm64@1.0.4:
|
||||
resolution: {integrity: sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg==}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
requiresBuild: true
|
||||
dev: false
|
||||
optional: true
|
||||
|
||||
/@img/sharp-libvips-darwin-x64@1.0.4:
|
||||
resolution: {integrity: sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ==}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
requiresBuild: true
|
||||
dev: false
|
||||
optional: true
|
||||
|
||||
/@img/sharp-libvips-linux-arm64@1.0.4:
|
||||
resolution: {integrity: sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
requiresBuild: true
|
||||
dev: false
|
||||
optional: true
|
||||
|
||||
/@img/sharp-libvips-linux-arm@1.0.5:
|
||||
resolution: {integrity: sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g==}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
requiresBuild: true
|
||||
dev: false
|
||||
optional: true
|
||||
|
||||
/@img/sharp-libvips-linux-s390x@1.0.4:
|
||||
resolution: {integrity: sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA==}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
requiresBuild: true
|
||||
dev: false
|
||||
optional: true
|
||||
|
||||
/@img/sharp-libvips-linux-x64@1.0.4:
|
||||
resolution: {integrity: sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
requiresBuild: true
|
||||
dev: false
|
||||
optional: true
|
||||
|
||||
/@img/sharp-libvips-linuxmusl-arm64@1.0.4:
|
||||
resolution: {integrity: sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
requiresBuild: true
|
||||
dev: false
|
||||
optional: true
|
||||
|
||||
/@img/sharp-libvips-linuxmusl-x64@1.0.4:
|
||||
resolution: {integrity: sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
requiresBuild: true
|
||||
dev: false
|
||||
optional: true
|
||||
|
||||
/@img/sharp-linux-arm64@0.33.5:
|
||||
resolution: {integrity: sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
requiresBuild: true
|
||||
optionalDependencies:
|
||||
'@img/sharp-libvips-linux-arm64': 1.0.4
|
||||
dev: false
|
||||
optional: true
|
||||
|
||||
/@img/sharp-linux-arm@0.33.5:
|
||||
resolution: {integrity: sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
requiresBuild: true
|
||||
optionalDependencies:
|
||||
'@img/sharp-libvips-linux-arm': 1.0.5
|
||||
dev: false
|
||||
optional: true
|
||||
|
||||
/@img/sharp-linux-s390x@0.33.5:
|
||||
resolution: {integrity: sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
requiresBuild: true
|
||||
optionalDependencies:
|
||||
'@img/sharp-libvips-linux-s390x': 1.0.4
|
||||
dev: false
|
||||
optional: true
|
||||
|
||||
/@img/sharp-linux-x64@0.33.5:
|
||||
resolution: {integrity: sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
requiresBuild: true
|
||||
optionalDependencies:
|
||||
'@img/sharp-libvips-linux-x64': 1.0.4
|
||||
dev: false
|
||||
optional: true
|
||||
|
||||
/@img/sharp-linuxmusl-arm64@0.33.5:
|
||||
resolution: {integrity: sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
requiresBuild: true
|
||||
optionalDependencies:
|
||||
'@img/sharp-libvips-linuxmusl-arm64': 1.0.4
|
||||
dev: false
|
||||
optional: true
|
||||
|
||||
/@img/sharp-linuxmusl-x64@0.33.5:
|
||||
resolution: {integrity: sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
requiresBuild: true
|
||||
optionalDependencies:
|
||||
'@img/sharp-libvips-linuxmusl-x64': 1.0.4
|
||||
dev: false
|
||||
optional: true
|
||||
|
||||
/@img/sharp-wasm32@0.33.5:
|
||||
resolution: {integrity: sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [wasm32]
|
||||
requiresBuild: true
|
||||
dependencies:
|
||||
'@emnapi/runtime': 1.8.1
|
||||
dev: false
|
||||
optional: true
|
||||
|
||||
/@img/sharp-win32-ia32@0.33.5:
|
||||
resolution: {integrity: sha512-T36PblLaTwuVJ/zw/LaH0PdZkRz5rd3SmMHX8GSmR7vtNSP5Z6bQkExdSK7xGWyxLw4sUknBuugTelgw2faBbQ==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [ia32]
|
||||
os: [win32]
|
||||
requiresBuild: true
|
||||
dev: false
|
||||
optional: true
|
||||
|
||||
/@img/sharp-win32-x64@0.33.5:
|
||||
resolution: {integrity: sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
requiresBuild: true
|
||||
dev: false
|
||||
optional: true
|
||||
|
||||
/@ioredis/commands@1.5.0:
|
||||
resolution: {integrity: sha512-eUgLqrMf8nJkZxT24JvVRrQya1vZkQh8BBeYNwGDqa5I0VUi8ACx7uFvAaLxintokpTenkK6DASvo/bvNbBGow==}
|
||||
dev: false
|
||||
|
|
@ -1919,7 +2195,7 @@ packages:
|
|||
'@nestjs/core': 10.4.21(@nestjs/common@10.4.21)(@nestjs/platform-express@10.4.21)(@nestjs/websockets@10.4.21)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||
reflect-metadata: 0.2.2
|
||||
rxjs: 7.8.2
|
||||
typeorm: 0.3.28(ioredis@5.9.1)(pg@8.16.3)
|
||||
typeorm: 0.3.28(pg@8.16.3)(ts-node@10.9.2)
|
||||
uuid: 9.0.1
|
||||
dev: false
|
||||
|
||||
|
|
@ -3141,6 +3417,10 @@ packages:
|
|||
'@types/unist': 3.0.3
|
||||
dev: false
|
||||
|
||||
/@types/mime-types@2.1.4:
|
||||
resolution: {integrity: sha512-lfU4b34HOri+kAY5UheuFMWPDOI+OPceBSHZKp69gEyTL/mmJ4cnU6Y/rlme3UL3GyOn6Y42hyIEw0/q8sWx5w==}
|
||||
dev: true
|
||||
|
||||
/@types/mime@1.3.5:
|
||||
resolution: {integrity: sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==}
|
||||
dev: true
|
||||
|
|
@ -3148,6 +3428,12 @@ packages:
|
|||
/@types/ms@2.1.0:
|
||||
resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==}
|
||||
|
||||
/@types/multer@1.4.13:
|
||||
resolution: {integrity: sha512-bhhdtPw7JqCiEfC9Jimx5LqX9BDIPJEh2q/fQ4bqbBPtyEZYr3cvF22NwG0DmPZNYA0CAf2CnqDB4KIGGpJcaw==}
|
||||
dependencies:
|
||||
'@types/express': 4.17.25
|
||||
dev: true
|
||||
|
||||
/@types/node-fetch@2.6.13:
|
||||
resolution: {integrity: sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw==}
|
||||
dependencies:
|
||||
|
|
@ -3527,6 +3813,12 @@ packages:
|
|||
resolution: {integrity: sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==}
|
||||
dev: true
|
||||
|
||||
/@zxing/text-encoding@0.9.0:
|
||||
resolution: {integrity: sha512-U/4aVJ2mxI0aDNI8Uq0wEhMgY+u4CNtEb0om3+y3+niDAsoTCOB33UF0sxpzqzdqXLqmvc+vZyAt4O8pPdfkwA==}
|
||||
requiresBuild: true
|
||||
dev: false
|
||||
optional: true
|
||||
|
||||
/abbrev@1.1.1:
|
||||
resolution: {integrity: sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==}
|
||||
dev: false
|
||||
|
|
@ -3846,6 +4138,10 @@ packages:
|
|||
engines: {node: '>=8'}
|
||||
dev: true
|
||||
|
||||
/async@3.2.6:
|
||||
resolution: {integrity: sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==}
|
||||
dev: false
|
||||
|
||||
/asynckit@0.4.0:
|
||||
resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==}
|
||||
dev: false
|
||||
|
|
@ -4005,6 +4301,12 @@ packages:
|
|||
readable-stream: 3.6.2
|
||||
dev: true
|
||||
|
||||
/block-stream2@2.1.0:
|
||||
resolution: {integrity: sha512-suhjmLI57Ewpmq00qaygS8UgEq2ly2PCItenIyhMqVjo4t4pGzqMvfgJuX8iWTeSDdfSSqS6j38fL4ToNL7Pfg==}
|
||||
dependencies:
|
||||
readable-stream: 3.6.2
|
||||
dev: false
|
||||
|
||||
/body-parser@1.20.3:
|
||||
resolution: {integrity: sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==}
|
||||
engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16}
|
||||
|
|
@ -4042,6 +4344,10 @@ packages:
|
|||
fill-range: 7.1.1
|
||||
dev: true
|
||||
|
||||
/browser-or-node@2.1.1:
|
||||
resolution: {integrity: sha512-8CVjaLJGuSKMVTxJ2DpBl5XnlNDiT4cQFeuCJJrvJmts9YrTZDizTX7PjC2s6W4x+MBGZeEY6dGMrF04/6Hgqg==}
|
||||
dev: false
|
||||
|
||||
/browserslist@4.28.1:
|
||||
resolution: {integrity: sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==}
|
||||
engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
|
||||
|
|
@ -4067,6 +4373,11 @@ packages:
|
|||
node-int64: 0.4.0
|
||||
dev: true
|
||||
|
||||
/buffer-crc32@1.0.0:
|
||||
resolution: {integrity: sha512-Db1SbgBS/fg/392AblrMJk97KggmvYhr4pB5ZIMTWtaivCPMWLkmb7m21cJvpvgK+J3nsU2CmmixNBZx4vFj/w==}
|
||||
engines: {node: '>=8.0.0'}
|
||||
dev: false
|
||||
|
||||
/buffer-equal-constant-time@1.0.1:
|
||||
resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==}
|
||||
dev: false
|
||||
|
|
@ -4327,11 +4638,26 @@ packages:
|
|||
/color-name@1.1.4:
|
||||
resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==}
|
||||
|
||||
/color-string@1.9.1:
|
||||
resolution: {integrity: sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==}
|
||||
dependencies:
|
||||
color-name: 1.1.4
|
||||
simple-swizzle: 0.2.4
|
||||
dev: false
|
||||
|
||||
/color-support@1.1.3:
|
||||
resolution: {integrity: sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==}
|
||||
hasBin: true
|
||||
dev: false
|
||||
|
||||
/color@4.2.3:
|
||||
resolution: {integrity: sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==}
|
||||
engines: {node: '>=12.5.0'}
|
||||
dependencies:
|
||||
color-convert: 2.0.1
|
||||
color-string: 1.9.1
|
||||
dev: false
|
||||
|
||||
/combined-stream@1.0.8:
|
||||
resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==}
|
||||
engines: {node: '>= 0.8'}
|
||||
|
|
@ -4370,6 +4696,16 @@ packages:
|
|||
/concat-map@0.0.1:
|
||||
resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==}
|
||||
|
||||
/concat-stream@1.6.2:
|
||||
resolution: {integrity: sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw==}
|
||||
engines: {'0': node >= 0.8}
|
||||
dependencies:
|
||||
buffer-from: 1.1.2
|
||||
inherits: 2.0.4
|
||||
readable-stream: 2.3.8
|
||||
typedarray: 0.0.6
|
||||
dev: false
|
||||
|
||||
/concat-stream@2.0.0:
|
||||
resolution: {integrity: sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==}
|
||||
engines: {'0': node >= 6.0}
|
||||
|
|
@ -4419,7 +4755,6 @@ packages:
|
|||
|
||||
/core-util-is@1.0.3:
|
||||
resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==}
|
||||
dev: true
|
||||
|
||||
/cors@2.8.5:
|
||||
resolution: {integrity: sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==}
|
||||
|
|
@ -4616,6 +4951,11 @@ packages:
|
|||
character-entities: 2.0.2
|
||||
dev: false
|
||||
|
||||
/decode-uri-component@0.2.2:
|
||||
resolution: {integrity: sha512-FqUYQ+8o158GyGTrMFJms9qh3CqTKvAqgqsTnkLI8sKu0028orqBhxNMFkFen0zGyg6epACD32pjVk58ngIErQ==}
|
||||
engines: {node: '>=0.10'}
|
||||
dev: false
|
||||
|
||||
/dedent@1.7.1:
|
||||
resolution: {integrity: sha512-9JmrhGZpOlEgOLdQgSm0zxFaYoQon408V1v49aqTWuXENVlnCuY9JBZcXZiCsZQWDjTm5Qf/nIvAy77mXDAjEg==}
|
||||
peerDependencies:
|
||||
|
|
@ -5090,6 +5430,10 @@ packages:
|
|||
resolution: {integrity: sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==}
|
||||
dev: false
|
||||
|
||||
/eventemitter3@5.0.1:
|
||||
resolution: {integrity: sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==}
|
||||
dev: false
|
||||
|
||||
/events@3.3.0:
|
||||
resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==}
|
||||
engines: {node: '>=0.8.x'}
|
||||
|
|
@ -5219,6 +5563,13 @@ packages:
|
|||
resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==}
|
||||
dev: true
|
||||
|
||||
/fast-xml-parser@4.5.3:
|
||||
resolution: {integrity: sha512-RKihhV+SHsIUGXObeVy9AXiBbFwkVk7Syp8XgwN5U3JV416+Gwp/GO9i0JYKmikykgz/UHRrrV4ROuZEo/T0ig==}
|
||||
hasBin: true
|
||||
dependencies:
|
||||
strnum: 1.1.2
|
||||
dev: false
|
||||
|
||||
/fastq@1.20.1:
|
||||
resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==}
|
||||
dependencies:
|
||||
|
|
@ -5278,6 +5629,11 @@ packages:
|
|||
to-regex-range: 5.0.1
|
||||
dev: true
|
||||
|
||||
/filter-obj@1.1.0:
|
||||
resolution: {integrity: sha512-8rXg1ZnX7xzy2NGDVkBVaAy+lSlPNwad13BtgSlLuxfIslyt5Vg64U7tFcCt4WS1R0hvtnQybT/IyCkGZ3DpXQ==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
dev: false
|
||||
|
||||
/finalhandler@1.3.2:
|
||||
resolution: {integrity: sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==}
|
||||
engines: {node: '>= 0.8'}
|
||||
|
|
@ -5461,6 +5817,11 @@ packages:
|
|||
wide-align: 1.1.5
|
||||
dev: false
|
||||
|
||||
/generator-function@2.0.1:
|
||||
resolution: {integrity: sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==}
|
||||
engines: {node: '>= 0.4'}
|
||||
dev: false
|
||||
|
||||
/gensync@1.0.0-beta.2:
|
||||
resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
|
@ -5841,6 +6202,11 @@ packages:
|
|||
resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==}
|
||||
engines: {node: '>= 0.10'}
|
||||
|
||||
/ipaddr.js@2.3.0:
|
||||
resolution: {integrity: sha512-Zv/pA+ciVFbCSBBjGfaKUya/CcGmUHzTydLMaTwrUUEM2DIEO3iZvueGxmacvmN50fGpGVKeTXpb2LcYQxeVdg==}
|
||||
engines: {node: '>= 10'}
|
||||
dev: false
|
||||
|
||||
/is-alphabetical@2.0.1:
|
||||
resolution: {integrity: sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==}
|
||||
dev: false
|
||||
|
|
@ -5852,10 +6218,22 @@ packages:
|
|||
is-decimal: 2.0.1
|
||||
dev: false
|
||||
|
||||
/is-arguments@1.2.0:
|
||||
resolution: {integrity: sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA==}
|
||||
engines: {node: '>= 0.4'}
|
||||
dependencies:
|
||||
call-bound: 1.0.4
|
||||
has-tostringtag: 1.0.2
|
||||
dev: false
|
||||
|
||||
/is-arrayish@0.2.1:
|
||||
resolution: {integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==}
|
||||
dev: true
|
||||
|
||||
/is-arrayish@0.3.4:
|
||||
resolution: {integrity: sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==}
|
||||
dev: false
|
||||
|
||||
/is-binary-path@2.1.0:
|
||||
resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==}
|
||||
engines: {node: '>=8'}
|
||||
|
|
@ -5898,6 +6276,17 @@ packages:
|
|||
engines: {node: '>=6'}
|
||||
dev: true
|
||||
|
||||
/is-generator-function@1.1.2:
|
||||
resolution: {integrity: sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==}
|
||||
engines: {node: '>= 0.4'}
|
||||
dependencies:
|
||||
call-bound: 1.0.4
|
||||
generator-function: 2.0.1
|
||||
get-proto: 1.0.1
|
||||
has-tostringtag: 1.0.2
|
||||
safe-regex-test: 1.1.0
|
||||
dev: false
|
||||
|
||||
/is-glob@4.0.3:
|
||||
resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
|
@ -5933,6 +6322,16 @@ packages:
|
|||
engines: {node: '>=12'}
|
||||
dev: false
|
||||
|
||||
/is-regex@1.2.1:
|
||||
resolution: {integrity: sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==}
|
||||
engines: {node: '>= 0.4'}
|
||||
dependencies:
|
||||
call-bound: 1.0.4
|
||||
gopd: 1.2.0
|
||||
has-tostringtag: 1.0.2
|
||||
hasown: 2.0.2
|
||||
dev: false
|
||||
|
||||
/is-stream@2.0.1:
|
||||
resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==}
|
||||
engines: {node: '>=8'}
|
||||
|
|
@ -5955,6 +6354,10 @@ packages:
|
|||
engines: {node: '>= 0.4'}
|
||||
dev: false
|
||||
|
||||
/isarray@1.0.0:
|
||||
resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==}
|
||||
dev: false
|
||||
|
||||
/isarray@2.0.5:
|
||||
resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==}
|
||||
dev: false
|
||||
|
|
@ -7134,6 +7537,26 @@ packages:
|
|||
/minimist@1.2.8:
|
||||
resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==}
|
||||
|
||||
/minio@8.0.6:
|
||||
resolution: {integrity: sha512-sOeh2/b/XprRmEtYsnNRFtOqNRTPDvYtMWh+spWlfsuCV/+IdxNeKVUMKLqI7b5Dr07ZqCPuaRGU/rB9pZYVdQ==}
|
||||
engines: {node: ^16 || ^18 || >=20}
|
||||
dependencies:
|
||||
async: 3.2.6
|
||||
block-stream2: 2.1.0
|
||||
browser-or-node: 2.1.1
|
||||
buffer-crc32: 1.0.0
|
||||
eventemitter3: 5.0.1
|
||||
fast-xml-parser: 4.5.3
|
||||
ipaddr.js: 2.3.0
|
||||
lodash: 4.17.21
|
||||
mime-types: 2.1.35
|
||||
query-string: 7.1.3
|
||||
stream-json: 1.9.1
|
||||
through2: 4.0.2
|
||||
web-encoding: 1.1.5
|
||||
xml2js: 0.6.2
|
||||
dev: false
|
||||
|
||||
/minipass@3.3.6:
|
||||
resolution: {integrity: sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==}
|
||||
engines: {node: '>=8'}
|
||||
|
|
@ -7180,6 +7603,20 @@ packages:
|
|||
/ms@2.1.3:
|
||||
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
|
||||
|
||||
/multer@1.4.5-lts.2:
|
||||
resolution: {integrity: sha512-VzGiVigcG9zUAoCNU+xShztrlr1auZOlurXynNvO9GiWD1/mTBbUljOKY+qMeazBqXgRnjzeEgJI/wyjJUHg9A==}
|
||||
engines: {node: '>= 6.0.0'}
|
||||
deprecated: Multer 1.x is impacted by a number of vulnerabilities, which have been patched in 2.x. You should upgrade to the latest 2.x version.
|
||||
dependencies:
|
||||
append-field: 1.0.0
|
||||
busboy: 1.6.0
|
||||
concat-stream: 1.6.2
|
||||
mkdirp: 0.5.6
|
||||
object-assign: 4.1.1
|
||||
type-is: 1.6.18
|
||||
xtend: 4.0.2
|
||||
dev: false
|
||||
|
||||
/multer@2.0.2:
|
||||
resolution: {integrity: sha512-u7f2xaZ/UG8oLXHvtF/oWTRvT44p9ecwBBqTwgJVq0+4BW1g8OW01TyMEGWBHbyMOYVHXslaut7qEQ1meATXgw==}
|
||||
engines: {node: '>= 10.16.0'}
|
||||
|
|
@ -7764,6 +8201,10 @@ packages:
|
|||
react-is: 18.3.1
|
||||
dev: true
|
||||
|
||||
/process-nextick-args@2.0.1:
|
||||
resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==}
|
||||
dev: false
|
||||
|
||||
/prompts@2.4.2:
|
||||
resolution: {integrity: sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==}
|
||||
engines: {node: '>= 6'}
|
||||
|
|
@ -7833,6 +8274,16 @@ packages:
|
|||
dependencies:
|
||||
side-channel: 1.1.0
|
||||
|
||||
/query-string@7.1.3:
|
||||
resolution: {integrity: sha512-hh2WYhq4fi8+b+/2Kg9CEge4fDPvHS534aOOvOZeQ3+Vf2mCFsaFBYj0i+iXcAq6I9Vzp5fjMFBlONvayDC1qg==}
|
||||
engines: {node: '>=6'}
|
||||
dependencies:
|
||||
decode-uri-component: 0.2.2
|
||||
filter-obj: 1.1.0
|
||||
split-on-first: 1.1.0
|
||||
strict-uri-encode: 2.0.0
|
||||
dev: false
|
||||
|
||||
/queue-microtask@1.2.3:
|
||||
resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
|
||||
dev: true
|
||||
|
|
@ -8532,6 +8983,18 @@ packages:
|
|||
pify: 2.3.0
|
||||
dev: true
|
||||
|
||||
/readable-stream@2.3.8:
|
||||
resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==}
|
||||
dependencies:
|
||||
core-util-is: 1.0.3
|
||||
inherits: 2.0.4
|
||||
isarray: 1.0.0
|
||||
process-nextick-args: 2.0.1
|
||||
safe-buffer: 5.1.2
|
||||
string_decoder: 1.1.1
|
||||
util-deprecate: 1.0.2
|
||||
dev: false
|
||||
|
||||
/readable-stream@3.6.2:
|
||||
resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==}
|
||||
engines: {node: '>= 6'}
|
||||
|
|
@ -8744,12 +9207,30 @@ packages:
|
|||
dependencies:
|
||||
tslib: 2.8.1
|
||||
|
||||
/safe-buffer@5.1.2:
|
||||
resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==}
|
||||
dev: false
|
||||
|
||||
/safe-buffer@5.2.1:
|
||||
resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==}
|
||||
|
||||
/safe-regex-test@1.1.0:
|
||||
resolution: {integrity: sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==}
|
||||
engines: {node: '>= 0.4'}
|
||||
dependencies:
|
||||
call-bound: 1.0.4
|
||||
es-errors: 1.3.0
|
||||
is-regex: 1.2.1
|
||||
dev: false
|
||||
|
||||
/safer-buffer@2.1.2:
|
||||
resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==}
|
||||
|
||||
/sax@1.4.4:
|
||||
resolution: {integrity: sha512-1n3r/tGXO6b6VXMdFT54SHzT9ytu9yr7TaELowdYpMqY/Ao7EnlQGmAQ1+RatX7Tkkdm6hONI2owqNx2aZj5Sw==}
|
||||
engines: {node: '>=11.0.0'}
|
||||
dev: false
|
||||
|
||||
/scheduler@0.23.2:
|
||||
resolution: {integrity: sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==}
|
||||
dependencies:
|
||||
|
|
@ -8860,6 +9341,36 @@ packages:
|
|||
to-buffer: 1.2.2
|
||||
dev: false
|
||||
|
||||
/sharp@0.33.5:
|
||||
resolution: {integrity: sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
requiresBuild: true
|
||||
dependencies:
|
||||
color: 4.2.3
|
||||
detect-libc: 2.1.2
|
||||
semver: 7.7.3
|
||||
optionalDependencies:
|
||||
'@img/sharp-darwin-arm64': 0.33.5
|
||||
'@img/sharp-darwin-x64': 0.33.5
|
||||
'@img/sharp-libvips-darwin-arm64': 1.0.4
|
||||
'@img/sharp-libvips-darwin-x64': 1.0.4
|
||||
'@img/sharp-libvips-linux-arm': 1.0.5
|
||||
'@img/sharp-libvips-linux-arm64': 1.0.4
|
||||
'@img/sharp-libvips-linux-s390x': 1.0.4
|
||||
'@img/sharp-libvips-linux-x64': 1.0.4
|
||||
'@img/sharp-libvips-linuxmusl-arm64': 1.0.4
|
||||
'@img/sharp-libvips-linuxmusl-x64': 1.0.4
|
||||
'@img/sharp-linux-arm': 0.33.5
|
||||
'@img/sharp-linux-arm64': 0.33.5
|
||||
'@img/sharp-linux-s390x': 0.33.5
|
||||
'@img/sharp-linux-x64': 0.33.5
|
||||
'@img/sharp-linuxmusl-arm64': 0.33.5
|
||||
'@img/sharp-linuxmusl-x64': 0.33.5
|
||||
'@img/sharp-wasm32': 0.33.5
|
||||
'@img/sharp-win32-ia32': 0.33.5
|
||||
'@img/sharp-win32-x64': 0.33.5
|
||||
dev: false
|
||||
|
||||
/shebang-command@2.0.0:
|
||||
resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==}
|
||||
engines: {node: '>=8'}
|
||||
|
|
@ -8913,6 +9424,12 @@ packages:
|
|||
resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==}
|
||||
engines: {node: '>=14'}
|
||||
|
||||
/simple-swizzle@0.2.4:
|
||||
resolution: {integrity: sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==}
|
||||
dependencies:
|
||||
is-arrayish: 0.3.4
|
||||
dev: false
|
||||
|
||||
/sisteransi@1.0.5:
|
||||
resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==}
|
||||
dev: true
|
||||
|
|
@ -9032,6 +9549,11 @@ packages:
|
|||
resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==}
|
||||
dev: false
|
||||
|
||||
/split-on-first@1.1.0:
|
||||
resolution: {integrity: sha512-43ZssAJaMusuKWL8sKUBQXHWOpq8d6CfN/u1p4gUzfJkM05C8rxTmYrkIPTXapZpORA6LkkzcUulJ8FqA7Uudw==}
|
||||
engines: {node: '>=6'}
|
||||
dev: false
|
||||
|
||||
/split2@4.2.0:
|
||||
resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==}
|
||||
engines: {node: '>= 10.x'}
|
||||
|
|
@ -9070,10 +9592,25 @@ packages:
|
|||
resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==}
|
||||
engines: {node: '>= 0.8'}
|
||||
|
||||
/stream-chain@2.2.5:
|
||||
resolution: {integrity: sha512-1TJmBx6aSWqZ4tx7aTpBDXK0/e2hhcNSTV8+CbFJtDjbb+I1mZ8lHit0Grw9GRT+6JbIrrDd8esncgBi8aBXGA==}
|
||||
dev: false
|
||||
|
||||
/stream-json@1.9.1:
|
||||
resolution: {integrity: sha512-uWkjJ+2Nt/LO9Z/JyKZbMusL8Dkh97uUBTv3AJQ74y07lVahLY4eEFsPsE97pxYBwr8nnjMAIch5eqI0gPShyw==}
|
||||
dependencies:
|
||||
stream-chain: 2.2.5
|
||||
dev: false
|
||||
|
||||
/streamsearch@1.1.0:
|
||||
resolution: {integrity: sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==}
|
||||
engines: {node: '>=10.0.0'}
|
||||
|
||||
/strict-uri-encode@2.0.0:
|
||||
resolution: {integrity: sha512-QwiXZgpRcKkhTj2Scnn++4PKtWsH0kpzZ62L2R6c/LUVYv7hVnZqcg2+sMuT6R7Jusu1vviK/MFsu6kNJfWlEQ==}
|
||||
engines: {node: '>=4'}
|
||||
dev: false
|
||||
|
||||
/string-convert@0.2.1:
|
||||
resolution: {integrity: sha512-u/1tdPl4yQnPBjnVrmdLo9gtuLvELKsAoRapekWggdiQNvvvum+jYF329d84NAa660KQw7pB2n36KrIKVoXa3A==}
|
||||
dev: false
|
||||
|
|
@ -9102,6 +9639,12 @@ packages:
|
|||
emoji-regex: 9.2.2
|
||||
strip-ansi: 7.1.2
|
||||
|
||||
/string_decoder@1.1.1:
|
||||
resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==}
|
||||
dependencies:
|
||||
safe-buffer: 5.1.2
|
||||
dev: false
|
||||
|
||||
/string_decoder@1.3.0:
|
||||
resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==}
|
||||
dependencies:
|
||||
|
|
@ -9154,6 +9697,10 @@ packages:
|
|||
qs: 6.14.1
|
||||
dev: false
|
||||
|
||||
/strnum@1.1.2:
|
||||
resolution: {integrity: sha512-vrN+B7DBIoTTZjnPNewwhx6cBA/H+IS7rfW68n7XxC1y7uoiGQBxaKzqucGUgavX15dJgiGztLJ8vxuEzwqBdA==}
|
||||
dev: false
|
||||
|
||||
/strtok3@10.3.4:
|
||||
resolution: {integrity: sha512-KIy5nylvC5le1OdaaoCJ07L+8iQzJHGH6pWDuzS+d07Cu7n1MZ2x26P8ZKIWfbK02+XIL8Mp4RkWeqdUCrDMfg==}
|
||||
engines: {node: '>=18'}
|
||||
|
|
@ -9354,6 +9901,12 @@ packages:
|
|||
engines: {node: '>=12.22'}
|
||||
dev: false
|
||||
|
||||
/through2@4.0.2:
|
||||
resolution: {integrity: sha512-iOqSav00cVxEEICeD7TjLB1sueEL+81Wpzp2bY17uZjZN0pWZPuo4suZ/61VujxmqSGFfgOcNuTZ85QJwNZQpw==}
|
||||
dependencies:
|
||||
readable-stream: 3.6.2
|
||||
dev: false
|
||||
|
||||
/through@2.3.8:
|
||||
resolution: {integrity: sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==}
|
||||
|
||||
|
|
@ -10009,6 +10562,16 @@ packages:
|
|||
/util-deprecate@1.0.2:
|
||||
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
|
||||
|
||||
/util@0.12.5:
|
||||
resolution: {integrity: sha512-kZf/K6hEIrWHI6XqOFUiiMa+79wE/D8Q+NCNAWclkyg3b4d2k7s0QGepNjiABc+aR3N1PAyHL7p6UcLY6LmrnA==}
|
||||
dependencies:
|
||||
inherits: 2.0.4
|
||||
is-arguments: 1.2.0
|
||||
is-generator-function: 1.1.2
|
||||
is-typed-array: 1.1.15
|
||||
which-typed-array: 1.1.19
|
||||
dev: false
|
||||
|
||||
/utility@1.18.0:
|
||||
resolution: {integrity: sha512-PYxZDA+6QtvRvm//++aGdmKG/cI07jNwbROz0Ql+VzFV1+Z0Dy55NI4zZ7RHc9KKpBePNFwoErqIuqQv/cjiTA==}
|
||||
engines: {node: '>= 0.12.0'}
|
||||
|
|
@ -10146,6 +10709,14 @@ packages:
|
|||
defaults: 1.0.4
|
||||
dev: true
|
||||
|
||||
/web-encoding@1.1.5:
|
||||
resolution: {integrity: sha512-HYLeVCdJ0+lBYV2FvNZmv3HJ2Nt0QYXqZojk3d9FJOLkwnuhzM9tmamh8d7HPM8QqjKH8DeHkFTx+CFlWpZZDA==}
|
||||
dependencies:
|
||||
util: 0.12.5
|
||||
optionalDependencies:
|
||||
'@zxing/text-encoding': 0.9.0
|
||||
dev: false
|
||||
|
||||
/web-streams-polyfill@4.0.0-beta.3:
|
||||
resolution: {integrity: sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug==}
|
||||
engines: {node: '>= 14'}
|
||||
|
|
@ -10343,6 +10914,19 @@ packages:
|
|||
utf-8-validate:
|
||||
optional: true
|
||||
|
||||
/xml2js@0.6.2:
|
||||
resolution: {integrity: sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA==}
|
||||
engines: {node: '>=4.0.0'}
|
||||
dependencies:
|
||||
sax: 1.4.4
|
||||
xmlbuilder: 11.0.1
|
||||
dev: false
|
||||
|
||||
/xmlbuilder@11.0.1:
|
||||
resolution: {integrity: sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==}
|
||||
engines: {node: '>=4.0'}
|
||||
dev: false
|
||||
|
||||
/xmlhttprequest-ssl@2.1.2:
|
||||
resolution: {integrity: sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==}
|
||||
engines: {node: '>=0.4.0'}
|
||||
|
|
|
|||
|
|
@ -1445,6 +1445,60 @@ CREATE INDEX idx_admins_active ON admins(is_active);
|
|||
INSERT INTO admins (username, password_hash, name, role, permissions) VALUES
|
||||
('admin', '$2b$10$rQNDjKwYXOw8FNrFcD3e0.T8KCqVJLqDQT9gQR2KPnDqPvqK8VpKi', '系统管理员', 'SUPER_ADMIN', '["*"]');
|
||||
|
||||
-- ===========================================
|
||||
-- 文件表 (files)
|
||||
-- 存储用户上传的文件信息
|
||||
-- ===========================================
|
||||
CREATE TABLE files (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
-- 所属用户ID
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
-- 关联的对话ID(可选)
|
||||
conversation_id UUID REFERENCES conversations(id) ON DELETE SET NULL,
|
||||
-- 原始文件名
|
||||
original_name VARCHAR(500) NOT NULL,
|
||||
-- MinIO存储路径
|
||||
storage_path VARCHAR(1000) NOT NULL,
|
||||
-- MIME类型
|
||||
mime_type VARCHAR(100) NOT NULL,
|
||||
-- 文件类型: image, document, audio, video, other
|
||||
type VARCHAR(20) NOT NULL
|
||||
CHECK (type IN ('image', 'document', 'audio', 'video', 'other')),
|
||||
-- 文件大小(字节)
|
||||
size BIGINT NOT NULL,
|
||||
-- 文件状态: uploading, processing, ready, failed, deleted
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'uploading'
|
||||
CHECK (status IN ('uploading', 'processing', 'ready', 'failed', 'deleted')),
|
||||
-- 缩略图路径(图片文件)
|
||||
thumbnail_path VARCHAR(1000),
|
||||
-- 元数据(如图片尺寸、文档页数等)
|
||||
metadata JSONB,
|
||||
-- 提取的文本内容(用于文档)
|
||||
extracted_text TEXT,
|
||||
-- 错误信息
|
||||
error_message TEXT,
|
||||
-- 创建时间
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
-- 更新时间
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
-- 删除时间(软删除)
|
||||
deleted_at TIMESTAMP WITH TIME ZONE
|
||||
);
|
||||
|
||||
COMMENT ON TABLE files IS '文件表 - 存储用户上传的文件信息,关联MinIO存储';
|
||||
COMMENT ON COLUMN files.storage_path IS 'MinIO中的对象路径';
|
||||
COMMENT ON COLUMN files.type IS '文件类型分类,用于处理逻辑';
|
||||
COMMENT ON COLUMN files.status IS '文件状态,tracking处理进度';
|
||||
COMMENT ON COLUMN files.extracted_text IS '从文档提取的文本,用于AI分析';
|
||||
|
||||
CREATE INDEX idx_files_user_id ON files(user_id);
|
||||
CREATE INDEX idx_files_conversation_id ON files(conversation_id);
|
||||
CREATE INDEX idx_files_user_created ON files(user_id, created_at DESC);
|
||||
CREATE INDEX idx_files_status ON files(status);
|
||||
CREATE INDEX idx_files_type ON files(type);
|
||||
|
||||
CREATE TRIGGER update_files_updated_at BEFORE UPDATE ON files FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||
|
||||
-- ===========================================
|
||||
-- 结束
|
||||
-- ===========================================
|
||||
|
|
|
|||
Loading…
Reference in New Issue