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:
hailin 2026-01-10 05:34:41 -08:00
parent 7adbaaa871
commit d4925719fc
34 changed files with 3046 additions and 131 deletions

View File

@ -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:*)"
]
}
}

View File

@ -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

View File

@ -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)

View File

@ -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"

View File

@ -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
#===============================================================================
# 全局插件配置
#===============================================================================

View File

@ -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;

View File

@ -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', {

View File

@ -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;

View File

@ -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',

View File

@ -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

View File

@ -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"]

View File

@ -0,0 +1,8 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true
}
}

View File

@ -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"
}
}

View File

@ -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 {}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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);
}
}

View File

@ -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 {}

View File

@ -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;
}
}

View File

@ -0,0 +1,9 @@
import { Controller, Get } from '@nestjs/common';
@Controller()
export class HealthController {
@Get('health')
health() {
return { status: 'ok', timestamp: new Date().toISOString() };
}
}

View File

@ -0,0 +1,7 @@
import { Module } from '@nestjs/common';
import { HealthController } from './health.controller';
@Module({
controllers: [HealthController],
})
export class HealthModule {}

View File

@ -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();

View File

@ -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 {}

View File

@ -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,
);
}
}

View File

@ -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"]
}

View File

@ -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>

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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,
};
}

View File

@ -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>;
}

View File

@ -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,
};
}

View File

@ -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('删除文件失败');
}
}

View File

@ -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'}

View File

@ -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();
-- ===========================================
-- 结束
-- ===========================================