Compare commits

..

11 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
KofK
435b7f3fca fix: prisma + posts service 2024-08-20 20:11:21 +10:00
KofK
11082d93a5 feat: auth + front 2024-08-20 13:01:37 +10:00
58 changed files with 19744 additions and 9191 deletions

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

71
.gitignore vendored
View File

@@ -1,56 +1,21 @@
# compiled output /uploads
/dist /__pycache__
/node_modules /myenv
/build imageboard.db
**/node_modules
# Logs **/venv
logs **/.git
**/.github
**/logs
**/dist
**/build
*.log *.log
npm-debug.log* *.md
pnpm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
# OS
.DS_Store .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
.env.development.local **/__pycache__
.env.test.local *.sqlite3
.env.production.local *.pyc
.env.local *.pyo
*.pyd
# temp directory .Python
.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 +1,18 @@
# Use the official Node.js image # Используем официальный образ Python
FROM node:18 FROM python:3.9-slim
# Set the working directory # Устанавливаем системные зависимости
RUN apt-get update && \
apt-get install -y --no-install-recommends gcc python3-dev && \
rm -rf /var/lib/apt/lists/*
# Рабочая директория
WORKDIR /app WORKDIR /app
# Copy package.json and package-lock.json # Копируем весь код и устанавливаем зависимости
COPY package*.json ./ COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Install dependencies
RUN npm install
# Copy the rest of the application code
COPY . . COPY . .
# Build the application # Команда для запуска
RUN npm run build CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
# Expose the port the app runs on
EXPOSE 3000
# Start the application
CMD ["npm", "run", "start:dev"]

View File

@@ -1,73 +1 @@
<p align="center"> docker-compose up --build
<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 +1,58 @@
version: '3.8' version: "3.8"
services:
postgres:
image: postgres:latest
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: services:
build: . qdrant:
container_name: app 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: ports:
- "3000:3000" - "3000:3000"
environment: restart: unless-stopped
DATABASE_URL: "postgresql://user:password@postgres:5432/mydatabase"
depends_on:
- postgres
networks:
- mynetwork
networks: ollama:
mynetwork: build:
driver: bridge context: ./ollama
dockerfile: Dockerfile
container_name: ollama
ports:
- "11434:11434"
volumes:
- ollama_data:/root/.ollama
restart: unless-stopped
volumes: volumes:
postgres_data: qdrant_data:
ollama_data:

23
futa-clone/.gitignore vendored Normal file
View File

@@ -0,0 +1,23 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
# testing
/coverage
# production
/build
# misc
.DS_Store
.env.local
.env.development.local
.env.test.local
.env.production.local
npm-debug.log*
yarn-debug.log*
yarn-error.log*

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

70
futa-clone/README.md Normal file
View File

@@ -0,0 +1,70 @@
# Getting Started with Create React App
This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app).
## Available Scripts
In the project directory, you can run:
### `npm start`
Runs the app in the development mode.\
Open [http://localhost:3000](http://localhost:3000) to view it in your browser.
The page will reload when you make changes.\
You may also see any lint errors in the console.
### `npm test`
Launches the test runner in the interactive watch mode.\
See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information.
### `npm run build`
Builds the app for production to the `build` folder.\
It correctly bundles React in production mode and optimizes the build for the best performance.
The build is minified and the filenames include the hashes.\
Your app is ready to be deployed!
See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information.
### `npm run eject`
**Note: this is a one-way operation. Once you `eject`, you can't go back!**
If you aren't satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project.
Instead, it will copy all the configuration files and the transitive dependencies (webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you're on your own.
You don't have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn't feel obligated to use this feature. However we understand that this tool wouldn't be useful if you couldn't customize it when you are ready for it.
## Learn More
You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started).
To learn React, check out the [React documentation](https://reactjs.org/).
### Code Splitting
This section has moved here: [https://facebook.github.io/create-react-app/docs/code-splitting](https://facebook.github.io/create-react-app/docs/code-splitting)
### Analyzing the Bundle Size
This section has moved here: [https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size](https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size)
### Making a Progressive Web App
This section has moved here: [https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app](https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app)
### Advanced Configuration
This section has moved here: [https://facebook.github.io/create-react-app/docs/advanced-configuration](https://facebook.github.io/create-react-app/docs/advanced-configuration)
### Deployment
This section has moved here: [https://facebook.github.io/create-react-app/docs/deployment](https://facebook.github.io/create-react-app/docs/deployment)
### `npm run build` fails to minify
This section has moved here: [https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify](https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify)

18135
futa-clone/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

44
futa-clone/package.json Normal file
View File

@@ -0,0 +1,44 @@
{
"name": "futa-clone",
"version": "0.1.0",
"private": true,
"dependencies": {
"@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",
"vis-network": "^9.1.9",
"web-vitals": "^4.2.4",
"yup": "^1.6.1"
},
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test",
"eject": "react-scripts eject"
},
"eslintConfig": {
"extends": [
"react-app",
"react-app/jest"
]
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

View File

@@ -0,0 +1,43 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<meta
name="description"
content="Web site created using create-react-app"
/>
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
<!--
manifest.json provides metadata used when your web app is installed on a
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
-->
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
<!--
Notice the use of %PUBLIC_URL% in the tags above.
It will be replaced with the URL of the `public` folder during the build.
Only files inside the `public` folder can be referenced from the HTML.
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
work correctly both with client-side routing and a non-root public URL.
Learn how to configure a non-root public URL by running `npm run build`.
-->
<title>React App</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<!--
This HTML file is a template.
If you open it directly in the browser, you will see an empty page.
You can add webfonts, meta tags, or analytics to this file.
The build step will place the bundled scripts into the <body> tag.
To begin the development, run `npm start` or `yarn start`.
To create a production bundle, use `npm run build` or `yarn build`.
-->
</body>
</html>

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

View File

@@ -0,0 +1,25 @@
{
"short_name": "React App",
"name": "Create React App Sample",
"icons": [
{
"src": "favicon.ico",
"sizes": "64x64 32x32 24x24 16x16",
"type": "image/x-icon"
},
{
"src": "logo192.png",
"type": "image/png",
"sizes": "192x192"
},
{
"src": "logo512.png",
"type": "image/png",
"sizes": "512x512"
}
],
"start_url": ".",
"display": "standalone",
"theme_color": "#000000",
"background_color": "#ffffff"
}

View File

@@ -0,0 +1,3 @@
# https://www.robotstxt.org/robotstxt.html
User-agent: *
Disallow:

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,8 @@
import { render, screen } from '@testing-library/react';
import App from './App';
test('renders learn react link', () => {
render(<App />);
const linkElement = screen.getByText(/learn react/i);
expect(linkElement).toBeInTheDocument();
});

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

13
futa-clone/src/index.css Normal file
View File

@@ -0,0 +1,13 @@
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
code {
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
monospace;
}

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

1
futa-clone/src/logo.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 841.9 595.3"><g fill="#61DAFB"><path d="M666.3 296.5c0-32.5-40.7-63.3-103.1-82.4 14.4-63.6 8-114.2-20.2-130.4-6.5-3.8-14.1-5.6-22.4-5.6v22.3c4.6 0 8.3.9 11.4 2.6 13.6 7.8 19.5 37.5 14.9 75.7-1.1 9.4-2.9 19.3-5.1 29.4-19.6-4.8-41-8.5-63.5-10.9-13.5-18.5-27.5-35.3-41.6-50 32.6-30.3 63.2-46.9 84-46.9V78c-27.5 0-63.5 19.6-99.9 53.6-36.4-33.8-72.4-53.2-99.9-53.2v22.3c20.7 0 51.4 16.5 84 46.6-14 14.7-28 31.4-41.3 49.9-22.6 2.4-44 6.1-63.6 11-2.3-10-4-19.7-5.2-29-4.7-38.2 1.1-67.9 14.6-75.8 3-1.8 6.9-2.6 11.5-2.6V78.5c-8.4 0-16 1.8-22.6 5.6-28.1 16.2-34.4 66.7-19.9 130.1-62.2 19.2-102.7 49.9-102.7 82.3 0 32.5 40.7 63.3 103.1 82.4-14.4 63.6-8 114.2 20.2 130.4 6.5 3.8 14.1 5.6 22.5 5.6 27.5 0 63.5-19.6 99.9-53.6 36.4 33.8 72.4 53.2 99.9 53.2 8.4 0 16-1.8 22.6-5.6 28.1-16.2 34.4-66.7 19.9-130.1 62-19.1 102.5-49.9 102.5-82.3zm-130.2-66.7c-3.7 12.9-8.3 26.2-13.5 39.5-4.1-8-8.4-16-13.1-24-4.6-8-9.5-15.8-14.4-23.4 14.2 2.1 27.9 4.7 41 7.9zm-45.8 106.5c-7.8 13.5-15.8 26.3-24.1 38.2-14.9 1.3-30 2-45.2 2-15.1 0-30.2-.7-45-1.9-8.3-11.9-16.4-24.6-24.2-38-7.6-13.1-14.5-26.4-20.8-39.8 6.2-13.4 13.2-26.8 20.7-39.9 7.8-13.5 15.8-26.3 24.1-38.2 14.9-1.3 30-2 45.2-2 15.1 0 30.2.7 45 1.9 8.3 11.9 16.4 24.6 24.2 38 7.6 13.1 14.5 26.4 20.8 39.8-6.3 13.4-13.2 26.8-20.7 39.9zm32.3-13c5.4 13.4 10 26.8 13.8 39.8-13.1 3.2-26.9 5.9-41.2 8 4.9-7.7 9.8-15.6 14.4-23.7 4.6-8 8.9-16.1 13-24.1zM421.2 430c-9.3-9.6-18.6-20.3-27.8-32 9 .4 18.2.7 27.5.7 9.4 0 18.7-.2 27.8-.7-9 11.7-18.3 22.4-27.5 32zm-74.4-58.9c-14.2-2.1-27.9-4.7-41-7.9 3.7-12.9 8.3-26.2 13.5-39.5 4.1 8 8.4 16 13.1 24 4.7 8 9.5 15.8 14.4 23.4zM420.7 163c9.3 9.6 18.6 20.3 27.8 32-9-.4-18.2-.7-27.5-.7-9.4 0-18.7.2-27.8.7 9-11.7 18.3-22.4 27.5-32zm-74 58.9c-4.9 7.7-9.8 15.6-14.4 23.7-4.6 8-8.9 16-13 24-5.4-13.4-10-26.8-13.8-39.8 13.1-3.1 26.9-5.8 41.2-7.9zm-90.5 125.2c-35.4-15.1-58.3-34.9-58.3-50.6 0-15.7 22.9-35.6 58.3-50.6 8.6-3.7 18-7 27.7-10.1 5.7 19.6 13.2 40 22.5 60.9-9.2 20.8-16.6 41.1-22.2 60.6-9.9-3.1-19.3-6.5-28-10.2zM310 490c-13.6-7.8-19.5-37.5-14.9-75.7 1.1-9.4 2.9-19.3 5.1-29.4 19.6 4.8 41 8.5 63.5 10.9 13.5 18.5 27.5 35.3 41.6 50-32.6 30.3-63.2 46.9-84 46.9-4.5-.1-8.3-1-11.3-2.7zm237.2-76.2c4.7 38.2-1.1 67.9-14.6 75.8-3 1.8-6.9 2.6-11.5 2.6-20.7 0-51.4-16.5-84-46.6 14-14.7 28-31.4 41.3-49.9 22.6-2.4 44-6.1 63.6-11 2.3 10.1 4.1 19.8 5.2 29.1zm38.5-66.7c-8.6 3.7-18 7-27.7 10.1-5.7-19.6-13.2-40-22.5-60.9 9.2-20.8 16.6-41.1 22.2-60.6 9.9 3.1 19.3 6.5 28.1 10.2 35.4 15.1 58.3 34.9 58.3 50.6-.1 15.7-23 35.6-58.4 50.6zM320.8 78.4z"/><circle cx="420.9" cy="296.5" r="45.7"/><path d="M520.5 78.1z"/></g></svg>

After

Width:  |  Height:  |  Size: 2.6 KiB

View File

@@ -0,0 +1,13 @@
const reportWebVitals = onPerfEntry => {
if (onPerfEntry && onPerfEntry instanceof Function) {
import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => {
getCLS(onPerfEntry);
getFID(onPerfEntry);
getFCP(onPerfEntry);
getLCP(onPerfEntry);
getTTFB(onPerfEntry);
});
}
};
export default reportWebVitals;

View File

@@ -0,0 +1,5 @@
// jest-dom adds custom jest matchers for asserting on DOM nodes.
// allows you to do things like:
// expect(element).toHaveTextContent(/react/i)
// learn more: https://github.com/testing-library/jest-dom
import '@testing-library/jest-dom';

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)

View File

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

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

8676
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,73 +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": {
"@nestjs/common": "^10.0.0",
"@nestjs/core": "^10.0.0",
"@nestjs/platform-express": "^10.4.1",
"@prisma/client": "^5.18.0",
"dotenv": "^16.4.5",
"multer": "^1.4.5-lts.1",
"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.3.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,18 +0,0 @@
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
generator client {
provider = "prisma-client-js"
binaryTargets = ["native", "debian-openssl-3.0.x"]
}
model Post {
id Int @id @default(autoincrement())
text String?
media String? // URL или путь к файлу
mediaType String? // "image", "video", "drawing", "code"
code String? // Код, который нужно запустить
//TODO Добавить vector для оценки близости постов
}

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

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,9 +0,0 @@
import { Module } from '@nestjs/common';
import { PostsModule } from './posts/posts.module';
import { PrismaService } from './prisma.service';
@Module({
imports: [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,8 +0,0 @@
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
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,17 +0,0 @@
import { Controller, Post, Body, Get } from '@nestjs/common';
import { PostsService } from './posts.service';
@Controller('posts')
export class PostsController {
constructor(private readonly postsService: PostsService) {}
@Post()
async create(@Body() createPostDto: { text?: string; media?: string; mediaType?: string; code?: string }) {
return this.postsService.create(createPostDto);
}
@Get()
async findAll() {
return this.postsService.findAll();
}
}

View File

@@ -1,10 +0,0 @@
import { Module } from '@nestjs/common';
import { PostsService } from './posts.service';
import { PostsController } from './posts.controller';
import { PrismaService } from '../prisma.service';
@Module({
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,17 +0,0 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../prisma.service';
@Injectable()
export class PostsService {
constructor(private readonly prisma: PrismaService) {}
async create(data: { text?: string; media?: string; mediaType?: string; code?: string }) {
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,21 +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
}
}