Compare commits

..

9 Commits

Author SHA1 Message Date
KofK
6dc977134b fix: deploy 2025-02-17 14:05:25 +03:00
KofK
3f1ccfdbfe fix: deploy 2025-02-16 18:07:38 +03:00
KofK
3928cbcd61 fix: main & yml 2025-02-15 13:52:05 +03:00
KofK
2eafc574b6 fix: yml 2025-02-12 12:40:23 +03:00
KofK
0870bbcba3 fix: yml 2025-02-12 12:37:49 +03:00
e7dab371f3 Обновить README.md 2025-02-12 19:21:01 +10:00
KofK
ef99f5de39 feat: add dockerfiles & change yml 2025-02-12 12:19:45 +03:00
KofK
a3b9ce9985 fix: g+f+p 2025-02-12 00:57:29 +03:00
KofK
0c81c88228 feat: im1 2025-02-10 15:53:52 +03:00
71 changed files with 5635 additions and 16242 deletions

21
.gitignore vendored Normal file
View File

@@ -0,0 +1,21 @@
/uploads
/__pycache__
/myenv
imageboard.db
**/node_modules
**/venv
**/.git
**/.github
**/logs
**/dist
**/build
*.log
*.md
.DS_Store
.env
**/__pycache__
*.sqlite3
*.pyc
*.pyo
*.pyd
.Python

18
Dockerfile Normal file
View File

@@ -0,0 +1,18 @@
# Используем официальный образ Python
FROM python:3.9-slim
# Устанавливаем системные зависимости
RUN apt-get update && \
apt-get install -y --no-install-recommends gcc python3-dev && \
rm -rf /var/lib/apt/lists/*
# Рабочая директория
WORKDIR /app
# Копируем весь код и устанавливаем зависимости
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
# Команда для запуска
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]

1
README.md Normal file
View File

@@ -0,0 +1 @@
docker-compose up --build

View File

@@ -1,2 +0,0 @@
DATABASE_URL=
SERVICE_TOKEN=

View File

@@ -1,25 +0,0 @@
module.exports = {
parser: '@typescript-eslint/parser',
parserOptions: {
project: 'tsconfig.json',
tsconfigRootDir: __dirname,
sourceType: 'module',
},
plugins: ['@typescript-eslint/eslint-plugin'],
extends: [
'plugin:@typescript-eslint/recommended',
'plugin:prettier/recommended',
],
root: true,
env: {
node: true,
jest: true,
},
ignorePatterns: ['.eslintrc.js'],
rules: {
'@typescript-eslint/interface-name-prefix': 'off',
'@typescript-eslint/explicit-function-return-type': 'off',
'@typescript-eslint/explicit-module-boundary-types': 'off',
'@typescript-eslint/no-explicit-any': 'off',
},
};

56
back/.gitignore vendored
View File

@@ -1,56 +0,0 @@
# compiled output
/dist
/node_modules
/build
# Logs
logs
*.log
npm-debug.log*
pnpm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
# OS
.DS_Store
# Tests
/coverage
/.nyc_output
# IDEs and editors
/.idea
.project
.classpath
.c9/
*.launch
.settings/
*.sublime-workspace
# IDE - VSCode
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
# temp directory
.temp
.tmp
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json

View File

@@ -1,4 +0,0 @@
{
"singleQuote": true,
"trailingComma": "all"
}

View File

@@ -1,23 +0,0 @@
# Use the official Node.js image
FROM node:18
# Set the working directory
WORKDIR /app
# Copy package.json and package-lock.json
COPY package*.json ./
# Install dependencies
RUN npm install
# Copy the rest of the application code
COPY . .
# Build the application
RUN npm run build
# Expose the port the app runs on
EXPOSE 3000
# Start the application
CMD ["npm", "run", "start:dev"]

View File

@@ -1,73 +0,0 @@
<p align="center">
<a href="http://nestjs.com/" target="blank"><img src="https://nestjs.com/img/logo-small.svg" width="200" alt="Nest Logo" /></a>
</p>
[circleci-image]: https://img.shields.io/circleci/build/github/nestjs/nest/master?token=abc123def456
[circleci-url]: https://circleci.com/gh/nestjs/nest
<p align="center">A progressive <a href="http://nodejs.org" target="_blank">Node.js</a> framework for building efficient and scalable server-side applications.</p>
<p align="center">
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/v/@nestjs/core.svg" alt="NPM Version" /></a>
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/l/@nestjs/core.svg" alt="Package License" /></a>
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/dm/@nestjs/common.svg" alt="NPM Downloads" /></a>
<a href="https://circleci.com/gh/nestjs/nest" target="_blank"><img src="https://img.shields.io/circleci/build/github/nestjs/nest/master" alt="CircleCI" /></a>
<a href="https://coveralls.io/github/nestjs/nest?branch=master" target="_blank"><img src="https://coveralls.io/repos/github/nestjs/nest/badge.svg?branch=master#9" alt="Coverage" /></a>
<a href="https://discord.gg/G7Qnnhy" target="_blank"><img src="https://img.shields.io/badge/discord-online-brightgreen.svg" alt="Discord"/></a>
<a href="https://opencollective.com/nest#backer" target="_blank"><img src="https://opencollective.com/nest/backers/badge.svg" alt="Backers on Open Collective" /></a>
<a href="https://opencollective.com/nest#sponsor" target="_blank"><img src="https://opencollective.com/nest/sponsors/badge.svg" alt="Sponsors on Open Collective" /></a>
<a href="https://paypal.me/kamilmysliwiec" target="_blank"><img src="https://img.shields.io/badge/Donate-PayPal-ff3f59.svg"/></a>
<a href="https://opencollective.com/nest#sponsor" target="_blank"><img src="https://img.shields.io/badge/Support%20us-Open%20Collective-41B883.svg" alt="Support us"></a>
<a href="https://twitter.com/nestframework" target="_blank"><img src="https://img.shields.io/twitter/follow/nestframework.svg?style=social&label=Follow"></a>
</p>
<!--[![Backers on Open Collective](https://opencollective.com/nest/backers/badge.svg)](https://opencollective.com/nest#backer)
[![Sponsors on Open Collective](https://opencollective.com/nest/sponsors/badge.svg)](https://opencollective.com/nest#sponsor)-->
## Description
[Nest](https://github.com/nestjs/nest) framework TypeScript starter repository.
## Installation
```bash
$ npm install
```
## Running the app
```bash
# development
$ npm run start
# watch mode
$ npm run start:dev
# production mode
$ npm run start:prod
```
## Test
```bash
# unit tests
$ npm run test
# e2e tests
$ npm run test:e2e
# test coverage
$ npm run test:cov
```
## Support
Nest is an MIT-licensed open source project. It can grow thanks to the sponsors and support by the amazing backers. If you'd like to join them, please [read more here](https://docs.nestjs.com/support).
## Stay in touch
- Author - [Kamil Myśliwiec](https://kamilmysliwiec.com)
- Website - [https://nestjs.com](https://nestjs.com/)
- Twitter - [@nestframework](https://twitter.com/nestframework)
## License
Nest is [MIT licensed](LICENSE).

View File

@@ -1,34 +0,0 @@
version: '3.8'
services:
postgres:
image: pgvector/pgvector:pg16
container_name: postgres
environment:
POSTGRES_USER: user
POSTGRES_PASSWORD: password
POSTGRES_DB: mydatabase
ports:
- "5432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
networks:
- mynetwork
app:
build: .
container_name: app
ports:
- "3000:3000"
environment:
DATABASE_URL: "postgresql://user:password@postgres:5432/mydatabase"
depends_on:
- postgres
networks:
- mynetwork
networks:
mynetwork:
driver: bridge
volumes:
postgres_data:

View File

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

9919
back/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,79 +0,0 @@
{
"name": "vdkch",
"version": "0.0.1",
"description": "",
"author": "",
"private": true,
"license": "UNLICENSED",
"scripts": {
"build": "nest build",
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
"start": "nest start",
"start:dev": "nest start --watch",
"start:debug": "nest start --debug --watch",
"start:prod": "node dist/main",
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
"test": "jest",
"test:watch": "jest --watch",
"test:cov": "jest --coverage",
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
"test:e2e": "jest --config ./test/jest-e2e.json"
},
"dependencies": {
"@langchain/community": "^0.2.28",
"@langchain/ollama": "^0.0.4",
"@nestjs/common": "^10.4.1",
"@nestjs/core": "^10.4.1",
"@nestjs/passport": "^10.0.3",
"@nestjs/platform-express": "^10.4.1",
"@prisma/client": "^5.18.0",
"dotenv": "^16.4.5",
"langchain": "^0.2.16",
"multer": "^1.4.5-lts.1",
"passport": "^0.7.0",
"passport-http": "^0.3.0",
"reflect-metadata": "^0.2.0",
"rxjs": "^7.8.1"
},
"devDependencies": {
"@nestjs/cli": "^10.0.0",
"@nestjs/schematics": "^10.0.0",
"@nestjs/testing": "^10.0.0",
"@types/express": "^4.17.17",
"@types/jest": "^29.5.2",
"@types/node": "^20.16.1",
"@types/supertest": "^6.0.0",
"@typescript-eslint/eslint-plugin": "^7.0.0",
"@typescript-eslint/parser": "^7.0.0",
"eslint": "^8.42.0",
"eslint-config-prettier": "^9.0.0",
"eslint-plugin-prettier": "^5.0.0",
"jest": "^29.5.0",
"prettier": "^3.0.0",
"prisma": "^5.18.0",
"source-map-support": "^0.5.21",
"supertest": "^7.0.0",
"ts-jest": "^29.1.0",
"ts-loader": "^9.4.3",
"ts-node": "^10.9.1",
"tsconfig-paths": "^4.2.0",
"typescript": "^5.1.3"
},
"jest": {
"moduleFileExtensions": [
"js",
"json",
"ts"
],
"rootDir": "src",
"testRegex": ".*\\.spec\\.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
},
"collectCoverageFrom": [
"**/*.(t|j)s"
],
"coverageDirectory": "../coverage",
"testEnvironment": "node"
}
}

View File

@@ -1,20 +0,0 @@
generator client {
provider = "prisma-client-js"
binaryTargets = ["native", "debian-openssl-3.0.x"]
previewFeatures = ["postgresqlExtensions"]
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
extensions = [pgvector(map: "vector")]
}
model Post {
id Int @id @default(autoincrement())
text String?
media String? // URL или путь к файлу
mediaType String? // "image", "video", "drawing", "code"
code String? // Код, который нужно запустить
embedding Float[] //Unsupported("vector")?
}

View File

@@ -1,19 +0,0 @@
import { Module, Injectable, Logger } from '@nestjs/common';
import { resolve } from 'path';
import * as dotenv from 'dotenv';
export type PossibleModels =
| 'text-davinci-002-render'
| 'text-davinci-003'
| 'gpt-3.5-turbo';
@Injectable()
export class AppConfig {
public readonly serviceToken: string = process.env.SERVICE_TOKEN || '';
}
@Module({
providers: [AppConfig],
exports: [AppConfig],
})
export class AppConfigModule {}

View File

@@ -1,22 +0,0 @@
import { Test, TestingModule } from '@nestjs/testing';
import { AppController } from './app.controller';
import { AppService } from './app.service';
describe('AppController', () => {
let appController: AppController;
beforeEach(async () => {
const app: TestingModule = await Test.createTestingModule({
controllers: [AppController],
providers: [AppService],
}).compile();
appController = app.get<AppController>(AppController);
});
describe('root', () => {
it('should return "Hello World!"', () => {
expect(appController.getHello()).toBe('Hello World!');
});
});
});

View File

@@ -1,12 +0,0 @@
import { Controller, Get } from '@nestjs/common';
import { AppService } from './app.service';
@Controller()
export class AppController {
constructor(private readonly appService: AppService) {}
@Get()
getHello(): string {
return this.appService.getHello();
}
}

View File

@@ -1,10 +0,0 @@
import { Module } from '@nestjs/common';
import { PostsModule } from './posts/posts.module';
import { PrismaService } from './prisma.service';
import { AuthModule } from './auth/auth.module';
@Module({
imports: [AuthModule, PostsModule],
providers: [PrismaService],
})
export class AppModule {}

View File

@@ -1,8 +0,0 @@
import { Injectable } from '@nestjs/common';
@Injectable()
export class AppService {
getHello(): string {
return 'Hello World!';
}
}

View File

@@ -1,14 +0,0 @@
import { CanActivate, ExecutionContext, Injectable } from "@nestjs/common";
import { AppConfig } from "src/app.config";
@Injectable()
export class AuthGuard implements CanActivate {
constructor(
private appConfig: AppConfig,
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest();
const authHeader = request.headers.authorization;
return authHeader == this.appConfig.serviceToken;
}
}

View File

@@ -1,10 +0,0 @@
import { Module } from "@nestjs/common";
import { AppConfigModule } from "src/app.config";
import { AuthGuard } from "./auth.guard";
@Module({
imports: [AppConfigModule],
providers: [AuthGuard],
exports: [AuthGuard],
})
export class AuthModule {}

View File

@@ -1,11 +0,0 @@
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.enableCors({
origin: '*', // Замените на домен вашего другого проекта
});
await app.listen(3000);
}
bootstrap();

View File

@@ -1,8 +0,0 @@
export interface Post {
id?: number;
text: string;
imageUrl?: string;
videoUrl?: string;
drawing?: string;
code?: string;
}

View File

@@ -1,18 +0,0 @@
import { Test, TestingModule } from '@nestjs/testing';
import { PostsController } from './posts.controller';
describe('PostsController', () => {
let controller: PostsController;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [PostsController],
}).compile();
controller = module.get<PostsController>(PostsController);
});
it('should be defined', () => {
expect(controller).toBeDefined();
});
});

View File

@@ -1,19 +0,0 @@
import { Controller, Post, Body, Get, UseGuards } from '@nestjs/common';
import { PostsService } from './posts.service';
import { AuthGuard } from "../auth/auth.guard";
@UseGuards(AuthGuard)
@Controller('posts')
export class PostsController {
constructor(private readonly postsService: PostsService) {}
@Post()
async create(@Body() createPostDto: { text?: string; media?: string; mediaType?: string; code?: string; embedding?: number[] }) {
return this.postsService.create(createPostDto);
}
@Get()
async findAll() {
return this.postsService.findAll();
}
}

View File

@@ -1,12 +0,0 @@
import { Module } from '@nestjs/common';
import { PostsService } from './posts.service';
import { PostsController } from './posts.controller';
import { PrismaService } from '../prisma.service';
import { AppConfigModule } from 'src/app.config';
@Module({
imports: [AppConfigModule],
providers: [PostsService, PrismaService],
controllers: [PostsController],
})
export class PostsModule {}

View File

@@ -1,18 +0,0 @@
import { Test, TestingModule } from '@nestjs/testing';
import { PostsService } from './posts.service';
describe('PostsService', () => {
let service: PostsService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [PostsService],
}).compile();
service = module.get<PostsService>(PostsService);
});
it('should be defined', () => {
expect(service).toBeDefined();
});
});

View File

@@ -1,36 +0,0 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../prisma.service';
import { MemoryVectorStore } from 'langchain/vectorstores/memory';
import { RecursiveCharacterTextSplitter } from 'langchain/text_splitter';
import { OllamaEmbeddings } from '@langchain/ollama';
const embeddings = new OllamaEmbeddings({
model: 'llama2',
baseUrl: 'http://host.docker.internal:11434',
});
@Injectable()
export class PostsService {
constructor(private readonly prisma: PrismaService) {}
async create(data: { text?: string; media?: string; mediaType?: string; code?: string; embedding?: number[] }) {
const splitter = RecursiveCharacterTextSplitter.fromLanguage("markdown", {
chunkSize: 500,
chunkOverlap: 0,
});
const output = await splitter.createDocuments([data.text]);
console.log(output)
const vectorstore = await MemoryVectorStore.fromDocuments(output, embeddings);
console.log(vectorstore);
data.embedding ??= vectorstore.memoryVectors[0].embedding
return this.prisma.post.create({
data,
});
}
async findAll() {
return this.prisma.post.findMany();
}
}

View File

@@ -1,10 +0,0 @@
import 'dotenv/config';
import { Injectable } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
@Injectable()
export class PrismaService extends PrismaClient {
constructor() {
super();
}
}

View File

@@ -1,24 +0,0 @@
import { Test, TestingModule } from '@nestjs/testing';
import { INestApplication } from '@nestjs/common';
import * as request from 'supertest';
import { AppModule } from './../src/app.module';
describe('AppController (e2e)', () => {
let app: INestApplication;
beforeEach(async () => {
const moduleFixture: TestingModule = await Test.createTestingModule({
imports: [AppModule],
}).compile();
app = moduleFixture.createNestApplication();
await app.init();
});
it('/ (GET)', () => {
return request(app.getHttpServer())
.get('/')
.expect(200)
.expect('Hello World!');
});
});

View File

@@ -1,9 +0,0 @@
{
"moduleFileExtensions": ["js", "json", "ts"],
"rootDir": ".",
"testEnvironment": "node",
"testRegex": ".e2e-spec.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
}
}

View File

@@ -1,4 +0,0 @@
{
"extends": "./tsconfig.json",
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
}

View File

@@ -1,23 +0,0 @@
{
"compilerOptions": {
"module": "commonjs",
"declaration": true,
"removeComments": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"allowSyntheticDefaultImports": true,
"target": "ES2021",
"sourceMap": true,
"outDir": "./dist",
"baseUrl": "./",
"incremental": true,
"skipLibCheck": true,
"strictNullChecks": false,
"noImplicitAny": false,
"strictBindCallApply": false,
"forceConsistentCasingInFileNames": false,
"noFallthroughCasesInSwitch": false
},
"include": ["src/**/*"],
"exclude": ["node_modules", "**/*.spec.ts"]
}

58
docker-compose.yml Normal file
View File

@@ -0,0 +1,58 @@
version: "3.8"
services:
qdrant:
build:
context: ./qdrant
dockerfile: Dockerfile
container_name: qdrant
ports:
- "6333:6333"
- "6334:6334"
volumes:
- qdrant_data:/qdrant/storage
restart: unless-stopped
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:6333/collections"]
interval: 10s
timeout: 5s
retries: 5
backend:
build:
context: .
dockerfile: Dockerfile
container_name: backend
ports:
- "8000:8000"
restart: unless-stopped
depends_on:
qdrant:
condition: service_healthy
environment:
- QDRANT_HOST=qdrant
- QDRANT_PORT=6333
futa-clone:
build:
context: ./futa-clone
dockerfile: Dockerfile
container_name: futa-clone
ports:
- "3000:3000"
restart: unless-stopped
ollama:
build:
context: ./ollama
dockerfile: Dockerfile
container_name: ollama
ports:
- "11434:11434"
volumes:
- ollama_data:/root/.ollama
restart: unless-stopped
volumes:
qdrant_data:
ollama_data:

View File

@@ -1 +0,0 @@
REACT_APP_SERVICE_TOKEN=1234

View File

@@ -1,38 +0,0 @@
.App {
text-align: center;
}
.App-logo {
height: 40vmin;
pointer-events: none;
}
@media (prefers-reduced-motion: no-preference) {
.App-logo {
animation: App-logo-spin infinite 20s linear;
}
}
.App-header {
background-color: #282c34;
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
font-size: calc(10px + 2vmin);
color: white;
}
.App-link {
color: #61dafb;
}
@keyframes App-logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}

View File

@@ -1,13 +0,0 @@
// src/App.js
import React from 'react';
import Posts from './Posts';
function App() {
return (
<div className="App">
<Posts />
</div>
);
}
export default App;

View File

@@ -1,75 +0,0 @@
import React, { useState, useEffect } from 'react';
import axios from 'axios';
import ReactMarkdown from 'react-markdown';
import MarkdownEditor from 'react-markdown-editor-lite';
import 'react-markdown-editor-lite/lib/index.css';
import MarkdownIt from 'markdown-it';
// Инициализация MarkdownIt
const mdParser = new MarkdownIt();
const Posts = () => {
const [posts, setPosts] = useState([]);
const [text, setText] = useState('');
const axiosInstance = axios.create({
baseURL: 'http://localhost:3000',
headers: {
Authorization: process.env.REACT_APP_SERVICE_TOKEN,
},
});
useEffect(() => {
fetchPosts();
}, []);
const fetchPosts = async () => {
try {
const response = await axiosInstance.get('/posts');
setPosts(response.data);
} catch (error) {
console.error('Error fetching posts:', error);
}
};
const handleSubmit = async (e) => {
e.preventDefault();
try {
const newPost = { text };
await axiosInstance.post('/posts', newPost);
fetchPosts(); // Обновляем список постов после создания нового
setText('');
} catch (error) {
console.error('Error creating post:', error);
}
};
const handleEditorChange = ({ text }) => {
setText(text);
};
return (
<div>
<h1>Posts</h1>
<form onSubmit={handleSubmit}>
<MarkdownEditor
value={text}
style={{ height: '300px' }}
renderHTML={(text) => mdParser.render(text)}
onChange={handleEditorChange}
/>
<button type="submit">Create Post</button>
</form>
<h2>All Posts</h2>
<ul>
{posts.map((post, index) => (
<li key={index}>
<ReactMarkdown>{post.text}</ReactMarkdown>
</li>
))}
</ul>
</div>
);
};
export default Posts;

View File

@@ -1,17 +0,0 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import './index.css';
import App from './App';
import reportWebVitals from './reportWebVitals';
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(
<React.StrictMode>
<App />
</React.StrictMode>
);
// If you want to start measuring performance in your app, pass a function
// to log results (for example: reportWebVitals(console.log))
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
reportWebVitals();

18
futa-clone/Dockerfile Normal file
View File

@@ -0,0 +1,18 @@
# Используем официальный Node.js образ (на примере версии 16 на базе Alpine)
FROM node:20-alpine
# Устанавливаем рабочую директорию внутри контейнера
WORKDIR /app
# Копируем файлы зависимостей и устанавливаем их
COPY package*.json ./
RUN npm install
# Копируем исходный код приложения
COPY . .
# Если требуется, можно указать порт (например, 3000)
EXPOSE 3000
# Запускаем приложение
CMD ["npm", "run", "start"]

File diff suppressed because it is too large Load Diff

View File

@@ -1,19 +1,21 @@
{
"name": "front",
"name": "futa-clone",
"version": "0.1.0",
"private": true,
"dependencies": {
"@testing-library/jest-dom": "^5.17.0",
"@testing-library/react": "^13.4.0",
"@testing-library/user-event": "^13.5.0",
"axios": "^1.7.4",
"markdown-it": "^14.1.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-markdown": "^9.0.1",
"react-markdown-editor-lite": "^1.3.4",
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.0",
"@mui/icons-material": "^6.4.4",
"@mui/material": "^6.4.4",
"cra-template": "1.2.0",
"formik": "^2.4.6",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.1.5",
"react-scripts": "5.0.1",
"web-vitals": "^2.1.4"
"vis-network": "^9.1.9",
"web-vitals": "^4.2.4",
"yup": "^1.6.1"
},
"scripts": {
"start": "react-scripts start",

View File

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 3.8 KiB

View File

Before

Width:  |  Height:  |  Size: 5.2 KiB

After

Width:  |  Height:  |  Size: 5.2 KiB

View File

Before

Width:  |  Height:  |  Size: 9.4 KiB

After

Width:  |  Height:  |  Size: 9.4 KiB

162
futa-clone/src/App.css Normal file
View File

@@ -0,0 +1,162 @@
/* App.css */
/*#network {
width: 100%;
height: 600px;
border: 1px solid #800;
background: white;
}
.hidden { display: none; }
#greeting {
text-align: center;
padding: 20px;
}
.graph-container {
margin-top: 20px;
}
body {
background-color: #F0E0D6;
font-family: Arial, sans-serif;
margin: 20px;
max-width: 800px;
margin-left: auto;
margin-right: auto;
}
.post-form {
background: #FFF;
border: 1px solid #800;
padding: 15px;
margin-bottom: 20px;
}
.post {
background: #FFF;
border: 1px solid #800;
padding: 15px;
margin-bottom: 10px;
}
.post img {
max-width: 200px;
max-height: 200px;
margin-top: 10px;
}
input, textarea, button {
margin: 5px;
padding: 5px;
}
.preview-image {
max-width: 150px;
margin: 10px 0;
}*/
/* App.css */
#network {
width: 100%;
height: 600px;
border: 1px solid rgba(54, 102, 153, 0.3); /* Приглушенный синий с прозрачностью */
background: rgba(245, 249, 252, 0.9); /* Светлый голубовато-белый фон */
backdrop-filter: blur(2px);
border-radius: 4px;
}
.hidden { display: none; }
#greeting {
text-align: center;
padding: 20px;
color: #2c3e50; /* Темно-синий для контраста */
text-shadow: 1px 1px 2px rgba(255, 255, 255, 0.5);
}
.graph-container {
margin-top: 20px;
background: rgba(245, 249, 252, 0.8);
border-radius: 8px;
padding: 15px;
}
body {
background-color: #f5f9fc; /* Основной фоновый цвет - холодный бело-голубой */
font-family: 'Segoe UI', Arial, sans-serif;
margin: 20px;
max-width: 800px;
margin-left: auto;
margin-right: auto;
}
.post-form {
background: rgba(255, 255, 255, 0.95); /* Полупрозрачный белый */
border: 1px solid rgba(54, 102, 153, 0.2);
padding: 15px;
margin-bottom: 20px;
border-radius: 6px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
transition: all 0.3s ease;
}
.post {
background: rgba(255, 255, 255, 0.95); /* Полупрозрачный белый */
border: 1px solid rgba(54, 102, 153, 0.15);
padding: 15px;
margin-bottom: 10px;
border-radius: 6px;
backdrop-filter: blur(3px);
transition: transform 0.2s ease;
}
.post:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
}
.post img {
max-width: 200px;
max-height: 200px;
margin-top: 10px;
border-radius: 4px;
border: 1px solid rgba(54, 102, 153, 0.1);
}
input, textarea {
margin: 5px;
padding: 8px;
border: 1px solid rgba(54, 102, 153, 0.2);
border-radius: 4px;
background: rgba(255, 255, 255, 0.9);
transition: all 0.3s ease;
}
input:focus, textarea:focus {
outline: none;
box-shadow: 0 0 0 2px rgba(54, 102, 153, 0.2);
}
button {
margin: 5px;
padding: 8px 16px;
background: rgba(54, 102, 153, 0.85); /* Полупрозрачный синий */
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
transition: all 0.3s ease;
}
button:hover {
background: rgba(54, 102, 153, 1);
transform: scale(1.02);
}
.preview-image {
max-width: 150px;
margin: 10px 0;
border-radius: 4px;
opacity: 0.9;
}

38
futa-clone/src/App.js Normal file
View File

@@ -0,0 +1,38 @@
import { ThemeProvider, createTheme } from '@mui/material/styles';
import CssBaseline from '@mui/material/CssBaseline';
import logo from './logo.svg';
import './App.css';
const theme = createTheme({
palette: {
primary: {
main: '#1976d2',
},
},
});
function App() {
return (
<ThemeProvider theme={theme}>
<CssBaseline />
<div className="App">
<header className="App-header">
<img src={logo} className="App-logo" alt="logo" />
<p>
Edit <code>src/App.js</code> and save to reload.
</p>
<a
className="App-link"
href="https://reactjs.org"
target="_blank"
rel="noopener noreferrer"
>
Learn React
</a>
</header>
</div>
</ThemeProvider>
);
}
export default App;

View File

@@ -0,0 +1,52 @@
import { useState, useEffect } from 'react';
import PostForm from './PostForm';
import SearchForm from './SearchForm';
import PostList from './PostList';
import Greeting from './Greeting';
import GraphContainer from './GraphContainer';
import { API_URL } from '../config';
import '../App.css';
function App() {
const [posts, setPosts] = useState([]);
const [searchResults, setSearchResults] = useState([]);
const [userVector, setUserVector] = useState(
JSON.parse(localStorage.getItem('userVector')) || null
);
useEffect(() => {
loadPosts();
}, []);
const loadPosts = async () => {
try {
const response = await fetch(`${API_URL}/posts/`);
const data = await response.json();
setPosts(data);
} catch (error) {
console.error('Error loading posts:', error);
}
};
return (
<div className="app">
<Greeting show={!userVector} />
{userVector && <GraphContainer vector={userVector} />}
<PostForm
onPostCreated={(vector) => {
localStorage.setItem('userVector', JSON.stringify(vector));
setUserVector(vector);
loadPosts();
}}
/>
<SearchForm onSearch={setSearchResults} />
<PostList posts={searchResults.length > 0 ? searchResults : posts} />
</div>
);
}
export default App;

View File

@@ -0,0 +1,94 @@
import { useState, useEffect, useRef } from 'react';
import vis from 'vis-network/standalone/umd/vis-network.min';
import { API_URL } from '../config';
function GraphContainer({ vector }) {
const [posts, setPosts] = useState([]);
const networkRef = useRef(null);
const containerRef = useRef(null);
useEffect(() => {
const loadTree = async () => {
try {
const response = await fetch(`${API_URL}/posts/tree`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ vector }),
});
const data = await response.json();
// Добавлен лог полученных данных
console.log('Данные, полученные с сервера:', data);
setPosts(data);
} catch (error) {
console.error('Tree load error:', error);
}
};
loadTree();
}, [vector]);
// Остальной код без изменений
useEffect(() => {
if (posts.length > 0 && containerRef.current) {
const { nodes, edges } = createGraphData(posts);
const options = {
nodes: {
borderWidth: 2,
color: { border: '#42A5F5', background: '#F0E0D6' },
font: { size: 14 }
},
edges: {
color: '#42A5F5',
width: 2,
smooth: { type: 'curvedCW' },
arrows: 'none'
},
physics: {
stabilization: true,
barnesHut: { gravitationalConstant: -2000, springLength: 200 }
}
};
networkRef.current = new vis.Network(
containerRef.current,
{ nodes, edges },
options
);
}
return () => {
if (networkRef.current) {
networkRef.current.destroy();
}
};
}, [posts]);
return (
<div className="graph-container">
<h2>Ризома мыслительная</h2>
<div ref={containerRef} style={{ width: '100%', height: '600px', border: '0px solid #800' }} />
</div>
);
}
function createGraphData(posts) {
const nodes = posts.map(post => ({
id: post.id,
label: post.text || '[Изображение]',
image: post.image ? `${API_URL}/${post.image}` : 'https://via.placeholder.com/100',
shape: 'circularImage',
size: 25
}));
const edges = posts.slice(1).map((post, index) => ({
from: posts[index].id,
to: post.id
}));
return { nodes, edges };
}
export default GraphContainer;

View File

@@ -0,0 +1,9 @@
function Greeting({ show }) {
return show ? (
<div id="greeting">
<h2>Отправьте первое сообщение, чтобы начать</h2>
</div>
) : null;
}
export default Greeting;

View File

@@ -0,0 +1,152 @@
import { useFormik } from 'formik';
import { useState } from 'react';
import {
Box,
Button,
TextField,
CircularProgress,
InputAdornment,
IconButton,
Snackbar,
Alert,
Typography,
Paper
} from '@mui/material';
import { PhotoCamera, Close } from '@mui/icons-material';
import { API_URL } from '../config';
function PostForm({ onPostCreated }) {
const [preview, setPreview] = useState('');
const [snackbar, setSnackbar] = useState({ open: false, message: '', severity: 'success' });
const formik = useFormik({
initialValues: {
text: '',
image: null
},
onSubmit: async (values, { setSubmitting, resetForm }) => {
try {
const formData = new FormData();
if (values.text) formData.append('text', values.text);
if (values.image) formData.append('image', values.image);
const response = await fetch(`${API_URL}/posts/`, {
method: 'POST',
body: formData,
});
if (!response.ok) throw new Error('Ошибка сервера');
const data = await response.json();
onPostCreated(data.vector);
resetForm();
setPreview('');
showSnackbar('Пост успешно создан!', 'success');
} catch (error) {
showSnackbar(error.message || 'Ошибка создания поста', 'error');
} finally {
setSubmitting(false);
}
}
});
const handleImageChange = (e) => {
const file = e.target.files[0];
if (file) {
formik.setFieldValue('image', file);
setPreview(URL.createObjectURL(file));
}
};
const showSnackbar = (message, severity) => {
setSnackbar({ open: true, message, severity });
};
return (
<Paper elevation={3} sx={{ p: 3, mb: 4, maxWidth: 600, mx: 'auto' }}>
<Typography variant="h5" gutterBottom component="div">
Новый пост
</Typography>
<form onSubmit={formik.handleSubmit}>
<TextField
fullWidth
multiline
minRows={4}
name="text"
value={formik.values.text}
onChange={formik.handleChange}
placeholder="Текст поста..."
variant="outlined"
margin="normal"
InputProps={{
endAdornment: (
<InputAdornment position="end">
<input
accept="image/*"
style={{ display: 'none' }}
id="image-upload"
type="file"
onChange={handleImageChange}
/>
<label htmlFor="image-upload">
<IconButton component="span" color="primary">
<PhotoCamera />
</IconButton>
</label>
</InputAdornment>
)
}}
/>
{preview && (
<Box sx={{ mt: 2, position: 'relative' }}>
<img
src={preview}
alt="Preview"
style={{
maxWidth: '100%',
borderRadius: 4,
maxHeight: 200,
objectFit: 'cover'
}}
/>
<IconButton
onClick={() => {
formik.setFieldValue('image', null);
setPreview('');
}}
sx={{ position: 'absolute', right: 8, top: 8, bgcolor: 'background.paper' }}
>
<Close />
</IconButton>
</Box>
)}
<Box sx={{ mt: 3, display: 'flex', justifyContent: 'flex-end' }}>
<Button
type="submit"
variant="contained"
color="primary"
disabled={formik.isSubmitting || (!formik.values.text && !formik.values.image)}
startIcon={formik.isSubmitting && <CircularProgress size={20} />}
>
{formik.isSubmitting ? 'Отправка...' : 'Опубликовать'}
</Button>
</Box>
</form>
<Snackbar
open={snackbar.open}
autoHideDuration={6000}
onClose={() => setSnackbar(prev => ({ ...prev, open: false }))}
>
<Alert severity={snackbar.severity} sx={{ width: '100%' }}>
{snackbar.message}
</Alert>
</Snackbar>
</Paper>
);
}
export default PostForm;

View File

@@ -0,0 +1,19 @@
import { API_URL } from '../config';
function PostList({ posts }) {
return (
<div id="postsContainer">
{posts.map(post => (
<div key={post.id} className="post">
<div className="post-meta">
#{post.id} - {new Date(post.created_at).toLocaleString()}
</div>
{post.text && <div className="post-text">{post.text}</div>}
{post.image && <img src={`${API_URL}/${post.image}`} alt="Post" />}
</div>
))}
</div>
);
}
export default PostList;

View File

@@ -0,0 +1,31 @@
import { useState } from 'react';
import { API_URL } from '../config';
function SearchForm({ onSearch }) {
const [query, setQuery] = useState('');
const handleSearch = async () => {
try {
const response = await fetch(`${API_URL}/search?text=${encodeURIComponent(query)}`);
const results = await response.json();
onSearch(results);
} catch (error) {
console.error('Search error:', error);
}
};
return (
<div className="post-form">
<h2>Поиск</h2>
<input
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Текст для поиска"
/>
<button onClick={handleSearch}>Искать</button>
</div>
);
}
export default SearchForm;

1
futa-clone/src/config.js Normal file
View File

@@ -0,0 +1 @@
export const API_URL = 'http://localhost:8000';

11
futa-clone/src/index.js Normal file
View File

@@ -0,0 +1,11 @@
import React from 'react';
import { createRoot } from 'react-dom/client';
import App from './components/App';
const container = document.getElementById('root');
const root = createRoot(container);
root.render(
<React.StrictMode>
<App />
</React.StrictMode>
);

View File

Before

Width:  |  Height:  |  Size: 2.6 KiB

After

Width:  |  Height:  |  Size: 2.6 KiB

270
index.html Normal file
View File

@@ -0,0 +1,270 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>FutaClone</title>
<!-- Добавляем библиотеку для визуализации графа -->
<script src="https://unpkg.com/vis-network/standalone/umd/vis-network.min.js"></script>
<style>
/* Добавляем новые стили */
#network {
width: 100%;
height: 600px;
border: 1px solid #800;
background: white;
}
.hidden { display: none; }
#greeting { text-align: center; padding: 20px; }
#graphContainer { margin-top: 20px; }
/* Стили в духе традиционных имиджборд */
body {
background-color: #F0E0D6;
font-family: Arial, sans-serif;
margin: 20px;
max-width: 800px;
margin-left: auto;
margin-right: auto;
}
.post-form {
background: #FFF;
border: 1px solid #800;
padding: 15px;
margin-bottom: 20px;
}
.post {
background: #FFF;
border: 1px solid #800;
padding: 15px;
margin-bottom: 10px;
}
.post img {
max-width: 200px;
max-height: 200px;
margin-top: 10px;
}
input, textarea, button {
margin: 5px;
padding: 5px;
}
.hidden {
display: none;
}
.preview-image {
max-width: 150px;
margin: 10px 0;
}
</style>
</head>
<body>
<!-- Добавляем новые блоки -->
<div id="greeting" class="hidden">
<h2>Отправьте первое сообщение, чтобы начать</h2>
</div>
<div id="graphContainer" class="hidden">
<h2>Древо мыслей</h2>
<div id="network"></div>
</div>
<!-- Форма создания поста -->
<div class="post-form">
<h2>Новый пост</h2>
<form id="postForm">
<textarea id="postText" rows="4" cols="50" placeholder="Текст поста"></textarea><br>
<input type="file" id="imageInput" accept="image/*"><br>
<div id="imagePreview" class="hidden"></div>
<button type="submit">Отправить</button>
</form>
</div>
<!-- Форма поиска (только текст) -->
<div class="post-form">
<h2>Поиск</h2>
<input type="text" id="searchText" placeholder="Текст для поиска">
<button onclick="searchPosts()">Искать</button>
</div>
<!-- Список постов -->
<div id="postsContainer"></div>
<script>
// Базовый URL бэкенда
const API_URL = 'http://localhost:8000';
// Обработка отправки поста (оставляем без изменений)
document.getElementById('postForm').addEventListener('submit', async (e) => {
e.preventDefault();
const text = document.getElementById('postText').value;
const fileInput = document.getElementById('imageInput');
const formData = new FormData();
if (text) formData.append('text', text);
if (fileInput.files[0]) formData.append('image', fileInput.files[0]);
try {
const response = await fetch(`${API_URL}/posts/`, {
method: 'POST',
body: formData
});
if (response.ok) {
const postData = await response.json();
// Сохраняем вектор в localStorage
localStorage.setItem('userVector', JSON.stringify(postData.vector));
checkUserVector();
loadPosts();
document.getElementById('postForm').reset();
document.getElementById('imagePreview').classList.add('hidden');
} else {
console.error('Error:', error);
}
} catch (error) {
console.error('Error:', error);
}
});
// Превью изображения перед загрузкой
document.getElementById('imageInput').addEventListener('change', function(e) {
const preview = document.getElementById('imagePreview');
if (this.files && this.files[0]) {
const reader = new FileReader();
reader.onload = function(e) {
preview.innerHTML = `<img src="${e.target.result}" class="preview-image">`;
preview.classList.remove('hidden');
}
reader.readAsDataURL(this.files[0]);
}
});
// Загрузка и отображение постов (оставляем без изменений)
async function loadPosts() {
try {
const response = await fetch(`${API_URL}/posts/`);
const posts = await response.json();
const container = document.getElementById('postsContainer');
container.innerHTML = posts.map(post => `
<div class="post">
<div class="post-meta">#${post.id} - ${new Date(post.created_at).toLocaleString()}</div>
${post.text ? `<div class="post-text">${post.text}</div>` : ''}
${post.image ? `<img src="${API_URL}/${post.image}" />` : ''}
</div>
`).join('');
} catch (error) {
console.error('Error:', error);
}
}
// Обновлённая функция поиска, использующая GET-запрос
async function searchPosts() {
const text = document.getElementById('searchText').value;
// Формируем строку запроса, если введён текст
const query = text ? `?text=${encodeURIComponent(text)}` : '';
try {
const response = await fetch(`${API_URL}/search/${query}`, {
method: 'GET'
});
if (!response.ok) {
throw new Error('Запрос поиска завершился с ошибкой');
}
const results = await response.json();
alert('Найдено постов: ' + results.length);
// Здесь можно добавить отображение результатов поиска в интерфейсе
} catch (error) {
console.error('Error during search:', error);
}
}
// Первоначальная загрузка постов
loadPosts();
// Новая функция проверки вектора
function checkUserVector() {
const hasVector = localStorage.getItem('userVector') !== null;
document.getElementById('greeting').classList.toggle('hidden', hasVector);
document.getElementById('graphContainer').classList.toggle('hidden', !hasVector);
if (hasVector) {
loadUserTree();
} else {
loadPosts();
}
}
// Функция загрузки дерева
async function loadUserTree() {
const userVector = JSON.parse(localStorage.getItem('userVector'));
if (!userVector) return;
try {
const response = await fetch(`${API_URL}/posts/tree`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ vector: userVector }),
});
const posts = await response.json();
renderGraph(posts);
} catch (error) {
console.error('Error loading tree:', error);
}
}
// Визуализация графа
function renderGraph(posts) {
const container = document.getElementById('network');
const nodes = [];
const edges = [];
posts.forEach((post, index) => {
nodes.push({
id: post.id,
label: post.text || '[Изображение]',
image: post.image ? `${API_URL}/${post.image}` : 'https://via.placeholder.com/100',
shape: 'circularImage',
size: 25
});
if (index > 0) {
edges.push({
from: posts[index-1].id,
to: post.id,
arrows: 'to',
smooth: { type: 'curvedCW' }
});
}
});
const data = { nodes, edges };
const options = {
nodes: {
borderWidth: 2,
color: {
border: '#800',
background: '#F0E0D6'
},
font: { size: 14 }
},
edges: {
color: '#800',
width: 2
},
physics: {
stabilization: true,
barnesHut: {
gravitationalConstant: -2000,
springLength: 200
}
}
};
new vis.Network(container, data, options);
}
// Инициализация при загрузке страницы
checkUserVector();
</script>
</body>
</html>

364
main.py Normal file
View File

@@ -0,0 +1,364 @@
# main.py
from fastapi import FastAPI, UploadFile, File, HTTPException, Form
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel
from typing import List, Optional
import uuid
import os
from datetime import datetime
import requests
from qdrant_client import QdrantClient
from qdrant_client.http import models
from fastapi.middleware.cors import CORSMiddleware
import time # Добавлен импорт модуля time
# Database setup
from sqlalchemy import create_engine, Column, String, DateTime, Text
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import sessionmaker
# Image processing
from PIL import Image
import io
import torch
from transformers import CLIPProcessor, CLIPModel
import logging
from qdrant_client.http.models import VectorParams, Distance
import os
from dotenv import load_dotenv
# Настройка базового логирования
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(name)s - %(message)s"
)
logger = logging.getLogger(__name__)
# Загрузить переменные окружения из файла .env
load_dotenv()
# Получение значений с указанием значений по умолчанию
COLLECTION_NAME = os.getenv("COLLECTION_NAME", "posts")
VECTOR_SIZE = int(os.getenv("VECTOR_SIZE", 1280))
DATABASE_URL = os.getenv("DATABASE_URL", "sqlite:///./imageboard.db")
QDRANT_URL = os.getenv("QDRANT_URL", "http://qdrant:6333")
OLLAMA_URL = os.getenv("OLLAMA_URL", "http://ollama:11435")
EMBEDDING_MODEL = os.getenv("EMBEDDING_MODEL", "nomic-embed-text")
IMAGE_MODEL = os.getenv("IMAGE_MODEL", "openai/clip-vit-base-patch32")
# IMAGE_SIZE ожидается в формате "224,224", преобразуем его в кортеж чисел
image_size_str = os.getenv("IMAGE_SIZE", "224,224")
IMAGE_SIZE = tuple(map(int, image_size_str.split(',')))
UPLOAD_DIR = os.getenv("UPLOAD_DIR", "uploads")
os.makedirs(UPLOAD_DIR, exist_ok=True)
# Инициализация компонентов
Base = declarative_base()
engine = create_engine(DATABASE_URL)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
# Инициализация CLIP
clip_model = CLIPModel.from_pretrained(IMAGE_MODEL)
clip_processor = CLIPProcessor.from_pretrained(IMAGE_MODEL)
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
clip_model = clip_model.to(device)
# Функция для создания QdrantClient с повторными попытками
def create_qdrant_client():
max_attempts = 5
attempt = 0
while attempt < max_attempts:
try:
client = QdrantClient(QDRANT_URL)
# Проверка подключения
client.get_collections()
logger.info("Успешное подключение к Qdrant")
return client
except Exception as e:
logger.warning(f"Попытка {attempt+1} подключения к Qdrant не удалась: {str(e)}")
attempt += 1
time.sleep(2)
raise RuntimeError(f"Не удалось подключиться к Qdrant после {max_attempts} попыток")
# Инициализация клиента Qdrant
try:
qdrant_client = create_qdrant_client()
except Exception as e:
logger.error(f"Ошибка инициализации Qdrant: {str(e)}")
raise
# Функция проверки и создания коллекции
def ensure_collection_exists():
max_attempts = 5
attempt = 0
while attempt < max_attempts:
try:
# Проверка существования коллекции
qdrant_client.get_collection(collection_name=COLLECTION_NAME)
logger.info(f"Коллекция '{COLLECTION_NAME}' существует")
return
except Exception as e:
if "not found" in str(e).lower():
logger.info(f"Создание коллекции '{COLLECTION_NAME}'...")
try:
qdrant_client.create_collection(
collection_name=COLLECTION_NAME,
vectors_config=VectorParams(
size=VECTOR_SIZE,
distance=Distance.COSINE
)
)
logger.info(f"Коллекция '{COLLECTION_NAME}' создана")
return
except Exception as create_error:
logger.error(f"Ошибка создания: {str(create_error)}")
else:
logger.error(f"Ошибка подключения: {str(e)}")
attempt += 1
time.sleep(2)
raise RuntimeError(f"Не удалось инициализировать коллекцию после {max_attempts} попыток")
# Вызов функции проверки коллекции
try:
ensure_collection_exists()
except Exception as e:
logger.error(f"Ошибка инициализации коллекции: {str(e)}")
raise
# Инициализация FastAPI
app = FastAPI()
app.mount("/uploads", StaticFiles(directory=UPLOAD_DIR), name="uploads")
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Модель базы данных
class Post(Base):
__tablename__ = "posts"
id = Column(String, primary_key=True, index=True)
text = Column(Text, nullable=True)
image = Column(String, nullable=True)
created_at = Column(DateTime)
Base.metadata.create_all(bind=engine)
# Pydantic model для ответа
class PostResponse(BaseModel):
id: str
text: Optional[str] = None
image: Optional[str] = None
created_at: datetime
vector: Optional[List[float]] = None
class Config:
orm_mode = True
# Pydantic модель для запроса вектора
class VectorQuery(BaseModel):
vector: List[float]
# Utility functions
def generate_text_embedding(text: str) -> List[float]:
response = requests.post(
f"{OLLAMA_URL}/api/embeddings",
json={"model": EMBEDDING_MODEL, "prompt": text}
)
if response.status_code != 200:
raise HTTPException(status_code=500, detail="Embedding generation failed")
return response.json()["embedding"]
def generate_image_embedding(image_bytes: bytes) -> List[float]:
image = Image.open(io.BytesIO(image_bytes)).convert("RGB")
inputs = clip_processor(
images=image,
return_tensors="pt",
padding=True
).to(device)
with torch.no_grad():
features = clip_model.get_image_features(**inputs)
return features.cpu().numpy().tolist()[0]
def process_image(image_bytes: bytes) -> bytes:
img = Image.open(io.BytesIO(image_bytes))
img = img.convert("RGB")
img = img.resize(IMAGE_SIZE)
buffer = io.BytesIO()
img.save(buffer, format="JPEG")
return buffer.getvalue()
# API endpoints
# API endpoints
@app.post("/posts/", response_model=PostResponse)
async def create_post(
text: Optional[str] = Form(None),
image: Optional[UploadFile] = File(None)
):
db = SessionLocal()
try:
post_id = str(uuid.uuid4())
image_path = None
thumbnail_path = None # Placeholder for thumbnail
embeddings = []
if text:
logger.info("Генерация эмбеддинга для текста")
text_embedding = generate_text_embedding(text)
embeddings.extend(text_embedding)
if image:
logger.info("Обработка изображения")
image_bytes = await image.read()
# Save original image
image_path = f"{UPLOAD_DIR}/{post_id}.jpg"
with open(image_path, "wb") as f:
f.write(image_bytes)
# Create processed image for embeddings
processed_image = process_image(image_bytes) # Assume this resizes/image processing
# Generate thumbnail as placeholder (example implementation)
#thumbnail = generate_thumbnail(image_bytes) # Implement your thumbnail generation
#thumbnail_path = f"{UPLOAD_DIR}/{post_id}_thumbnail.jpg"
#with open(thumbnail_path, "wb") as f:
# f.write(thumbnail)
# Generate embeddings from processed image
image_embedding = generate_image_embedding(processed_image)
embeddings.extend(image_embedding)
logger.info("Сохранение данных в Qdrant")
qdrant_client.upsert(
collection_name="posts",
points=[models.PointStruct(
id=post_id,
vector=embeddings,
payload={"post_id": post_id}
)]
)
logger.info("Сохранение поста в базу данных")
db_post = Post(
id=post_id,
text=text,
image=image_path,
#thumbnail=thumbnail_path, Add thumbnail field to your Post model
created_at=datetime.now()
)
db.add(db_post)
db.commit()
db.refresh(db_post)
response = PostResponse(
id=db_post.id,
text=db_post.text,
image=db_post.image,
#thumbnail=db_post.thumbnail, Update response model to include thumbnail
created_at=db_post.created_at,
vector=embeddings
)
logger.info("Пост успешно создан: %s", response)
return response
except Exception as e:
db.rollback()
logger.exception("Ошибка при создании поста")
raise HTTPException(status_code=500, detail=str(e))
finally:
db.close()
@app.get("/search/")
async def search_posts(
text: Optional[str] = None,
image: Optional[UploadFile] = File(None)
):
try:
query_embedding = []
if text:
logger.info("Генерация эмбеддинга для текста (поиск)")
text_embedding = generate_text_embedding(text)
query_embedding.extend(text_embedding)
if image:
logger.info("Генерация эмбеддинга для изображения (поиск)")
image_bytes = await image.read()
processed_image = process_image(image_bytes)
image_embedding = generate_image_embedding(processed_image)
query_embedding.extend(image_embedding)
logger.info("Выполнение поиска в Qdrant")
search_results = qdrant_client.search(
collection_name="posts",
query_vector=query_embedding,
limit=10
)
logger.info("Поиск завершён. Найдено результатов: %d", len(search_results))
return [result.payload for result in search_results]
except Exception as e:
logger.exception("Ошибка при поиске постов")
raise HTTPException(status_code=500, detail=str(e))
@app.get("/posts/", response_model=List[PostResponse])
async def get_all_posts():
db = SessionLocal()
try:
posts = db.query(Post).all()
return posts
finally:
db.close()
# Новый endpoint: получение "древа" постов по вектору пользователя
@app.post("/posts/tree", response_model=List[PostResponse])
async def get_posts_tree(query: VectorQuery):
# Выполняем поиск в Qdrant с большим лимитом, чтобы получить все посты, отсортированные по сходству
search_results = qdrant_client.search(
collection_name="posts",
query_vector=query.vector,
limit=10000 # Задайте лимит в зависимости от ожидаемого числа постов
)
print("search_results")
print(search_results)
# Извлекаем список ID постов в том порядке, в котором Qdrant вернул результаты (от ближайших к дальним)
post_ids = [result.payload.get("post_id") for result in search_results]
print("post_ids")
print(post_ids)
db = SessionLocal()
try:
# Получаем все посты из БД по списку ID
posts = db.query(Post).filter(Post.id.in_(post_ids)).all()
print("posts")
print(posts)
# Создаём словарь для сохранения соответствия post_id -> post
posts_dict = {post.id: post for post in posts}
print("posts_dict")
print(posts_dict)
# Восстанавливаем порядок, используя список post_ids
ordered_posts = [posts_dict[pid] for pid in post_ids if pid in posts_dict]
print("ordered_posts")
print(ordered_posts)
return ordered_posts
finally:
db.close()
if __name__ == "__main__":
import uvicorn
print("COLLECTION_NAME:", COLLECTION_NAME)
print("VECTOR_SIZE:", VECTOR_SIZE)
print("DATABASE_URL:", DATABASE_URL)
print("QDRANT_URL:", QDRANT_URL)
print("OLLAMA_URL:", OLLAMA_URL)
print("EMBEDDING_MODEL:", EMBEDDING_MODEL)
print("IMAGE_MODEL:", IMAGE_MODEL)
print("IMAGE_SIZE:", IMAGE_SIZE)
print("UPLOAD_DIR:", UPLOAD_DIR)
uvicorn.run(app, host="0.0.0.0", port=8000)

8
ollama/Dockerfile Normal file
View File

@@ -0,0 +1,8 @@
FROM ollama/ollama:latest
# Копируем скрипт запуска в контейнер
COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
# Переопределяем ENTRYPOINT, чтобы запускался наш скрипт
ENTRYPOINT ["/entrypoint.sh"]

34
ollama/entrypoint.sh Normal file
View File

@@ -0,0 +1,34 @@
#!/bin/sh
set -e
echo "Запускаем ollama serve в фоне..."
# Запускаем сервер в фоне и сохраняем PID процесса
ollama serve &
SERVER_PID=$!
echo "Ожидаем, пока сервер станет доступным..."
# Пытаемся получить список моделей, ожидая доступность сервера
for i in $(seq 1 30); do
if ollama list >/dev/null 2>&1; then
echo "Сервер доступен."
break
fi
echo "Сервер ещё не готов, ждём..."
sleep 1
done
echo "Проверяем наличие модели nomic-embed-text..."
# Вывод списка моделей для отладки
ollama list
# Если модели нет, выполняем загрузку
if ! ollama list | grep -q 'nomic-embed-text'; then
echo "Модель nomic-embed-text не найдена. Загружаем..."
ollama pull nomic-embed-text
else
echo "Модель nomic-embed-text уже установлена."
fi
echo "Сервер ollama запущен и работает. Ожидание завершения процесса..."
# Ожидаем завершения фонового процесса сервера
wait $SERVER_PID

3
qdrant/Dockerfile Normal file
View File

@@ -0,0 +1,3 @@
FROM qdrant/qdrant
RUN apt-get update && apt-get install -y curl && rm -rf /var/lib/apt/lists/*

11
requirements.txt Normal file
View File

@@ -0,0 +1,11 @@
# requirements.txt
fastapi
uvicorn
python-multipart
qdrant-client
sqlalchemy
requests
pillow
transformers
torch
python-dotenv