initial commit
Build and Push Docker Images / build-and-push (push) Failing after 3m33s

This commit is contained in:
admin committed 2026-11-07 16:39:43 +03:00
commit 46af290db3
100 files changed
+16663

No files matched your search

+23
View File
@@ -0,0 +1,23 @@
# Правила разработки Writer IDE
## Общие принципы
- Ты — опытный Fullstack разработчик (Next.js + FastAPI).
- Всегда пиши типизированный TypeScript код. Избегай `any`.
- Для стилизации используй ТОЛЬКО Tailwind CSS. Никаких inline-стилей или CSS-модулей, если это не критично.
- Состояние приложения управляется через Zustand.
- API-запросы типизированы и вынесены в `frontend/src/lib/api.ts`.
## Frontend (Next.js App Router)
- Компоненты должны быть по возможности 'use client' только если используются хуки или интерактивность.
- Структура компонента: Импорты -> Типы -> Компонент -> Экспорт.
- Используй Lucide React для иконок.
## Backend (FastAPI)
- Используй асинхронные функции (`async def`) для всех эндпоинтов.
- Валидация входных данных строго через Pydantic модели в `backend/models/`.
- Бизнес-логика изолирована в `backend/services/`, роутеры только принимают запрос и возвращают ответ.
## Процесс работы
1. Прежде чем писать код, кратко опиши план действий (1-3 предложения).
2. Пиши код модульно. Если файл превышает 200 строк, предложи разбить его.
3. После генерации кода, укажи, как его протестировать или проверить.
+43
View File
@@ -0,0 +1,43 @@
# ============================================
# Writer IDE - Docker Configuration
# Скопируйте этот файл в .env и заполните значения
# ============================================
# --- OpenRouter API ---
OPENROUTER_API_KEY=your_api_key_here
OPENROUTER_BASE_URL=https://openrouter.ai/api/v1
# --- Ollama ---
# URL Ollama сервера. Варианты:
# - Если Ollama в другом Docker-контейнере на этом сервере:
# http://host.docker.internal:11434 (Linux/macOS/Windows)
# - Если Ollama на том же хосте, но не в Docker:
# http://host.docker.internal:11434
# - Если Ollama в той же Docker-сети:
# http://ollama:11434
OLLAMA_BASE_URL=http://host.docker.internal:11434
# --- Путь к данным на HDD ---
# Путь на хосте, куда будут сохраняться все данные проекта
# Убедитесь, что этот путь существует и имеет правильные права доступа
# Пример: /mnt/data/writer-ide или /data/writer-ide
DATA_VOLUME_PATH=/mnt/data/writer-ide
# --- Frontend ---
# URL Backend API, который будет использоваться браузером
# Если доступ к серверу по IP: http://YOUR_SERVER_IP:8000/api
# Если через домен: http://your-domain.com:8000/api
NEXT_PUBLIC_API_URL=http://localhost:8000/api
# --- Безопасность ---
# ОБЯЗАТЕЛЬНО измените в production!
# Сгенерировать можно командой: openssl rand -hex 32
JWT_SECRET_KEY=change-this-to-a-long-random-secret-string
# --- CORS ---
# Разрешённые источники для CORS (через запятую)
# Добавьте домен/IP вашего сервера
CORS_ORIGINS=http://localhost:3000,http://localhost:8000
# --- Окружение ---
ENVIRONMENT=production
+73
View File
@@ -0,0 +1,73 @@
name: Build and Push Docker Images
on:
push:
branches:
- main
- master
tags:
- 'v*'
workflow_dispatch: # Возможность ручного запуска
jobs:
build-and-push:
runs-on: ubuntu-latest # Или метка вашего раннера, например, 'self-hosted' или 'linux'
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to Gitea Container Registry
uses: docker/login-action@v3
with:
registry: ${{ gitea.server_url }}
username: ${{ gitea.actor }}
password: ${{ secrets.GITEA_TOKEN }}
- name: Extract metadata (tags, labels) for Backend
id: meta-backend
uses: docker/metadata-action@v5
with:
images: ${{ gitea.server_url }}/${{ gitea.repository }}/backend
tags: |
type=ref,event=branch
type=ref,event=tag
type=sha,prefix=sha-
- name: Build and push Backend image
uses: docker/build-push-action@v5
with:
context: ./backend
file: ./backend/Dockerfile
push: true
tags: ${{ steps.meta-backend.outputs.tags }}
labels: ${{ steps.meta-backend.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Extract metadata (tags, labels) for Frontend
id: meta-frontend
uses: docker/metadata-action@v5
with:
images: ${{ gitea.server_url }}/${{ gitea.repository }}/frontend
tags: |
type=ref,event=branch
type=ref,event=tag
type=sha,prefix=sha-
- name: Build and push Frontend image
uses: docker/build-push-action@v5
with:
context: ./frontend
file: ./frontend/Dockerfile
push: true
tags: ${{ steps.meta-frontend.outputs.tags }}
labels: ${{ steps.meta-frontend.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
+28
View File
@@ -0,0 +1,28 @@
name: Deploy to Server
on:
workflow_run:
workflows: ["Build and Push Docker Images"]
types:
- completed
workflow_dispatch:
jobs:
deploy:
if: ${{ github.event.workflow_run.conclusion == 'success' }}
runs-on: ubuntu-latest # Или ваш локальный раннер, если он имеет доступ к серверу
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Deploy via SSH
uses: appleboy/ssh-action@v1.0.3
with:
host: ${{ secrets.DEPLOY_HOST }}
username: ${{ secrets.DEPLOY_USER }}
key: ${{ secrets.DEPLOY_SSH_KEY }}
script: |
cd /path/to/writer-ide # Путь к папке с docker-compose.yml на сервере
docker-compose pull
docker-compose up -d
docker image prune -f
+28
View File
@@ -0,0 +1,28 @@
# Переменные окружения с секретами
.env
# Данные (монтируются с HDD)
data/
/mnt/data/
# Docker volumes (локальные)
volumes/
# IDE
.vscode/
.idea/
*.swp
# Node
node_modules/
.next/
# Python
__pycache__/
*.pyc
venv/
.venv/
# OS
.DS_Store
Thumbs.db
+64
View File
@@ -0,0 +1,64 @@
# Правила разработки Writer IDE
## Общие принципы
- Ты — опытный Fullstack разработчик (Next.js + FastAPI).
- Всегда пиши типизированный TypeScript код. Избегай `any`.
- Для стилизации используй ТОЛЬКО Tailwind CSS. Никаких inline-стилей или CSS-модулей, если это не критично.
- Состояние приложения управляется через Zustand.
- API-запросы типизированы и вынесены в `frontend/src/lib/api.ts`.
## Frontend (Next.js App Router)
- Фреймворк: Next.js 14+ (App Router, TypeScript).
- Компоненты должны быть `'use client'` только если используются хуки или интерактивность.
- Структура компонента: Импорты -> Типы -> Компонент -> Экспорт.
- Используй Lucide React для иконок.
- Tailwind CSS v4: конфигурация через `@theme` в `globals.css` и `@plugin` для плагинов.
- Для форматирования текста в редакторе используй класс `prose` (плагин `@tailwindcss/typography`).
## Tiptap Редактор
- Редактор: `@tiptap/react` с `StarterKit` и дополнительными расширениями.
- Базовые расширения: `TextStyle`, `Color`, `Underline`, `Blockquote`.
- Редактор должен быть обёрнут в `div` с классами `max-w-3xl mx-auto prose`.
- `onUpdate` вызывает экшен Zustand store для сохранения контента.
## Backend (FastAPI)
- Фреймворк: FastAPI (Python 3.10+).
- Используй асинхронные функции (`async def`) для всех эндпоинтов.
- Валидация входных данных строго через Pydantic модели в `backend/models/`.
- Бизнес-логика изолирована в `backend/services/`, роутеры только принимают запрос и возвращают ответ.
- CORS: разрешён `http://localhost:3000`.
## Управление состоянием (Zustand)
- Store располагается в `frontend/src/stores/`.
- Определяй интерфейсы для всех сущностей (Document, Project и т.д.).
- Инициализируй store с тестовыми данными для разработки.
## AI Интеграция (внутренняя логика приложения)
- Роутинг: Абстрактный сервис, поддерживающий OpenRouter API и локальный Ollama (`http://localhost:11434`).
- Пресеты: Жесткое разделение параметров генерации (`temperature`, `top_p`) по действию (Creative Draft, Editor, Brainstorm).
## Процесс работы
1. Прежде чем писать код, кратко опиши план действий (1-3 предложения).
2. Пиши код модульно. Если файл превышает 200 строк, предложи разбить его.
3. После генерации кода укажи, как его протестировать или проверить.
## Структура проекта
```
writer-ide/
├── frontend/ # Next.js приложение
│ ├── src/
│ │ ├── app/ # Next.js App Router (pages, layout)
│ │ ├── components/ # UI компоненты
│ │ │ ├── editor/ # Tiptap редактор, Toolbar, AI-меню
│ │ │ ├── sidebar/ # FileExplorer, StoryExplorer
│ │ │ └── ui/ # Переиспользуемые кнопки, инпуты
│ │ ├── lib/ # Утилиты, конфигурация axios, типы
│ │ └── stores/ # Zustand stores (appStore, editorStore)
│ └── package.json
│
└── backend/ # FastAPI сервер
├── main.py # Точка входа, настройка CORS
├── routers/ # API эндпоинты (projects, documents, ai)
├── services/ # Бизнес-логика (file_ops, llm_router)
└── models/ # Pydantic схемы запросов/ответов
```
+20
View File
@@ -0,0 +1,20 @@
# Стратегия использования AI для разработки Writer IDE
## 🧠 Архитектор и сложная логика
- **Модель**: `qwen/qwen-2.5-coder-32b-instruct` (через OpenRouter)
- **Задачи**: Проектирование API, сложные хуки Zustand, настройка Tiptap, архитектура БД.
- **Параметры**: Temperature 0.2, Top_P 0.9.
## 💻 Ежедневная разработка (Coding Agent)
- **Модель**: `qwen/qwen-2.5-coder-7b-instruct` (OpenRouter) или локальная `qwen2.5-coder:7b` (Ollama) для скорости.
- **Задачи**: Написание UI-компонентов, рутинный рефакторинг, написание типов TypeScript.
- **Параметры**: Temperature 0.1, Top_P 0.95.
## 🔍 Код-ревью и отладка
- **Модель**: `qwen/qwen-2.5-72b-instruct` (OpenRouter)
- **Задачи**: Анализ трейсбеков, поиск логических ошибок, оптимизация производительности.
- **Параметры**: Temperature 0.0.
## 🎨 Тестирование функционала (Внутренний AI приложения)
- **Модель**: Локальная `vikhr-creative-1.5b` или `qwen2.5:7b` через Ollama.
- **Задачи**: Проверка того, что наше приложение корректно отправляет промпты и получает креативные ответы.
+49
View File
@@ -0,0 +1,49 @@
# Writer IDE: Архитектурная спецификация
## 🎯 Видение проекта
Создание локально-ориентированного веб-приложения (IDE для писателей), которое объединяет мощь rich-text редактирования, структурированное управление сюжетом (Story Bible) и бесшовную интеграцию AI-ассистента для генерации, редактуры и анализа текста. Приложение должно давать пользователю полный контроль над данными (локальное хранение) и гибкость в выборе AI-моделей (OpenRouter для качества, Ollama для приватности).
## 🛠️ Технологический стек
### Frontend
- **Фреймворк**: Next.js 14+ (App Router, TypeScript)
- **Стилизация**: Tailwind CSS (строгое использование utility-классов, никаких inline-styles)
- **Редактор**: Tiptap (Headless wrapper вокруг ProseMirror)
- **Управление состоянием**: Zustand (легковесный, без бойлерплейта Redux)
- **Иконки**: Lucide React
- **HTTP-клиент**: Axios (с типизацией ответов)
### Backend
- **Фреймворк**: FastAPI (Python 3.10+)
- **Валидация**: Pydantic V2
- **Асинхронность**: Строгое использование `async`/`await`
- **Хранение**: Локальная файловая система (JSON/Markdown файлы в директории проекта пользователя)
### AI Интеграция (Внутренняя логика приложения)
- **Роутинг**: Абстрактный сервис, поддерживающий как OpenRouter API, так и локальный Ollama (`http://localhost:11434`).
- **Пресеты**: Жесткое разделение параметров генерации (`temperature`, `top_p`) в зависимости от действия (Creative Draft, Editor, Brainstorm).
## 📁 Структура проекта
```text
writer-ide/
├── PROJECT.md # Этот файл
├── TASKS.md # Пошаговый план разработки
├── AGENTS.md # Стратегия выбора AI-моделей
├── .cursorrules # Правила для AI-ассистента в IDE
│
├── frontend/ # Next.js приложение
│ ├── src/
│ │ ├── app/ # Next.js App Router (pages, layout)
│ │ ├── components/ # UI компоненты
│ │ │ ├── editor/ # Tiptap редактор, Toolbar, AI-меню
│ │ │ ├── sidebar/ # FileExplorer, StoryExplorer
│ │ │ └── ui/ # Переиспользуемые кнопки, инпуты
│ │ ├── lib/ # Утилиты, конфигурация axios, типы
│ │ └── stores/ # Zustand stores (appStore, editorStore)
│ └── package.json
│
└── backend/ # FastAPI сервер
├── main.py # Точка входа, настройка CORS
├── routers/ # API эндпоинты (projects, documents, ai)
├── services/ # Бизнес-логика (file_ops, llm_router)
└── models/ # Pydantic схемы запросов/ответов
+88
View File
@@ -0,0 +1,88 @@
# Writer IDE
Fullstack IDE для написания и редактирования текстов с интеграцией AI.
## 📁 Структура проекта
```
writer-ide/
├── frontend/ # Next.js приложение
├── backend/ # FastAPI сервер
├── .gitea/workflows/ # CI/CD pipelines
└── docker-compose.yml # Оркестрация контейнеров
```
## 🚀 CI/CD и Деплой
Проект настроен на автоматическую сборку и публикацию Docker-образов через **Gitea Actions**.
### Как это работает:
1. При пуше в ветку `main`/`master` или создании тега (например, `v1.0.0`) запускается workflow `.gitea/workflows/ci.yml`.
2. Собираются образы `backend` и `frontend` с использованием Docker Buildx и кэширования.
3. Образы публикуются во встроенный **Gitea Container Registry**.
4. (Опционально) Workflow `deploy.yml` автоматически подключается к серверу по SSH, стягивает новые образы и перезапускает сервисы через `docker-compose`.
### Настройка секретов в репозитории Gitea:
Перейдите в **Settings → Secrets and variables → Actions** и добавьте:
- `GITEA_TOKEN` — токен с доступом к Container Registry (используется в ci.yml)
- `DEPLOY_HOST` — IP или домен вашего сервера
- `DEPLOY_USER` — Имя пользователя для SSH
- `DEPLOY_SSH_KEY` — Приватный SSH-ключ (без парольной фразы) для подключения к серверу
### Включение Actions в репозитории:
Убедитесь, что в настройках репозитория (**Settings → Repository**) включена опция **Enable Actions**.
## 🛠 Разработка
### Установка зависимостей
#### Frontend (Next.js)
```bash
cd frontend
npm install
```
#### Backend (FastAPI)
```bash
cd backend
pip install -r requirements.txt
```
### Запуск в режиме разработки
#### Frontend
```bash
cd frontend
npm run dev
```
#### Backend
```bash
cd backend
uvicorn main:app --reload
```
## 🐳 Docker
### Сборка образов
```bash
docker-compose build
```
### Запуск
```bash
docker-compose up
```
### Остановка
```bash
docker-compose down
```
## 📄 Лицензия
MIT
+144
View File
@@ -0,0 +1,144 @@
📊 Writer IDE — Summary проекта
🎯 Видение проекта
Локально-ориентированное веб-приложение (IDE для писателей), объединяющее rich-text редактирование, структурированное управление сюжетом и бесшовную интеграцию AI-ассистента для генерации, редактуры и анализа текста.
🛠️ Технологический стек
Frontend: Next.js 14+ (App Router), TypeScript, Tailwind CSS, Tiptap (редактор), Zustand (state management)
Backend: FastAPI (Python), Pydantic, Uvicorn
AI Интеграция: Ollama (локально), OpenRouter API (облачные модели)
Разработка: Zed IDE + AI-агенты (Qwen 2.5 Coder 7b/32b через OpenRouter)
✅ Что уже сделано (Этап 1: Фундамент)
Задача
Статус
Что реализовано
1.1 Инициализация Frontend
✅ Выполнено
Next.js + Tailwind + Tiptap + Zustand установлены
1.2 Zustand Store
✅ Выполнено
appStore.ts с управлением документами (id, title, content, type)
1.3 Layout (3 колонки)
✅ Выполнено
FileExplorer (слева), StoryExplorer (посередине), Editor (справа)
1.4 Tiptap Editor
✅ Выполнено
Rich-text редактор с расширениями: TextStyle, Color, Underline, Blockquote, интеграция с Zustand
1.5 Toolbar
✅ Выполнено
Панель форматирования: Bold, Italic, Underline, Quote, Color picker, Clear formatting, Font family, Font size
1.6 Backend инициализация
✅ Выполнено
FastAPI с CORS, health check endpoint, структура папок (routers, services, models)
1.7 Frontend-Backend интеграция
✅ Выполнено
API endpoints для документов, автосохранение с debounce, персистентность данных
Итог Этапа 1: Рабочее приложение с rich-text редактором, трехколоночным интерфейсом и синхронизацией с backend.
🟡 Что планируется (Этап 2: AI Интеграция)
Задача
Статус
Что будет реализовано
2.1 Backend AI сервис
⏳ Готово к началу
LLMRouter для Ollama и OpenRouter, эндпоинт /api/ai/generate
2.2 Система пресетов
⏳ Ожидает
Пресеты: Creative Draft (temp 0.85), Editor (temp 0.3), Brainstorm (temp 1.0)
2.3 Frontend AI клиент
⏳ Ожидает
Типизированный API клиент ai-api.ts для вызова генерации
2.4 Inline AI меню
⏳ Ожидает
Всплывающее меню при выделении текста с действиями: Переписать, Расширить, Упростить
2.5 Diff view
⏳ Ожидает
Визуальное сравнение оригинального текста и AI-предложения с кнопками Accept/Reject
2.6 Интеграция AI действий
⏳ Ожидает
Связывание AIMenu + AIDiffView + API для полноценного AI-редактирования
Цель Этапа 2: Пользователь может выделить текст, выбрать AI действие, увидеть предпросмотр изменений в diff view и принять/отклонить.
🟠 Что планируется (Этап 3: Контекст и Story Bible)
Автоматическое извлечение сущностей (персонажи, локации) из текста
Панель Story Bible для управления контекстом
Умная подача контекста в промпты для консистентности повествования
Фоновый агент для обновления Story Bible при сохранении глав
🔴 Что планируется (Этап 4: Экспорт)
Экспорт в PDF с форматированием (шрифты, отступы, оглавление)
Экспорт в EPUB/FB2 для читалок
Генерация иллюстраций (опционально)
🎯 Ключевые архитектурные решения
Local First: Данные пользователя хранятся локально (backend как прокси к файловой системе)
AI Router: Абстракция над Ollama и OpenRouter для гибкого переключения между локальными и облачными моделями
Presets System: Жесткое разделение параметров генерации в зависимости от задачи (творчество vs анализ)
Inline AI: AI-действия не прерывают поток писателя (встроенные меню, diff view вместо чата)
Type Safety: Строгая типизация на frontend (TypeScript) и backend (Pydantic)
📁 Структура проекта
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
🚀 Следующий шаг (завтра)
Задача 2.1: Backend AI сервис
Модель: qwen/qwen-2.5-coder-32b-instruct (через OpenRouter)
Что сделать: Создать backend/services/llm_router.py с поддержкой Ollama и OpenRouter
Критерий успеха: Эндпоинт POST /api/ai/generate принимает промпт и возвращает сгенерированный текст
💡 Важные заметки
Python 3.14 на сервере — используем пакеты без жестких версий в requirements.txt
Zed IDE вместо VS Code для экономии памяти (16 GB RAM)
Для разработки используем OpenRouter (Qwen 2.5 Coder), для тестирования функционала — локальную Ollama
+258
View File
@@ -0,0 +1,258 @@
---
### 📄 Файл: `TASKS.md` (Этап 1: Полный разбор)
```markdown
# Задачи проекта Writer IDE
## 🟢 Этап 1: Фундамент, Layout и Базовый Редактор
### Задача 1.1: Инициализация Frontend и установка зависимостей
**Описание**: Создание базового Next.js приложения и установка всех необходимых библиотек для Этапа 1.
**Критерии успеха**:
- Команда `npm run dev` в папке `frontend` запускает приложение на `localhost:3000`.
- Tailwind CSS работает (проверка через класс `bg-red-500`).
**Инструкция для AI-агента**:
> "Инициализируй Next.js проект в текущей папке с TypeScript, Tailwind, App Router и src-директорией. Затем установи следующие зависимости: `@tiptap/react`, `@tiptap/starter-kit`, `@tiptap/extension-text-style`, `@tiptap/extension-color`, `@tiptap/extension-blockquote`, `@tiptap/extension-underline`, `zustand`, `lucide-react`, `axios`. Убедись, что `tailwind.config.ts` настроен корректно."
---
### Задача 1.2: Настройка Zustand Store (Состояние приложения)
**Описание**: Создание глобального хранилища для управления активным документом, его содержимым и состоянием боковых панелей.
**Критерии успеха**:
- Создан файл `frontend/src/stores/appStore.ts`.
- Store содержит: `activeDocumentId`, `documents` (массив объектов с id, title, content), функции `setActiveDocument`, `updateDocumentContent`.
**Инструкция для AI-агента**:
> "Создай Zustand store в `src/stores/appStore.ts`. Определи интерфейс `Document` (id: string, title: string, content: string, type: 'scene' | 'chapter'). Добавь состояния для списка документов и активного документа, а также экшены для их обновления. Инициализируй store одним тестовым документом."
---
### Задача 1.3: Архитектура Layout (Трехколоночный интерфейс)
**Описание**: Создание основного макета приложения `src/app/layout.tsx` и `src/app/page.tsx` с тремя колонками.
**Критерии успеха**:
- Левая колонка (w-64): Заглушка компонента `FileExplorer`.
- Средняя колонка (w-72): Заглушка компонента `StoryExplorer`.
- Правая колонка (flex-1): Область для редактора.
- Используется CSS Grid или Flexbox. Адаптивность: на экранах < 1024px боковые панели скрываются (или сжимаются).
**Инструкция для AI-агента**:
> "Создай компоненты-заглушки `FileExplorer.tsx` и `StoryExplorer.tsx` в `src/components/sidebar/`. Реализуй основной Layout в `src/app/page.tsx`, используя Flexbox или Grid для создания трех колонок с фиксированной шириной для сайдбаров и `flex-1` для центральной области. Примени темную/светлую тему через Tailwind."
---
### Задача 1.4: Ядро Tiptap Редактора
**Описание**: Создание переиспользуемого компонента редактора с поддержкой базового форматирования.
**Критерии успеха**:
- Компонент `src/components/editor/Editor.tsx` инициализирует `useEditor` с расширениями: `StarterKit`, `TextStyle`, `Color`, `Underline`, `Blockquote`.
- Редактор привязан к Zustand store (загружает `content` активного документа и сохраняет изменения через `onUpdate`).
**Инструкция для AI-агента**:
> "Создай компонент `Editor.tsx`. Используй `@tiptap/react`. Подключи расширения: StarterKit, TextStyle, Color, Underline, Blockquote. Настрой `onUpdate`, чтобы при изменении текста вызывался экшен `updateDocumentContent` из `appStore`. Оберни редактор в div с классами для красивого отображения (max-w-3xl, mx-auto, prose)."
---
### Задача 1.5: Панель инструментов (Toolbar) для форматирования
**Описание**: Создание плавающей или верхней панели инструментов для управления стилями текста.
**Критерии успеха**:
- Компонент `src/components/editor/Toolbar.tsx`.
- Кнопки для: Bold, Italic, Underline, Blockquote, выбор цвета текста (используй стандартный `<input type="color">` для простоты).
- Кнопки используют `editor.chain().focus().toggleBold().run()` и аналоги.
**Инструкция для AI-агента**:
> "Создай компонент `Toolbar.tsx`, который принимает экземпляр `editor` из Tiptap как пропс. Реализуй кнопки форматирования с использованием Lucide React иконок. Добавь простую логику подсветки активной кнопки (через `editor.isActive('bold')`). Размести этот Toolbar над компонентом Editor."
---
### Задача 1.6: Инициализация Backend (FastAPI)
**Описание**: Базовая настройка Python-сервера для обслуживания frontend и подготовки к работе с файлами.
**Критерии успеха**:
- Файл `backend/main.py` запускается через `uvicorn main:app --reload`.
- Настроен CORS middleware, разрешающий запросы с `http://localhost:3000`.
- Эндпоинт `GET /api/health` возвращает `{"status": "ok"}`.
**Инструкция для AI-агента**:
> "Создай базовое приложение FastAPI в `backend/main.py`. Добавь `CORSMiddleware` с разрешенными источниками `http://localhost:3000`. Создай простой роутер `/api/health`. Напиши инструкцию по запуску (создание venv, `pip install fastapi uvicorn pydantic`, запуск)."
---
### Задача 1.7: Интеграция Frontend и Backend (Сохранение/Загрузка)
**Описание**: Связывание Zustand store с Backend для имитации сохранения данных (пока в памяти или простом JSON).
**Критерии успеха**:
- При изменении текста в редакторе, через debounced-запрос (или по кнопке "Сохранить") данные отправляются на backend.
- При загрузке страницы frontend запрашивает список документов и контент активного документа с backend.
**Инструкция для AI-агента**:
> "Создай в `backend` простой in-memory список документов (или запись в `data.json`). Добавь эндпоинты `GET /api/documents` и `POST /api/documents/{id}/content`. В `frontend/src/lib/api.ts` создай типизированные функции для этих запросов. Обнови `appStore`, чтобы он загружал данные при инициализации."
---
## 🟡 Этап 2: AI Интеграция и Inline-действия
### Задача 2.1: Backend AI сервис (базовый роутер для LLM)
**Описание**: Создание сервиса на backend для работы с AI-моделями (Ollama локально и OpenRouter API).
**Критерии успеха**:
- Создан файл `backend/services/llm_router.py`
- Сервис поддерживает два провайдера: `ollama` и `openrouter`
- Эндпоинт `POST /api/ai/generate` принимает промпт и возвращает сгенерированный текст
- Поддержка выбора модели через параметр запроса
**Инструкция для AI-агента**:
> "Создай сервис `backend/services/llm_router.py` с классом `LLMRouter`. Реализуй методы:
> - `generate_ollama(prompt, model)` — вызов локальной Ollama через `http://localhost:11434/api/generate`
> - `generate_openrouter(prompt, model, api_key)` — вызов OpenRouter API
> - `generate(prompt, provider, model, **kwargs)` — универсальный метод, который маршрутизирует запрос
> Создай эндпоинт `POST /api/ai/generate` в `backend/routers/ai.py`, который принимает JSON: `{prompt, provider, model, options}` и возвращает `{generated_text}`. Используй `httpx` для async HTTP запросов. Добавь `httpx` в requirements.txt."
---
### Задача 2.2: Система пресетов для генерации
**Описание**: Реализация пресетов параметров генерации (Creative Draft, Editor, Brainstorm) на backend.
**Критерии успеха**:
- Создан файл `backend/services/presets.py`
- Определены три пресета с разными параметрами (temperature, top_p, frequency_penalty, presence_penalty)
- Эндпоинт `/api/ai/generate` принимает параметр `preset` и применяет соответствующие настройки
**Инструкция для AI-агента**:
> "Создай `backend/services/presets.py` с тремя пресетами:
> - `CREATIVE_DRAFT`: temperature=0.85, top_p=0.92, frequency_penalty=0.3, presence_penalty=0.4
> - `EDITOR`: temperature=0.3, top_p=0.85, frequency_penalty=0.0, presence_penalty=0.0
> - `BRAINSTORM`: temperature=1.0, top_p=0.95, frequency_penalty=0.5, presence_penalty=0.6
> Обнови `llm_router.py`, чтобы метод `generate` принимал параметр `preset` и применял соответствующие параметры к запросу. Обнови эндпоинт `/api/ai/generate` для поддержки пресетов."
---
### Задача 2.3: Frontend API клиент для AI
**Описание**: Создание типизированного API клиента на frontend для работы с AI эндпоинтами.
**Критерии успеха**:
- Создан файл `frontend/src/lib/ai-api.ts`
- Функция `generateText(prompt, provider, model, preset)` возвращает сгенерированный текст
- Все типы типизированы (никаких `any`)
**Инструкция для AI-агента**:
> "Создай `frontend/src/lib/ai-api.ts` с типами:
> ```typescript
> interface GenerateRequest {
> prompt: string;
> provider: 'ollama' | 'openrouter';
> model: string;
> preset: 'creative_draft' | 'editor' | 'brainstorm';
> options?: Record<string, any>;
> }
> interface GenerateResponse {
> generated_text: string;
> }
> ```
> Реализуй функцию `generateText(request: GenerateRequest): Promise<string>`, которая вызывает `POST /api/ai/generate` и возвращает `generated_text`. Используй axios из `lib/api.ts`."
---
### Задача 2.4: Inline AI меню в редакторе (UI)
**Описание**: Создание всплывающего меню при выделении текста в редакторе для выбора AI действий.
**Критерии успеха**:
- Создан компонент `frontend/src/components/editor/AIMenu.tsx`
- При выделении текста появляется кнопка "✨ AI"
- При клике на кнопку открывается меню с действиями: "Переписать", "Расширить", "Упростить", "Свой промпт"
**Инструкция для AI-агента**:
> "Создай компонент `AIMenu.tsx`, который использует Tiptap BubbleMenu или FloatingMenu. При выделении текста показывай кнопку с иконкой `Sparkles` из lucide-react. При клике на кнопку открывай dropdown с пунктами: 'Переписать', 'Расширить', 'Упростить', 'Свой промпт...'. Каждый пункт при клике вызывает callback `onAction(action: string, selectedText: string)`. Стилизуй меню в темной теме (bg-gray-800, border-gray-700)."
---
### Задача 2.5: Diff view для предпросмотра AI изменений
**Описание**: Создание компонента для визуального сравнения оригинального текста и AI-предложения.
**Критерии успеха**:
- Создан компонент `frontend/src/components/editor/AIDiffView.tsx`
- Компонент показывает оригинальный текст слева и AI-текст справа
- Есть кнопки "Принять" и "Отклонить"
- Использует библиотеку для сравнения текста (например, `diff` или простую визуализацию)
**Инструкция для AI-агента**:
> "Создай компонент `AIDiffView.tsx`, который принимает пропсы: `originalText: string`, `aiText: string`, `onAccept: () => void`, `onReject: () => void`. Отображай два блока текста (можно просто два div рядом или друг под другом). Добавь кнопки '✅ Принять' и '❌ Отклонить' вверху. Стилизуй: оригинальный текст с красноватым фоном (bg-red-900/20), AI текст с зеленоватым (bg-green-900/20). Кнопки: Accept — bg-green-600, Reject — bg-red-600."
---
### Задача 2.6: Интеграция AI действий с редактором
**Описание**: Связывание AIMenu, AIDiffView и API клиента для полноценной работы AI-редактирования.
**Критерии успеха**:
- При выборе действия в AIMenu вызывается API с соответствующим промптом
- Пока идет генерация, показывается спиннер
- После получения ответа открывается AIDiffView
- При нажатии "Принять" выделенный текст заменяется на AI-текст
- При нажатии "Отклонить" возвращается оригинальный текст
**Инструкция для AI-агента**:
> "Интегрируй AIMenu и AIDiffView в Editor.tsx:
> 1. Добавь состояние: `selectedText`, `aiGeneratedText`, `showDiffView`, `isLoading`
> 2. При выборе действия в AIMenu:
> - Сохрани `selectedText` из `editor.state.doc.textBetween(selection.from, selection.to)`
> - Сформируй промпт в зависимости от действия (например, для 'Переписать': 'Перепиши этот текст, сделай его более живым и кинематографичным: [selectedText]')
> - Вызови `generateText` из `ai-api.ts` с пресетом `creative_draft`
> - Установи `aiGeneratedText` и `showDiffView = true`
> 3. В AIDiffView:
> - `onAccept`: замени выделенный текст через `editor.chain().focus().deleteRange(selection).insertContent(aiGeneratedText).run()`
> - `onReject`: закрой diff view, ничего не меняй
> 4. Покажи спиннер (из lucide-react: `Loader2` с анимацией `animate-spin`), пока `isLoading = true`"
---
## 🟠 Этап 3: Контекст и Story Bible
### Задача 3.1: Backend модель и API для Story Bible
**Описание**: Создание структуры данных для Story Bible (персонажи, локации, события) и API для работы с ней.
**Критерии успеха**:
- Созданы Pydantic модели для сущностей (Character, Location, Event, StoryThread)
- Создан эндпоинт `GET /api/projects/{id}/bible` для получения Story Bible
- Создан эндпоинт `PUT /api/projects/{id}/bible` для обновления Story Bible
- Данные хранятся в JSON файле на диске (локально)
**Рекомендуемая модель**: `qwen/qwen-2.5-coder-7b-instruct` (простая CRUD задача)
**Инструкция для AI-агента**:
> "Создай Pydantic модели в `backend/models/bible.py` для сущностей: Character (id, name, description, traits, appearance), Location (id, name, description, atmosphere), Event (id, title, description, chapter), StoryThread (id, title, description, status). Создай модель StoryBible, которая содержит списки этих сущностей. Создай роутер `backend/routers/bible.py` с эндпоинтами для получения и обновления Story Bible. Данные храни в файле `data/story_bible.json`. Добавь функцию инициализации с примером данных."
---
### Задача 3.2: Frontend UI для Story Bible (панель)
**Описание**: Создание панели для просмотра и редактирования Story Bible.
**Критерии успеха**:
- Создан компонент `StoryBiblePanel.tsx` с вкладками: Персонажи, Локации, События, Сюжетные нити
- Каждая вкладка показывает список сущностей с возможностью редактирования
- Есть кнопки "Добавить" и "Удалить" для каждой сущности
- Панель интегрирована в layout (заменяет или дополняет StoryExplorer)
**Рекомендуемая модель**: `qwen/qwen-2.5-coder-7b-instruct` (UI компонент)
**Инструкция для AI-агента**:
> "Создай компонент `src/components/sidebar/StoryBiblePanel.tsx` с вкладками для персонажей, локаций, событий и сюжетных нитей. Каждая вкладка показывает список карточек сущностей. Карточка содержит название, краткое описание и кнопки редактирования/удаления. Добавь модальное окно для создания/редактирования сущности с полями, соответствующими модели. Интегрируй панель в layout, заменив заглушку StoryExplorer. Используй Zustand store для управления состоянием."
---
### Задача 3.3: Настройки выбора моделей для разных задач
**Описание**: Создание UI и backend логики для выбора моделей под разные задачи (генерация, фоновая обработка, анализ).
**Критерии успеха**:
- Создан компонент `ModelSettings.tsx` с выбором моделей для:
- Генерации текста (основная модель)
- Фоновой обработки (извлечение сущностей)
- Анализа текста (проверка логики)
- Настройки сохраняются в localStorage или backend
- При генерации используются выбранные модели
**Рекомендуемая модель**: `qwen/qwen-2.5-coder-7b-instruct` (UI + интеграция)
**Инструкция для AI-агента**:
> "Создай компонент `src/components/settings/ModelSettings.tsx` с dropdown для выбора моделей для разных задач: 'Основная модель для генерации', 'Модель для фоновой обработки', 'Модель для анализа'. Загружай список доступных моделей через `getAvailableModels()` из `ai-api.ts`. Сохраняй настройки в Zustand store. Обнови `ai-api.ts`, чтобы функция `generateText` принимала параметр `task_type` и использовала соответствующую модель из настроек."
---
### Задача 3.4: Фоновый агент для извлечения сущностей
**Описание**: Создание сервиса для автоматического извлечения персонажей, локаций и событий из текста главы.
**Критерии успеха**:
- Создан сервис `backend/services/entity_extractor.py`
- Сервис принимает текст главы и возвращает JSON с извлеченными сущностями
- Использует промпт для структурированного вывода (JSON mode)
- Интегрирован с Story Bible (обновляет или добавляет новые сущности)
**Рекомендуемая модель**: `qwen/qwen-2.5-coder-32b-instruct` (сложный промпт-инжиниринг)
**Инструкция для AI-агента**:
> "Создай сервис `backend/services/entity_extractor.py` с функцией `extract_entities(text, existing_bible)`. Функция формирует промпт для LLM, который извлекает из текста новых персонажей, локации и события в формате JSON. Промпт должен включать примеры формата вывода и инструкцию не дублировать существующие сущности из `existing_bible`. Используй модель для фоновой обработки из настроек. Пресет: `editor` (низкая температура для точности)."
---
### Задача 3.5: Автоматическое обновление Story Bible при сохранении
**Описание**: Интеграция извлечения сущностей с процессом сохранения главы.
**Критерии успеха**:
- При сохранении главы (автосохранение) вызывается фоновый агент
- Агент анализирует текст и обновляет Story Bible
- Пользователь видит уведомление "Story Bible обновлена"
- Есть возможность отключить автоматическое обновление в настройках
**Рекомендуемая модель**: `qwen/qwen-2.5-coder-32b-instruct` (интеграция)
**Инструкция для AI-агента**:
> "Обнови `Editor.tsx`, чтобы при автосохранении (после debounce) отправлял запрос на backend для извлечения сущностей. Создай эндпоинт `POST /api/ai/extract-entities` в `backend/routers/ai.py`, который принимает текст главы и текущую Story Bible, вызывает `entity_extractor`, и возвращает обновленную Bible. На frontend добавь переключатель 'Автоматически обновлять Story Bible' в настройках. Показывай уведомление (toast) при успешном обновлении."
---
## 🔴 Этап 4: Экспорт и полировка
*(Будет активирован после успешного завершения Этапа 3)*
+40
View File
@@ -0,0 +1,40 @@
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
venv/
env/
ENV/
.venv
# Данные (монтируются как volume, не копируются в образ)
data/
# Переменные окружения (секреты не должны попасть в образ)
.env
# IDE
.vscode/
.idea/
*.swp
*.swo
# Git
.git/
.gitignore
# Docker
Dockerfile
docker-compose*.yml
.dockerignore
# Документация
*.md
!requirements.txt
# Тесты
tests/
.pytest_cache/
.coverage
+18
View File
@@ -0,0 +1,18 @@
# OpenRouter API
OPENROUTER_API_KEY=your_api_key_here
OPENROUTER_BASE_URL=https://openrouter.ai/api/v1
# Ollama — имя контейнера в docker-compose сети
OLLAMA_BASE_URL=http://ollama:11434
# Путь к данным — монтируется volume
DATA_DIR=/app/data
# Окружение
ENVIRONMENT=production
# CORS — домен вашего сервера
CORS_ORIGINS=http://localhost:3000,http://your-server-ip:3000
# JWT секрет — сгенерируйте случайную строку!
JWT_SECRET_KEY=generate-a-long-random-string-here
+18
View File
@@ -0,0 +1,18 @@
# OpenRouter API
OPENROUTER_API_KEY=your_api_key_here
OPENROUTER_BASE_URL=https://openrouter.ai/api/v1
# Ollama — в Docker это будет http://ollama:11434 или IP хоста
OLLAMA_BASE_URL=http://localhost:11434
# Путь к данным — в Docker будет монтироваться volume
DATA_DIR=/app/data
# Окружение
ENVIRONMENT=development
# CORS — через запятую, если несколько источников
CORS_ORIGINS=http://localhost:3000,http://your-domain.com
# JWT секрет — ОБЯЗАТЕЛЬНО измените в production!
JWT_SECRET_KEY=change-this-to-a-random-secret-string
+8
View File
@@ -0,0 +1,8 @@
# OpenRouter API Key (получить на https://openrouter.ai)
OPENROUTER_API_KEY="sk-yapWeqHgWRbBQZcWOKSlq4bmd_uYC2oB"
# Ollama URL (по умолчанию localhost)
OLLAMA_BASE_URL=http://localhost:11434
# Режим разработки
ENVIRONMENT=development
+11
View File
@@ -0,0 +1,11 @@
venv/
__pycache__/
*.pyc
*.pyo
*.pyd
.Python
*.so
.coverage
.pytest_cache/
data/
.env
+49
View File
@@ -0,0 +1,49 @@
# Используем slim-образ Python для меньшего размера
FROM python:3.11-slim
# Устанавливаем системные зависимости для WeasyPrint и Pandoc
# WeasyPrint требует: cairo, pango, gdk-pixbuf2, libffi
# Pandoc — просто устанавливается через apt
RUN apt-get update && apt-get install -y --no-install-recommends \
# Для WeasyPrint
libpango-1.0-0 \
libpangoft2-1.0-0 \
libcairo2 \
libgdk-pixbuf2.0-0 \
libffi-dev \
shared-mime-info \
# Для Pandoc
pandoc \
# Утилиты
curl \
&& rm -rf /var/lib/apt/lists/*
# Устанавливаем рабочую директорию
WORKDIR /app
# Копируем requirements и устанавливаем Python-зависимости
# (отдельным слоем для кэширования)
COPY requirements.txt .
RUN pip install --no-cache-dir --upgrade pip && \
pip install --no-cache-dir -r requirements.txt
# Копируем исходный код
COPY . .
# Создаём папку для данных (будет переопределена volume при запуске)
RUN mkdir -p /app/data
# Обновляем кэш MIME-типов (нужно для WeasyPrint)
RUN update-mime-database /usr/share/mime
# Переменные окружения по умолчанию
ENV PYTHONUNBUFFERED=1 \
DATA_DIR=/app/data \
OLLAMA_BASE_URL=http://ollama:11434 \
ENVIRONMENT=production
# Открываем порт
EXPOSE 8000
# Команда запуска
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
+91
View File
@@ -0,0 +1,91 @@
# Writer IDE Backend
## Установка
```bash
cd backend
python3 -m venv venv
source venv/bin/activate # На Windows: venv\Scripts\activate
pip install -r requirements.txt
```
## Переменные окружения
Все настройки читаются из переменных окружения. Создайте файл `.env` на основе `.env.example`:
```bash
cp .env.example .env
# Отредактируйте .env, указав ваши значения
```
### Ключевые переменные
| Переменная | Описание | По умолчанию |
|------------|----------|--------------|
| `OPENROUTER_API_KEY` | API ключ OpenRouter | (пусто) |
| `OPENROUTER_BASE_URL` | URL OpenRouter API | https://openrouter.ai/api/v1 |
| `OLLAMA_BASE_URL` | URL Ollama сервера | http://localhost:11434 |
| `DATA_DIR` | Путь к папке с данными | data |
| `CORS_ORIGINS` | Разрешённые источники (через запятую) | http://localhost:3000 |
| `JWT_SECRET_KEY` | Секрет для JWT токенов | (захардкожен, изменить в production!) |
| `ENVIRONMENT` | Окружение | development |
### Docker-окружение
При запуске в Docker используйте файл `.env.docker` как шаблон:
```bash
cp .env.docker .env
```
Обратите внимание:
- `OLLAMA_BASE_URL` должен указывать на контейнер Ollama (например, http://ollama:11434)
- `DATA_DIR` должен быть `/app/data` (монтируется volume)
- `CORS_ORIGINS` должен включать домен/IP вашего сервера
- `JWT_SECRET_KEY` **обязательно** измените на случайную строку!
## Запуск
```bash
uvicorn main:app --reload --host 0.0.0.0 --port 8000
```
## Проверка
- API Health: http://localhost:8000/api/health
- Swagger UI: http://localhost:8000/docs
## Если возникли проблемы с Python 3.14
Если пакеты не устанавливаются, используйте pyenv для создания окружения с Python 3.12:
```bash
pyenv install 3.12.0
pyenv local 3.12.0
python3 -m venv venv
source venv/bin/activate
pip install -r requirements.txt
```
## Установка Pandoc (для экспорта в EPUB, FB2, PDF, DOCX)
Для работы функции экспорта необходимо установить [Pandoc](https://pandoc.org/) — универсальный конвертер документов.
### Ubuntu / Debian
```bash
sudo apt-get install pandoc
```
### macOS
```bash
brew install pandoc
```
### Windows
Скачайте установщик с официального сайта: [pandoc.org/installing.html](https://pandoc.org/installing.html)
### Проверка установки
```bash
pandoc --version
```
> **Примечание:** Для экспорта в PDF также требуется LaTeX-движок (например, `texlive-xetex` на Ubuntu или `MacTeX` на macOS). По умолчанию используется `xelatex`.
+72
View File
@@ -0,0 +1,72 @@
{
"ollama": {
"default_model": "qwen2.5:1.5b",
"available_models": [
{
"id": "qwen2.5:1.5b",
"name": "Qwen 2.5 1.5B (Local)",
"description": "Компактная модель для быстрых задач",
"context_length": 32768
},
{
"id": "qwen2.5:7b",
"name": "Qwen 2.5 7B (Local)",
"description": "Баланс скорости и качества",
"context_length": 32768
},
{
"id": "qwen2.5:14b",
"name": "Qwen 2.5 14B (Local)",
"description": "Высокое качество, требует больше ресурсов",
"context_length": 32768
}
]
},
"openrouter": {
"default_model": "qwen/qwen-2.5-coder-7b-instruct",
"available_models": [
{
"id": "qwen/qwen-2.5-coder-7b-instruct",
"name": "Qwen 2.5 Coder 7B",
"description": "Специализированная модель для кода, быстрая и дешевая",
"context_length": 32768,
"pricing": "$0.0001/1K tokens"
},
{
"id": "qwen/qwen-2.5-coder-32b-instruct",
"name": "Qwen 2.5 Coder 32B",
"description": "Мощная модель для сложных задач",
"context_length": 32768,
"pricing": "$0.0008/1K tokens"
},
{
"id": "qwen/qwen-2.5-7b-instruct",
"name": "Qwen 2.5 7B Instruct",
"description": "Универсальная модель для текста",
"context_length": 32768,
"pricing": "$0.0001/1K tokens"
},
{
"id": "qwen/qwen-2.5-72b-instruct",
"name": "Qwen 2.5 72B Instruct",
"description": "Максимальное качество, дороже",
"context_length": 32768,
"pricing": "$0.0008/1K tokens"
},
{
"id": "meta-llama/llama-3.1-8b-instruct",
"name": "Llama 3.1 8B",
"description": "Универсальная модель Meta",
"context_length": 131072,
"pricing": "$0.0001/1K tokens"
},
{
"id": "google/gemma-2-9b-it",
"name": "Gemma 2 9B",
"description": "Хороша для креативных задач",
"context_length": 8192,
"pricing": "$0.0001/1K tokens"
}
]
}
}
+48
View File
@@ -0,0 +1,48 @@
import os
from pathlib import Path
from dotenv import load_dotenv
# Загружаем переменные окружения из .env файла
load_dotenv()
class Settings:
# API ключи
OPENROUTER_API_KEY: str = os.getenv("OPENROUTER_API_KEY", "")
OPENROUTER_BASE_URL: str = os.getenv(
"OPENROUTER_BASE_URL", "https://openrouter.ai/api/v1"
)
# Ollama — в Docker это будет не localhost, а имя контейнера или IP хоста
OLLAMA_BASE_URL: str = os.getenv("OLLAMA_BASE_URL", "http://localhost:11434")
# Путь к данным — по умолчанию локальная папка, в Docker будет монтироваться volume
DATA_DIR: str = os.getenv("DATA_DIR", "data")
# Окружение
ENVIRONMENT: str = os.getenv("ENVIRONMENT", "development")
# CORS — разрешённые источники (в production это будет домен)
CORS_ORIGINS: list = os.getenv("CORS_ORIGINS", "http://localhost:3000").split(",")
# JWT секрет — в production обязательно переопределить через .env
JWT_SECRET_KEY: str = os.getenv(
"JWT_SECRET_KEY", "your-super-secret-key-change-in-production"
)
def validate(self) -> dict:
"""Проверить, какие провайдеры настроены"""
return {
"ollama": True, # Ollama всегда доступна локально
"openrouter": bool(
self.OPENROUTER_API_KEY
and self.OPENROUTER_API_KEY != "your_api_key_here"
),
"data_dir": self.DATA_DIR,
"cors_origins": self.CORS_ORIGINS,
}
# Singleton instance
settings = Settings()
+44
View File
@@ -0,0 +1,44 @@
from config.settings import settings
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from routers import ai, auth, bible, documents, export, health, images, projects
from services.document_service import document_service
from services.project_service import project_service
app = FastAPI(title="Writer IDE API", version="0.1.0")
# CORS configuration - uses settings from .env
app.add_middleware(
CORSMiddleware,
allow_origins=settings.CORS_ORIGINS,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Include routers
app.include_router(health.router, prefix="/api")
app.include_router(auth.router, prefix="/api")
app.include_router(projects.router, prefix="/api")
app.include_router(documents.router, prefix="/api")
app.include_router(ai.router, prefix="/api")
app.include_router(bible.router, prefix="/api")
app.include_router(export.router, prefix="/api")
app.include_router(images.router, prefix="/api")
@app.on_event("startup")
async def startup_event():
"""Инициализация тестового документа в активном проекте при запуске сервера"""
# Если есть проекты, инициализируем тестовый документ в первом
all_projects = project_service.get_all_projects()
if all_projects:
default_project = all_projects[0]
document_service.initialize_test_document(
default_project.id, default_project.user_id
)
@app.get("/")
async def root():
return {"message": "Writer IDE API", "docs": "/docs"}
View File
Whitespace-only changes.
+95
View File
@@ -0,0 +1,95 @@
from typing import Any, Dict, List, Literal, Optional
from pydantic import BaseModel
from models.bible import StoryBible
class GenerateRequest(BaseModel):
prompt: str
provider: Literal["ollama", "openrouter"] = "ollama"
model: Optional[str] = None
api_key: Optional[str] = None # Опционально, обычно берется из .env
preset: Literal["creative_draft", "editor", "brainstorm"] = "creative_draft"
options: Optional[Dict[str, Any]] = None
story_bible: Optional[StoryBible] = None # Story Bible для контекста
selected_text: Optional[str] = None # Текст, который будет обрабатываться
class GenerateResponse(BaseModel):
generated_text: str
provider: str
model: str
preset: str
class ExtractedEntities(BaseModel):
new_characters: List[Dict[str, Any]] = []
new_locations: List[Dict[str, Any]] = []
new_events: List[Dict[str, Any]] = []
updated_traits: List[Dict[str, Any]] = []
class ExtractEntitiesRequest(BaseModel):
text: str
existing_bible: Optional[StoryBible] = None
task_type: str = "background"
class ExtractEntitiesResponse(BaseModel):
entities: ExtractedEntities
model_used: str
provider: str
class AutoUpdateBibleRequest(BaseModel):
project_id: str
scene_text: str
auto_update_enabled: bool = True
class AutoUpdateBibleResponse(BaseModel):
updated: bool
new_characters: int = 0
new_locations: int = 0
new_events: int = 0
message: str = ""
class CleanupDuplicatesResponse(BaseModel):
removed_characters: int = 0
removed_locations: int = 0
removed_events: int = 0
message: str = ""
class PreviewContextRequest(BaseModel):
text: str
story_bible: StoryBible
class PreviewContextResponse(BaseModel):
context_string: str
mentioned_characters: List[str]
mentioned_locations: List[str]
mentioned_events: List[str]
class GenerateImageRequest(BaseModel):
prompt: str
project_id: str
document_id: str # ID документа (книги или главы)
document_type: str # 'book' или 'chapter'
model: Optional[str] = None
size: str = "1024x1024"
class GenerateImageResponse(BaseModel):
image_id: str
filename: str
path: str
prompt: str
model: str
data_url: str # base64 изображение для немедленного отображения
document_id: str # ID документа, к которому привязано изображение
document_type: str # 'book' или 'chapter'
+46
View File
@@ -0,0 +1,46 @@
from typing import List, Literal, Optional
from uuid import uuid4
from pydantic import BaseModel, Field
class Character(BaseModel):
id: str = Field(default_factory=lambda: str(uuid4()))
name: str
description: str = ""
traits: List[str] = [] # Характер, привычки
appearance: str = "" # Внешность
role: Literal["protagonist", "antagonist", "supporting", "minor"] = "supporting"
class Location(BaseModel):
id: str = Field(default_factory=lambda: str(uuid4()))
name: str
description: str = ""
atmosphere: str = "" # Атмосфера, ощущения
type: Literal["city", "building", "nature", "interior", "other"] = "other"
class Event(BaseModel):
id: str = Field(default_factory=lambda: str(uuid4()))
title: str
description: str = ""
chapter: Optional[str] = None # В какой главе произошло
importance: Literal["major", "minor", "background"] = "minor"
class StoryThread(BaseModel):
id: str = Field(default_factory=lambda: str(uuid4()))
title: str
description: str = ""
status: Literal["active", "resolved", "abandoned"] = "active"
related_characters: List[str] = [] # ID персонажей
related_events: List[str] = [] # ID событий
class StoryBible(BaseModel):
characters: List[Character] = []
locations: List[Location] = []
events: List[Event] = []
threads: List[StoryThread] = []
last_updated: Optional[str] = None # ISO timestamp
+51
View File
@@ -0,0 +1,51 @@
from datetime import datetime
from typing import Optional
from uuid import uuid4
from pydantic import BaseModel, Field
class DocumentNode(BaseModel):
"""Иерархическая модель документа: Книга → Глава → Сцена"""
id: str = Field(default_factory=lambda: str(uuid4()))
project_id: str
parent_id: Optional[str] = None
title: str = Field(..., min_length=1, max_length=200)
content: str = ""
type: str = Field(default="scene", pattern="^(book|chapter|scene)$")
order: int = 0
created_at: str = Field(default_factory=lambda: datetime.now().isoformat())
updated_at: str = Field(default_factory=lambda: datetime.now().isoformat())
# --- Модели для обратной совместимости (старый плоский формат) ---
class DocumentBase(BaseModel):
"""Базовая модель документа (legacy)"""
title: str = Field(..., min_length=1, max_length=200)
content: str = ""
type: str = Field(default="scene", pattern="^(scene|chapter|bible)$")
class DocumentCreate(DocumentBase):
"""Модель для создания нового документа (legacy)"""
pass
class DocumentUpdate(BaseModel):
"""Модель для обновления контента документа (legacy)"""
content: str
class DocumentResponse(DocumentBase):
"""Модель ответа с документом (legacy)"""
id: str
class Config:
from_attributes = True
+15
View File
@@ -0,0 +1,15 @@
from datetime import datetime
from uuid import uuid4
from pydantic import BaseModel, Field
class Project(BaseModel):
id: str = Field(default_factory=lambda: str(uuid4()))
user_id: str = (
"" # Привязка к пользователю (пустой для старых проектов до миграции)
)
title: str
description: str = ""
created_at: str = Field(default_factory=lambda: datetime.now().isoformat())
updated_at: str = Field(default_factory=lambda: datetime.now().isoformat())
+28
View File
@@ -0,0 +1,28 @@
from uuid import uuid4
from pydantic import BaseModel
class User(BaseModel):
id: str
username: str
email: str
hashed_password: str
class UserCreate(BaseModel):
username: str
email: str
password: str
class UserLogin(BaseModel):
username: str
password: str
class Token(BaseModel):
access_token: str
token_type: str
user_id: str
username: str
+8
View File
@@ -0,0 +1,8 @@
fastapi
uvicorn[standard]
pydantic
httpx
python-dotenv
bcrypt
python-jose[cryptography]
weasyprint
+1
View File
@@ -0,0 +1 @@
+428
View File
@@ -0,0 +1,428 @@
from config.settings import settings
from fastapi import APIRouter, Depends, HTTPException
from models.ai import (
AutoUpdateBibleRequest,
AutoUpdateBibleResponse,
CleanupDuplicatesResponse,
ExtractedEntities,
ExtractEntitiesRequest,
ExtractEntitiesResponse,
GenerateImageRequest,
GenerateImageResponse,
GenerateRequest,
GenerateResponse,
PreviewContextRequest,
PreviewContextResponse,
)
from models.bible import Character, Event, Location, StoryBible
from models.user import User
from services.bible_service import bible_service
from services.context_builder import context_builder
from services.deduplication import deduplication_service
from services.entity_extractor import entity_extractor
from services.image_generator import image_generator
from services.llm_router import llm_router
from services.model_config import model_config
from routers.auth import get_current_user
router = APIRouter()
def _get_user_project(project_id: str, current_user: User) -> None:
"""Проверить что проект принадлежит пользователю"""
from services.project_service import project_service
project = project_service.get_project(project_id)
if not project:
raise HTTPException(status_code=404, detail="Project not found")
if project.user_id != current_user.id:
raise HTTPException(status_code=403, detail="Access denied")
@router.get("/ai/providers/status")
async def get_providers_status():
"""Проверить, какие провайдеры настроены и готовы к использованию"""
return {
"providers": settings.validate(),
"openrouter_configured": bool(
settings.OPENROUTER_API_KEY
and settings.OPENROUTER_API_KEY != "your_api_key_here"
),
"openrouter_url": settings.OPENROUTER_BASE_URL,
"ollama_url": settings.OLLAMA_BASE_URL,
}
@router.get("/ai/models")
async def get_available_models(provider: str = "ollama"):
"""Получить список доступных моделей для указанного провайдера"""
try:
models = model_config.get_available_models(provider)
default_model = model_config.get_default_model(provider)
return {
"provider": provider,
"default_model": default_model,
"models": models,
}
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
@router.post("/ai/generate", response_model=GenerateResponse)
async def generate_text(request: GenerateRequest):
try:
# Обогащаем промпт контекстом из Story Bible
enriched_prompt = context_builder.enrich_prompt(
original_prompt=request.prompt,
selected_text=request.selected_text or "",
bible=request.story_bible,
)
# Генерируем текст с обогащенным промптом
generated_text = await llm_router.generate(
prompt=enriched_prompt,
provider=request.provider,
model=request.model,
api_key=request.api_key,
preset=request.preset,
options=request.options,
)
return GenerateResponse(
generated_text=generated_text,
provider=request.provider,
model=request.model or "default",
preset=request.preset,
)
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.post("/ai/preview-context", response_model=PreviewContextResponse)
async def preview_context(request: PreviewContextRequest):
"""Предпросмотр контекста, который будет добавлен к промпту"""
mentioned = context_builder.extract_mentioned_entities(
text=request.text,
bible=request.story_bible,
)
context_str = context_builder.build_context_string(mentioned)
return PreviewContextResponse(
context_string=context_str,
mentioned_characters=[c.name for c in mentioned["characters"]],
mentioned_locations=[l.name for l in mentioned["locations"]],
mentioned_events=[e.title for e in mentioned["events"]],
)
@router.post("/ai/extract-entities", response_model=ExtractEntitiesResponse)
async def extract_entities(request: ExtractEntitiesRequest):
"""Извлечение сущностей из текста с помощью AI"""
try:
# Используем OpenRouter с моделью, оптимальной для извлечения
provider = "openrouter"
model = "qwen/qwen-2.5-7b-instruct"
if (
not settings.OPENROUTER_API_KEY
or settings.OPENROUTER_API_KEY == "your_api_key_here"
):
raise ValueError(
"OpenRouter API key not configured. "
"Entity extraction requires a cloud model. "
"Please set OPENROUTER_API_KEY in backend/.env"
)
entities = await entity_extractor.extract_entities(
text=request.text,
existing_bible=request.existing_bible,
provider=provider,
model=model,
)
return ExtractEntitiesResponse(
entities=ExtractedEntities(**entities),
model_used=model,
provider=provider,
)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.post("/ai/auto-update-bible", response_model=AutoUpdateBibleResponse)
async def auto_update_bible(
request: AutoUpdateBibleRequest, current_user: User = Depends(get_current_user)
):
"""Автоматическое обновление Story Bible при сохранении сцены"""
if not request.auto_update_enabled:
return AutoUpdateBibleResponse(updated=False, message="Auto-update disabled")
_get_user_project(request.project_id, current_user)
try:
# Получаем текущую Story Bible проекта
current_bible = bible_service.get_bible(request.project_id, current_user.id)
# Извлекаем сущности из текста сцены
entities = await entity_extractor.extract_entities(
text=request.scene_text,
existing_bible=current_bible,
provider="openrouter",
model="qwen/qwen-2.5-7b-instruct",
)
# Добавляем новые сущности в Bible
new_chars_count = 0
new_locs_count = 0
new_events_count = 0
# Добавляем новых персонажей С ДЕДУПЛИКАЦИЕЙ
for char_data in entities.get("new_characters", []):
try:
char_name = char_data.get("name", "")
if not char_name:
continue
# Ищем существующего персонажа с похожим именем
existing_char = deduplication_service.find_existing_character(
char_name, current_bible
)
if existing_char:
# Объединяем данные с существующим персонажем
deduplication_service.merge_character_data(existing_char, char_data)
print(f"Merged character: {char_name} -> {existing_char.name}")
else:
# Создаем нового персонажа
character = Character(**char_data)
current_bible.characters.append(character)
new_chars_count += 1
print(f"Added new character: {char_name}")
except Exception as e:
print(f"Failed to process character: {e}")
# Добавляем новые локации С ДЕДУПЛИКАЦИЕЙ
for loc_data in entities.get("new_locations", []):
try:
loc_name = loc_data.get("name", "")
if not loc_name:
continue
existing_loc = deduplication_service.find_existing_location(
loc_name, current_bible
)
if existing_loc:
# Обновляем описание, если новое длиннее
new_desc = loc_data.get("description", "")
if new_desc and len(new_desc) > len(existing_loc.description):
existing_loc.description = new_desc
new_atm = loc_data.get("atmosphere", "")
if new_atm and not existing_loc.atmosphere:
existing_loc.atmosphere = new_atm
print(f"Merged location: {loc_name} -> {existing_loc.name}")
else:
location = Location(**loc_data)
current_bible.locations.append(location)
new_locs_count += 1
print(f"Added new location: {loc_name}")
except Exception as e:
print(f"Failed to process location: {e}")
# Добавляем новые события С ДЕДУПЛИКАЦИЕЙ
for event_data in entities.get("new_events", []):
try:
event_title = event_data.get("title", "")
if not event_title:
continue
existing_event = deduplication_service.find_existing_event(
event_title, current_bible
)
if existing_event:
# Обновляем описание, если новое длиннее
new_desc = event_data.get("description", "")
if new_desc and len(new_desc) > len(existing_event.description):
existing_event.description = new_desc
print(f"Merged event: {event_title} -> {existing_event.title}")
else:
event = Event(**event_data)
current_bible.events.append(event)
new_events_count += 1
print(f"Added new event: {event_title}")
except Exception as e:
print(f"Failed to process event: {e}")
# Обновляем черты существующих персонажей
for trait_update in entities.get("updated_traits", []):
char_name = trait_update.get("character_name")
new_traits = trait_update.get("new_traits", [])
for char in current_bible.characters:
if char.name.lower() == (char_name or "").lower():
# Добавляем новые черты, которых еще нет
for trait in new_traits:
if trait not in char.traits:
char.traits.append(trait)
# Сохраняем обновленную Bible
if new_chars_count > 0 or new_locs_count > 0 or new_events_count > 0:
bible_service.update_bible(
request.project_id, current_bible, current_user.id
)
message = (
f"Story Bible обновлена: +{new_chars_count} персонажей, "
f"+{new_locs_count} локаций, +{new_events_count} событий"
)
else:
message = "Новых сущностей не обнаружено"
return AutoUpdateBibleResponse(
updated=True,
new_characters=new_chars_count,
new_locations=new_locs_count,
new_events=new_events_count,
message=message,
)
except Exception as e:
print(f"Auto-update bible error: {e}")
return AutoUpdateBibleResponse(
updated=False,
message=f"Ошибка при обновлении: {str(e)}",
)
@router.post("/ai/extract-entities/test")
async def test_extraction():
"""Тестовый эндпоинт с примером текста"""
test_text = """
Алекс вошел в таверну "Серебряный дракон". Внутри пахло жареным мясом и элем.
За стойкой стоял хозяин — крепкий мужчина по имени Гаррет с рыжей бородой.
В углу сидела таинственная женщина в красном плаще — Алекс узнал Лиру.
"""
sample_bible = StoryBible(
characters=[
Character(name="Алекс", description="Главный герой", role="protagonist"),
Character(
name="Лира", description="Таинственная проводница", role="supporting"
),
]
)
try:
entities = await entity_extractor.extract_entities(
text=test_text,
existing_bible=sample_bible,
provider="openrouter",
model="qwen/qwen-2.5-7b-instruct",
)
return {
"test_text": test_text,
"extracted": entities,
}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.post(
"/projects/{project_id}/bible/cleanup-duplicates",
response_model=CleanupDuplicatesResponse,
)
async def cleanup_duplicates(
project_id: str, current_user: User = Depends(get_current_user)
):
"""Очистка дубликатов в Story Bible"""
_get_user_project(project_id, current_user)
try:
bible = bible_service.get_bible(project_id, current_user.id)
# Удаляем дубликаты персонажей
unique_chars = []
removed_chars = 0
for char in bible.characters:
is_duplicate = False
for existing in unique_chars:
if deduplication_service.is_similar_name(char.name, existing.name):
# Объединяем данные
deduplication_service.merge_character_data(
existing, char.model_dump()
)
is_duplicate = True
removed_chars += 1
break
if not is_duplicate:
unique_chars.append(char)
bible.characters = unique_chars
# Удаляем дубликаты локаций
unique_locs = []
removed_locs = 0
for loc in bible.locations:
is_duplicate = False
for existing in unique_locs:
if deduplication_service.is_similar_name(loc.name, existing.name):
is_duplicate = True
removed_locs += 1
break
if not is_duplicate:
unique_locs.append(loc)
bible.locations = unique_locs
# Удаляем дубликаты событий
unique_events = []
removed_events = 0
for event in bible.events:
is_duplicate = False
for existing in unique_events:
if deduplication_service.is_similar_name(event.title, existing.title):
is_duplicate = True
removed_events += 1
break
if not is_duplicate:
unique_events.append(event)
bible.events = unique_events
# Сохраняем очищенную Bible
bible_service.update_bible(project_id, bible, current_user.id)
return CleanupDuplicatesResponse(
removed_characters=removed_chars,
removed_locations=removed_locs,
removed_events=removed_events,
message=f"Удалено дубликатов: {removed_chars} персонажей, {removed_locs} локаций, {removed_events} событий",
)
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.post("/ai/generate-image", response_model=GenerateImageResponse)
async def generate_image(
request: GenerateImageRequest, current_user: User = Depends(get_current_user)
):
"""Генерация изображения по промпту"""
try:
result = await image_generator.generate_image(
prompt=request.prompt,
project_id=request.project_id,
user_id=current_user.id,
document_id=request.document_id,
document_type=request.document_type,
model=request.model,
size=request.size,
)
return result
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
+50
View File
@@ -0,0 +1,50 @@
from fastapi import APIRouter, Depends, HTTPException
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from jose import JWTError, jwt
from models.user import Token, User, UserCreate, UserLogin
from services.auth_service import ALGORITHM, SECRET_KEY, auth_service
router = APIRouter()
security = HTTPBearer()
@router.post("/auth/register", response_model=Token)
async def register(user_create: UserCreate):
try:
user = auth_service.create_user(user_create)
return auth_service.get_token(user.username, user_create.password)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
@router.post("/auth/login", response_model=Token)
async def login(user_login: UserLogin):
try:
return auth_service.get_token(user_login.username, user_login.password)
except ValueError:
raise HTTPException(status_code=401, detail="Invalid username or password")
def get_current_user(
credentials: HTTPAuthorizationCredentials = Depends(security),
) -> User:
try:
payload = jwt.decode(
credentials.credentials, SECRET_KEY, algorithms=[ALGORITHM]
)
username: str = payload.get("sub")
user_id: str = payload.get("user_id")
if username is None or user_id is None:
raise HTTPException(status_code=401, detail="Invalid token")
user = auth_service.get_user_by_username(username)
if not user or user.id != user_id:
raise HTTPException(status_code=401, detail="User not found")
return user
except JWTError:
raise HTTPException(status_code=401, detail="Invalid token")
@router.get("/auth/me", response_model=User)
async def get_me(current_user: User = Depends(get_current_user)):
return current_user
+83
View File
@@ -0,0 +1,83 @@
from fastapi import APIRouter, Depends, HTTPException
from models.bible import Character, Event, Location, StoryBible, StoryThread
from models.user import User
from services.bible_service import bible_service
from routers.auth import get_current_user
router = APIRouter()
def _get_user_project(project_id: str, current_user: User) -> None:
"""Проверить что проект принадлежит пользователю"""
from services.project_service import project_service
project = project_service.get_project(project_id)
if not project:
raise HTTPException(status_code=404, detail="Project not found")
if project.user_id != current_user.id:
raise HTTPException(status_code=403, detail="Access denied")
@router.get("/projects/{project_id}/bible", response_model=StoryBible)
async def get_story_bible(
project_id: str, current_user: User = Depends(get_current_user)
):
"""Получить Story Bible проекта"""
_get_user_project(project_id, current_user)
return bible_service.get_bible(project_id, current_user.id)
@router.put("/projects/{project_id}/bible", response_model=StoryBible)
async def update_story_bible(
project_id: str,
bible: StoryBible,
current_user: User = Depends(get_current_user),
):
"""Обновить всю Story Bible проекта"""
_get_user_project(project_id, current_user)
return bible_service.update_bible(project_id, bible, current_user.id)
@router.post("/projects/{project_id}/bible/characters", response_model=Character)
async def add_character(
project_id: str,
character: Character,
current_user: User = Depends(get_current_user),
):
"""Добавить нового персонажа в проект"""
_get_user_project(project_id, current_user)
return bible_service.add_character(project_id, character, current_user.id)
@router.post("/projects/{project_id}/bible/locations", response_model=Location)
async def add_location(
project_id: str,
location: Location,
current_user: User = Depends(get_current_user),
):
"""Добавить новую локацию в проект"""
_get_user_project(project_id, current_user)
return bible_service.add_location(project_id, location, current_user.id)
@router.post("/projects/{project_id}/bible/events", response_model=Event)
async def add_event(
project_id: str,
event: Event,
current_user: User = Depends(get_current_user),
):
"""Добавить новое событие в проект"""
_get_user_project(project_id, current_user)
return bible_service.add_event(project_id, event, current_user.id)
@router.post("/projects/{project_id}/bible/threads", response_model=StoryThread)
async def add_thread(
project_id: str,
thread: StoryThread,
current_user: User = Depends(get_current_user),
):
"""Добавить новую сюжетную нить в проект"""
_get_user_project(project_id, current_user)
return bible_service.add_thread(project_id, thread, current_user.id)
+151
View File
@@ -0,0 +1,151 @@
from typing import Any, Dict, List, Optional
from fastapi import APIRouter, Depends, HTTPException
from models.document import DocumentNode
from models.user import User
from pydantic import BaseModel
from services.document_service import document_service
from routers.auth import get_current_user
router = APIRouter()
class CreateDocumentRequest(BaseModel):
title: str
type: str # "book", "chapter", "scene"
parent_id: Optional[str] = None
content: str = ""
class UpdateDocumentRequest(BaseModel):
title: Optional[str] = None
content: Optional[str] = None
parent_id: Optional[str] = None # Для переноса
order: Optional[int] = None
def _get_user_project(project_id: str, current_user: User) -> None:
"""Проверить что проект принадлежит пользователю"""
from services.project_service import project_service
project = project_service.get_project(project_id)
if not project:
raise HTTPException(status_code=404, detail="Project not found")
if project.user_id != current_user.id:
raise HTTPException(status_code=403, detail="Access denied")
@router.get(
"/projects/{project_id}/documents/tree", response_model=List[Dict[str, Any]]
)
async def get_documents_tree(
project_id: str, current_user: User = Depends(get_current_user)
):
"""Получить документы в виде дерева"""
_get_user_project(project_id, current_user)
return document_service.get_documents_tree(project_id, current_user.id)
@router.get("/projects/{project_id}/documents", response_model=List[DocumentNode])
async def get_all_documents(
project_id: str, current_user: User = Depends(get_current_user)
):
"""Получить все документы (плоский список)"""
_get_user_project(project_id, current_user)
return document_service.get_all_documents(project_id, current_user.id)
@router.get(
"/projects/{project_id}/documents/{document_id}", response_model=DocumentNode
)
async def get_document(
project_id: str,
document_id: str,
current_user: User = Depends(get_current_user),
):
"""Получить документ по ID"""
_get_user_project(project_id, current_user)
document = document_service.get_document(project_id, document_id, current_user.id)
if not document:
raise HTTPException(status_code=404, detail="Document not found")
return document
@router.post(
"/projects/{project_id}/documents", response_model=DocumentNode, status_code=201
)
async def create_document(
project_id: str,
request: CreateDocumentRequest,
current_user: User = Depends(get_current_user),
):
"""Создать новый документ"""
_get_user_project(project_id, current_user)
try:
return document_service.create_document(
project_id=project_id,
title=request.title,
doc_type=request.type,
user_id=current_user.id,
parent_id=request.parent_id,
content=request.content,
)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
@router.put(
"/projects/{project_id}/documents/{document_id}", response_model=DocumentNode
)
async def update_document(
project_id: str,
document_id: str,
request: UpdateDocumentRequest,
current_user: User = Depends(get_current_user),
):
"""Обновить документ (включая перенос)"""
_get_user_project(project_id, current_user)
try:
document = document_service.update_document(
project_id=project_id,
document_id=document_id,
user_id=current_user.id,
title=request.title,
content=request.content,
parent_id=request.parent_id,
order=request.order,
)
if not document:
raise HTTPException(status_code=404, detail="Document not found")
return document
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
@router.delete("/projects/{project_id}/documents/{document_id}")
async def delete_document(
project_id: str,
document_id: str,
current_user: User = Depends(get_current_user),
):
"""Удалить документ с каскадом"""
_get_user_project(project_id, current_user)
success = document_service.delete_document(project_id, document_id, current_user.id)
if not success:
raise HTTPException(status_code=404, detail="Document not found")
return {"status": "deleted", "document_id": document_id}
@router.get(
"/projects/{project_id}/documents/{parent_id}/children",
response_model=List[DocumentNode],
)
async def get_children(
project_id: str,
parent_id: str,
current_user: User = Depends(get_current_user),
):
"""Получить прямых детей узла"""
_get_user_project(project_id, current_user)
return document_service.get_children(project_id, parent_id, current_user.id)
+64
View File
@@ -0,0 +1,64 @@
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import Response
from models.user import User
from pydantic import BaseModel
from services.export_service import export_service
from routers.auth import get_current_user
router = APIRouter()
class ExportRequest(BaseModel):
project_id: str
document_id: Optional[str] = (
None # ID книги или главы. Если None — экспортируем всю книгу
)
format: str # 'epub', 'fb2', 'pdf', 'docx'
def _get_user_project(project_id: str, current_user: User) -> None:
"""Проверить что проект принадлежит пользователю"""
from services.project_service import project_service
project = project_service.get_project(project_id)
if not project:
raise HTTPException(status_code=404, detail="Project not found")
if project.user_id != current_user.id:
raise HTTPException(status_code=403, detail="Access denied")
@router.post("/export")
async def export_book(
request: ExportRequest, current_user: User = Depends(get_current_user)
):
_get_user_project(request.project_id, current_user)
try:
file_content = export_service.export_document(
project_id=request.project_id,
user_id=current_user.id,
document_id=request.document_id,
format=request.format,
)
# Определяем MIME-тип
mime_types = {
"epub": "application/epub+zip",
"fb2": "application/fb2",
"pdf": "application/pdf",
"docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
}
filename = f"export.{request.format}"
return Response(
content=file_content,
media_type=mime_types.get(request.format, "application/octet-stream"),
headers={"Content-Disposition": f"attachment; filename={filename}"},
)
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
+8
View File
@@ -0,0 +1,8 @@
from fastapi import APIRouter
router = APIRouter()
@router.get("/health")
async def health_check():
return {"status": "ok", "message": "Writer IDE API is running"}
+40
View File
@@ -0,0 +1,40 @@
from pathlib import Path
from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import FileResponse
from models.user import User
from services.project_service import project_service
from routers.auth import get_current_user
router = APIRouter()
@router.get("/images/{project_id}/{filename}")
async def get_image(
project_id: str, filename: str, current_user: User = Depends(get_current_user)
):
"""Отдаёт изображение из папки проекта"""
try:
project_dir = project_service.get_project_dir(project_id, current_user.id)
image_path = project_dir / "images" / filename
if not image_path.exists():
raise HTTPException(status_code=404, detail="Image not found")
# Определяем MIME тип по расширению
suffix = Path(filename).suffix.lower()
mime_types = {
".png": "image/png",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".webp": "image/webp",
".gif": "image/gif",
}
return FileResponse(
path=image_path,
media_type=mime_types.get(suffix, "application/octet-stream"),
)
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
+85
View File
@@ -0,0 +1,85 @@
from fastapi import APIRouter, Depends, HTTPException
from models.project import Project
from models.user import User
from pydantic import BaseModel
from services.project_service import project_service
from routers.auth import get_current_user
router = APIRouter()
class CreateProjectRequest(BaseModel):
title: str
description: str = ""
class UpdateProjectRequest(BaseModel):
title: str | None = None
description: str | None = None
@router.get("/projects", response_model=list[Project])
async def get_all_projects(current_user: User = Depends(get_current_user)):
"""Получить все проекты текущего пользователя"""
return project_service.get_all_projects(user_id=current_user.id)
@router.get("/projects/{project_id}", response_model=Project)
async def get_project(project_id: str, current_user: User = Depends(get_current_user)):
"""Получить проект по ID"""
project = project_service.get_project(project_id)
if not project:
raise HTTPException(status_code=404, detail="Project not found")
if project.user_id != current_user.id:
raise HTTPException(status_code=403, detail="Access denied")
return project
@router.post("/projects", response_model=Project, status_code=201)
async def create_project(
request: CreateProjectRequest, current_user: User = Depends(get_current_user)
):
"""Создать новый проект"""
return project_service.create_project(
title=request.title,
user_id=current_user.id,
description=request.description,
)
@router.put("/projects/{project_id}", response_model=Project)
async def update_project(
project_id: str,
request: UpdateProjectRequest,
current_user: User = Depends(get_current_user),
):
"""Обновить проект"""
project = project_service.get_project(project_id)
if not project:
raise HTTPException(status_code=404, detail="Project not found")
if project.user_id != current_user.id:
raise HTTPException(status_code=403, detail="Access denied")
updated = project_service.update_project(
project_id=project_id,
title=request.title,
description=request.description,
)
if not updated:
raise HTTPException(status_code=404, detail="Project not found")
return updated
@router.delete("/projects/{project_id}")
async def delete_project(
project_id: str, current_user: User = Depends(get_current_user)
):
"""Удалить проект"""
try:
success = project_service.delete_project(project_id, current_user.id)
if not success:
raise HTTPException(status_code=404, detail="Project not found")
return {"status": "deleted", "project_id": project_id}
except ValueError as e:
raise HTTPException(status_code=403, detail=str(e))
View File
Whitespace-only changes.
+109
View File
@@ -0,0 +1,109 @@
import json
from datetime import datetime, timedelta
from pathlib import Path
from typing import Optional
from uuid import uuid4
import bcrypt
# Настройки JWT (читаются из config/settings)
from config.settings import settings
from jose import JWTError, jwt
from models.user import Token, User, UserCreate
SECRET_KEY = settings.JWT_SECRET_KEY
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = 60 * 24 * 7 # 7 дней
class AuthService:
def __init__(self):
from config.settings import settings
self.data_dir = Path(settings.DATA_DIR)
self.data_dir.mkdir(exist_ok=True)
self.users_file = self.data_dir / "users.json"
self.users = self._load_users()
def _load_users(self) -> list[User]:
if self.users_file.exists():
with open(self.users_file, "r", encoding="utf-8") as f:
data = json.load(f)
return [User(**u) for u in data]
return []
def _save_users(self):
with open(self.users_file, "w", encoding="utf-8") as f:
json.dump(
[u.model_dump() for u in self.users], f, ensure_ascii=False, indent=2
)
def verify_password(self, plain_password: str, hashed_password: str) -> bool:
return bcrypt.checkpw(
plain_password.encode("utf-8"), hashed_password.encode("utf-8")
)
def get_password_hash(self, password: str) -> str:
return bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt()).decode("utf-8")
def get_user_by_username(self, username: str) -> Optional[User]:
for user in self.users:
if user.username == username:
return user
return None
def get_user_by_id(self, user_id: str) -> Optional[User]:
for user in self.users:
if user.id == user_id:
return user
return None
def create_user(self, user_create: UserCreate) -> User:
if self.get_user_by_username(user_create.username):
raise ValueError("Username already exists")
new_user = User(
id=str(uuid4()),
username=user_create.username,
email=user_create.email,
hashed_password=self.get_password_hash(user_create.password),
)
self.users.append(new_user)
self._save_users()
# Создаем папку для проектов пользователя
user_projects_dir = self.data_dir / "users" / new_user.id / "projects"
user_projects_dir.mkdir(parents=True, exist_ok=True)
return new_user
def authenticate_user(self, username: str, password: str) -> Optional[User]:
user = self.get_user_by_username(username)
if not user or not self.verify_password(password, user.hashed_password):
return None
return user
def create_access_token(self, data: dict) -> str:
to_encode = data.copy()
expire = datetime.utcnow() + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
to_encode.update({"exp": expire})
return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
def get_token(self, username: str, password: str) -> Token:
user = self.authenticate_user(username, password)
if not user:
raise ValueError("Invalid username or password")
access_token = self.create_access_token(
data={"sub": user.username, "user_id": user.id}
)
return Token(
access_token=access_token,
token_type="bearer",
user_id=user.id,
username=user.username,
)
# Singleton
auth_service = AuthService()
+135
View File
@@ -0,0 +1,135 @@
import json
from datetime import datetime
from pathlib import Path
from config.settings import settings
from models.bible import Character, Event, Location, StoryBible, StoryThread
from services.project_service import project_service
class BibleService:
def __init__(self):
from config.settings import settings
self.data_dir = Path(settings.DATA_DIR)
self.project_service = project_service
def _get_bible_file(self, project_id: str, user_id: str) -> Path:
"""Получить путь к файлу Story Bible проекта"""
project_dir = self.project_service.get_project_dir(project_id, user_id)
return project_dir / "story_bible.json"
def _load_bible(self, project_id: str, user_id: str) -> StoryBible:
"""Загрузить Story Bible проекта"""
bible_file = self._get_bible_file(project_id, user_id)
if bible_file.exists():
with open(bible_file, "r", encoding="utf-8") as f:
data = json.load(f)
return StoryBible(**data)
else:
# Инициализация с примером данных
return self._create_sample_bible()
def _create_sample_bible(self) -> StoryBible:
"""Создать пример Story Bible для тестирования"""
return StoryBible(
characters=[
Character(
name="Алекс",
description="Главный герой, бывший архитектор",
traits=["Циничный", "Справедливый", "Наблюдательный"],
appearance="Высокий, темные волосы, шрам на левой руке",
role="protagonist",
),
Character(
name="Лира",
description="Таинственная проводница",
traits=["Загадочная", "Решительная"],
appearance="Носит красный плащ, зеленые глаза",
role="supporting",
),
],
locations=[
Location(
name="Нижний Город",
description="Старая часть города с узкими улочками",
atmosphere="Вечный туман, запах озона и мокрого камня",
type="city",
)
],
events=[
Event(
title="Встреча с Лирой",
description="Алекс встречает Лиру в тумане Нижнего Города",
chapter="Глава 1",
importance="major",
)
],
threads=[
StoryThread(
title="Тайна хранилища",
description="Поиск древнего хранилища знаний",
status="active",
related_characters=[],
related_events=[],
)
],
last_updated=datetime.now().isoformat(),
)
def _save_bible(self, project_id: str, bible: StoryBible, user_id: str):
"""Сохранить Story Bible проекта"""
bible_file = self._get_bible_file(project_id, user_id)
bible.last_updated = datetime.now().isoformat()
with open(bible_file, "w", encoding="utf-8") as f:
json.dump(bible.model_dump(), f, ensure_ascii=False, indent=2)
def get_bible(self, project_id: str, user_id: str) -> StoryBible:
"""Получить Story Bible проекта"""
return self._load_bible(project_id, user_id)
def update_bible(
self, project_id: str, new_bible: StoryBible, user_id: str
) -> StoryBible:
"""Обновить Story Bible проекта"""
self._save_bible(project_id, new_bible, user_id)
return new_bible
def add_character(
self, project_id: str, character: Character, user_id: str
) -> Character:
"""Добавить персонажа в проект"""
bible = self._load_bible(project_id, user_id)
bible.characters.append(character)
self._save_bible(project_id, bible, user_id)
return character
def add_location(
self, project_id: str, location: Location, user_id: str
) -> Location:
"""Добавить локацию в проект"""
bible = self._load_bible(project_id, user_id)
bible.locations.append(location)
self._save_bible(project_id, bible, user_id)
return location
def add_event(self, project_id: str, event: Event, user_id: str) -> Event:
"""Добавить событие в проект"""
bible = self._load_bible(project_id, user_id)
bible.events.append(event)
self._save_bible(project_id, bible, user_id)
return event
def add_thread(
self, project_id: str, thread: StoryThread, user_id: str
) -> StoryThread:
"""Добавить сюжетную нить в проект"""
bible = self._load_bible(project_id, user_id)
bible.threads.append(thread)
self._save_bible(project_id, bible, user_id)
return thread
# Singleton instance
bible_service = BibleService()
+123
View File
@@ -0,0 +1,123 @@
import re
from typing import Any, Dict, List, Optional
from models.bible import Character, Event, Location, StoryBible
class ContextBuilder:
def extract_mentioned_entities(
self, text: str, bible: StoryBible
) -> Dict[str, List[Any]]:
"""Извлекает упоминания сущностей из текста"""
text_lower = text.lower()
mentioned_chars: List[Character] = []
mentioned_locs: List[Location] = []
mentioned_events: List[Event] = []
# Ищем упоминания персонажей
for char in bible.characters:
name_patterns = [
char.name.lower(),
char.name.lower().split()[0] if " " in char.name else char.name.lower(),
]
for pattern in name_patterns:
if pattern and len(pattern) > 2:
if re.search(r"\b" + re.escape(pattern) + r"\b", text_lower):
mentioned_chars.append(char)
break
# Ищем упоминания локаций
for loc in bible.locations:
name_patterns = [
loc.name.lower(),
loc.name.lower().split()[0] if " " in loc.name else loc.name.lower(),
]
for pattern in name_patterns:
if pattern and len(pattern) > 2:
if re.search(r"\b" + re.escape(pattern) + r"\b", text_lower):
mentioned_locs.append(loc)
break
# Ищем упоминания событий (по ключевым словам в названии)
for event in bible.events:
keywords = re.findall(r"\w{4,}", event.title.lower())
for keyword in keywords:
if keyword in text_lower:
mentioned_events.append(event)
break
return {
"characters": mentioned_chars,
"locations": mentioned_locs,
"events": mentioned_events,
}
def build_context_string(self, mentioned_entities: Dict[str, List[Any]]) -> str:
"""Строит строку контекста для промпта"""
context_parts: List[str] = []
# Добавляем информацию о персонажах
if mentioned_entities["characters"]:
chars_info: List[str] = []
for char in mentioned_entities["characters"][:5]:
traits_str = (
", ".join(char.traits[:3]) if char.traits else "нет описания"
)
chars_info.append(
f"- {char.name}: {char.description} (черты: {traits_str})"
)
context_parts.append("ПЕРСОНАЖИ В СЦЕНЕ:\n" + "\n".join(chars_info))
# Добавляем информацию о локациях
if mentioned_entities["locations"]:
locs_info: List[str] = []
for loc in mentioned_entities["locations"][:3]:
atm_str = f"Атмосфера: {loc.atmosphere}" if loc.atmosphere else ""
locs_info.append(f"- {loc.name}: {loc.description} {atm_str}".strip())
context_parts.append("ЛОКАЦИИ:\n" + "\n".join(locs_info))
# Добавляем информацию о событиях
if mentioned_entities["events"]:
events_info: List[str] = []
for event in mentioned_entities["events"][:3]:
events_info.append(f"- {event.title}: {event.description}")
context_parts.append("СОБЫТИЯ:\n" + "\n".join(events_info))
return "\n\n".join(context_parts)
def enrich_prompt(
self,
original_prompt: str,
selected_text: str,
bible: Optional[StoryBible],
) -> str:
"""Обогащает промпт контекстом из Story Bible"""
if not bible or not selected_text:
return original_prompt
mentioned = self.extract_mentioned_entities(selected_text, bible)
if not any(mentioned.values()):
return original_prompt
context_str = self.build_context_string(mentioned)
enriched_prompt = (
f"КОНТЕКСТ ПРОИЗВЕДЕНИЯ:\n{context_str}\n\n"
f"ВАЖНО: При выполнении задачи учитывай приведенный контекст. "
f"Сохраняй консистентность с описаниями персонажей, локаций и событий. "
f"Не противоречь установленным фактам.\n\n"
f"ЗАДАЧА:\n{original_prompt}"
)
return enriched_prompt
# Singleton instance
context_builder = ContextBuilder()
+133
View File
@@ -0,0 +1,133 @@
import re
from typing import List, Optional, Tuple
from models.bible import Character, Event, Location, StoryBible
class DeduplicationService:
# Общие слова, которые нужно удалять при нормализации
CHARACTER_PREFIXES = [
"котенок",
"кот",
"господин",
"мистер",
"доктор",
"профессор",
"мальчик",
"девочка",
"пёс",
"собака",
]
LOCATION_PREFIXES = ["город", "деревня", "село", "улица", "площадь"]
def normalize_name(self, name: str, entity_type: str = "character") -> str:
"""Нормализует имя сущности для сравнения"""
# Приводим к нижнему регистру
normalized = name.lower().strip()
# Убираем лишние пробелы
normalized = re.sub(r"\s+", " ", normalized)
# Убираем префиксы в зависимости от типа
prefixes = (
self.CHARACTER_PREFIXES
if entity_type == "character"
else self.LOCATION_PREFIXES
)
for prefix in prefixes:
if normalized.startswith(prefix + " "):
normalized = normalized[len(prefix) :].strip()
return normalized
def is_similar_name(self, name1: str, name2: str) -> bool:
"""Проверяет, являются ли два имени вариантами одного и того же"""
norm1 = self.normalize_name(name1)
norm2 = self.normalize_name(name2)
# Точное совпадение после нормализации
if norm1 == norm2:
return True
# Одно имя содержит другое (например, "Кекс" и "котенок Кекс")
if norm1 in norm2 or norm2 in norm1:
return True
# Расстояние Левенштейна (простая реализация)
distance = self._levenshtein_distance(norm1, norm2)
max_len = max(len(norm1), len(norm2))
if max_len > 0:
similarity = 1 - (distance / max_len)
if similarity > 0.7: # 70% сходства
return True
return False
def _levenshtein_distance(self, s1: str, s2: str) -> int:
"""Вычисляет расстояние Левенштейна между двумя строками"""
if len(s1) < len(s2):
return self._levenshtein_distance(s2, s1)
if len(s2) == 0:
return len(s1)
previous_row = range(len(s2) + 1)
for i, c1 in enumerate(s1):
current_row = [i + 1]
for j, c2 in enumerate(s2):
insertions = previous_row[j + 1] + 1
deletions = current_row[j] + 1
substitutions = previous_row[j] + (c1 != c2)
current_row.append(min(insertions, deletions, substitutions))
previous_row = current_row
return previous_row[-1]
def find_existing_character(
self, name: str, bible: StoryBible
) -> Optional[Character]:
"""Ищет существующего персонажа с похожим именем"""
for char in bible.characters:
if self.is_similar_name(name, char.name):
return char
return None
def find_existing_location(
self, name: str, bible: StoryBible
) -> Optional[Location]:
"""Ищет существующую локацию с похожим именем"""
for loc in bible.locations:
if self.is_similar_name(name, loc.name):
return loc
return None
def find_existing_event(self, title: str, bible: StoryBible) -> Optional[Event]:
"""Ищет существующее событие с похожим названием"""
for event in bible.events:
if self.is_similar_name(title, event.title):
return event
return None
def merge_character_data(self, existing: Character, new_data: dict) -> Character:
"""Объединяет данные нового персонажа с существующим"""
# Добавляем новые черты, которых еще нет
new_traits = new_data.get("traits", [])
for trait in new_traits:
if trait and trait not in existing.traits:
existing.traits.append(trait)
# Обновляем описание, если новое длиннее и содержательнее
new_desc = new_data.get("description", "")
if new_desc and len(new_desc) > len(existing.description):
existing.description = new_desc
# Обновляем внешность, если она была пустой
new_appearance = new_data.get("appearance", "")
if new_appearance and not existing.appearance:
existing.appearance = new_appearance
return existing
# Singleton instance
deduplication_service = DeduplicationService()
+302
View File
@@ -0,0 +1,302 @@
"""
Сервис для работы с документами (иерархическая структура: Книга → Глава → Сцена)
Документы хранятся в папке проекта: data/projects/{project_id}/documents.json
"""
import json
from datetime import datetime
from pathlib import Path
from typing import Any, Dict, List, Optional
from uuid import uuid4
from models.document import DocumentNode
from services.project_service import project_service
class DocumentService:
def __init__(self):
self.project_service = project_service
def _get_documents_file(self, project_id: str, user_id: str) -> Path:
project_dir = self.project_service.get_project_dir(project_id, user_id)
return project_dir / "documents.json"
def _load_documents(self, project_id: str, user_id: str) -> List[DocumentNode]:
documents_file = self._get_documents_file(project_id, user_id)
if documents_file.exists():
with open(documents_file, "r", encoding="utf-8") as f:
data = json.load(f)
# Старый формат: dict, где ключи — ID документов
if isinstance(data, dict):
flat_list = list(data.values())
if flat_list and len(flat_list) > 0:
return self._migrate_flat_to_hierarchical(
flat_list, project_id, user_id
)
return []
# Новый формат: list
if isinstance(data, list) and len(data) > 0:
# Миграция: если старые данные без parent_id
first = data[0]
if isinstance(first, dict) and "parent_id" not in first:
return self._migrate_flat_to_hierarchical(
data, project_id, user_id
)
return [DocumentNode(**doc) for doc in data]
return []
def _migrate_flat_to_hierarchical(
self, old_data: List[Dict], project_id: str, user_id: str
) -> List[DocumentNode]:
"""Миграция плоских документов в иерархию"""
book = DocumentNode(
project_id=project_id,
title="Книга 1",
type="book",
order=0,
)
chapter = DocumentNode(
project_id=project_id,
parent_id=book.id,
title="Глава 1",
type="chapter",
order=0,
)
scenes = []
for i, old_doc in enumerate(old_data):
scene = DocumentNode(
id=old_doc.get("id", str(uuid4())),
project_id=project_id,
parent_id=chapter.id,
title=old_doc.get("title", f"Сцена {i + 1}"),
content=old_doc.get("content", ""),
type="scene",
order=i,
)
scenes.append(scene)
result = [book, chapter] + scenes
self._save_documents(project_id, result, user_id)
print(f"Migrated {len(old_data)} flat documents to hierarchical structure")
return result
def _save_documents(
self, project_id: str, documents: List[DocumentNode], user_id: str
):
documents_file = self._get_documents_file(project_id, user_id)
with open(documents_file, "w", encoding="utf-8") as f:
json.dump(
[doc.model_dump() for doc in documents],
f,
ensure_ascii=False,
indent=2,
)
def get_documents_tree(self, project_id: str, user_id: str) -> List[Dict[str, Any]]:
"""Получить документы в виде дерева"""
documents = self._load_documents(project_id, user_id)
return self._build_tree(documents)
def _build_tree(self, documents: List[DocumentNode]) -> List[Dict[str, Any]]:
"""Строит дерево из плоского списка"""
doc_map = {doc.id: {**doc.model_dump(), "children": []} for doc in documents}
roots = []
for doc in documents:
node = doc_map[doc.id]
if doc.parent_id is None:
roots.append(node)
elif doc.parent_id in doc_map:
doc_map[doc.parent_id]["children"].append(node)
def sort_tree(nodes):
nodes.sort(key=lambda x: x["order"])
for node in nodes:
sort_tree(node["children"])
sort_tree(roots)
return roots
def get_all_documents(self, project_id: str, user_id: str) -> List[DocumentNode]:
"""Получить все документы (плоский список)"""
return self._load_documents(project_id, user_id)
def get_document(
self, project_id: str, document_id: str, user_id: str
) -> Optional[DocumentNode]:
documents = self._load_documents(project_id, user_id)
for doc in documents:
if doc.id == document_id:
return doc
return None
def create_document(
self,
project_id: str,
title: str,
doc_type: str,
user_id: str,
parent_id: Optional[str] = None,
content: str = "",
) -> DocumentNode:
"""Создать новый документ с валидацией иерархии"""
documents = self._load_documents(project_id, user_id)
# Валидация иерархии
if doc_type == "chapter" and parent_id:
parent = next((d for d in documents if d.id == parent_id), None)
if not parent or parent.type != "book":
raise ValueError("Глава может быть создана только внутри книги")
if doc_type == "scene" and parent_id:
parent = next((d for d in documents if d.id == parent_id), None)
if not parent or parent.type != "chapter":
raise ValueError("Сцена может быть создана только внутри главы")
if doc_type == "book" and parent_id:
raise ValueError("Книга не может иметь родителя")
# Вычисляем order (последний среди братьев + 1)
siblings = [d for d in documents if d.parent_id == parent_id]
max_order = max([s.order for s in siblings], default=-1)
document = DocumentNode(
project_id=project_id,
parent_id=parent_id,
title=title,
content=content,
type=doc_type,
order=max_order + 1,
)
documents.append(document)
self._save_documents(project_id, documents, user_id)
return document
def update_document(
self,
project_id: str,
document_id: str,
user_id: str,
title: Optional[str] = None,
content: Optional[str] = None,
parent_id: Optional[str] = None,
order: Optional[int] = None,
) -> Optional[DocumentNode]:
"""Обновить документ (включая перенос)"""
documents = self._load_documents(project_id, user_id)
for i, doc in enumerate(documents):
if doc.id == document_id:
# Валидация при переносе
if parent_id is not None and parent_id != doc.parent_id:
new_parent = next((d for d in documents if d.id == parent_id), None)
if doc.type == "book":
raise ValueError("Книга не может быть перемещена")
if doc.type == "chapter" and (
not new_parent or new_parent.type != "book"
):
raise ValueError("Глава может находиться только внутри книги")
if doc.type == "scene" and (
not new_parent or new_parent.type != "chapter"
):
raise ValueError("Сцена может находиться только внутри главы")
documents[i].parent_id = parent_id
if title is not None:
documents[i].title = title
if content is not None:
documents[i].content = content
if order is not None:
documents[i].order = order
documents[i].updated_at = datetime.now().isoformat()
self._save_documents(project_id, documents, user_id)
return documents[i]
return None
def delete_document(self, project_id: str, document_id: str, user_id: str) -> bool:
"""Удалить документ с каскадом (дети тоже удаляются)"""
documents = self._load_documents(project_id, user_id)
target = next((d for d in documents if d.id == document_id), None)
if not target:
return False
ids_to_delete: set[str] = set()
def collect_descendants(parent_id: str):
ids_to_delete.add(parent_id)
children = [d for d in documents if d.parent_id == parent_id]
for child in children:
collect_descendants(child.id)
collect_descendants(document_id)
documents = [d for d in documents if d.id not in ids_to_delete]
self._save_documents(project_id, documents, user_id)
return True
def get_children(
self, project_id: str, parent_id: str, user_id: str
) -> List[DocumentNode]:
"""Получить прямых детей узла"""
documents = self._load_documents(project_id, user_id)
children = [d for d in documents if d.parent_id == parent_id]
children.sort(key=lambda x: x.order)
return children
def initialize_test_document(
self, project_id: str, user_id: str
) -> List[DocumentNode]:
"""
Создаёт тестовую иерархию документов при первом запуске,
если файл документов пуст.
"""
documents = self._load_documents(project_id, user_id)
if documents:
return documents
book = DocumentNode(
project_id=project_id,
title="Книга 1",
type="book",
order=0,
)
chapter = DocumentNode(
project_id=project_id,
parent_id=book.id,
title="Глава 1",
type="chapter",
order=0,
)
scene = DocumentNode(
project_id=project_id,
parent_id=chapter.id,
title="Сцена 1",
content="Это тестовый документ для разработки Writer IDE. Здесь будет располагаться контент ваших сценариев.",
type="scene",
order=0,
)
result = [book, chapter, scene]
self._save_documents(project_id, result, user_id)
print(f"Created test hierarchical documents for project: {project_id}")
return result
# Singleton instance
document_service = DocumentService()
+134
View File
@@ -0,0 +1,134 @@
import json
import re
from typing import Any, Dict, List, Optional
from models.bible import StoryBible
from services.llm_router import llm_router
class EntityExtractor:
def __init__(self):
self.llm_router = llm_router
def _build_extraction_prompt(
self, text: str, existing_bible: Optional[StoryBible]
) -> str:
"""Формирует промпт для извлечения сущностей"""
existing_chars = ""
existing_locs = ""
existing_events = ""
if existing_bible:
if existing_bible.characters:
existing_chars = "Известные персонажи: " + ", ".join(
[c.name for c in existing_bible.characters]
)
if existing_bible.locations:
existing_locs = "Известные локации: " + ", ".join(
[l.name for l in existing_bible.locations]
)
if existing_bible.events:
existing_events = "Известные события: " + ", ".join(
[e.title for e in existing_bible.events]
)
prompt = f"""Ты — эксперт по анализу художественных текстов. Твоя задача — извлечь из текста новых персонажей, локации и события.
ВАЖНО: Отвечай СТРОГО НА РУССКОМ ЯЗЫКЕ. Все имена, описания и названия должны быть на русском языке.
{existing_chars}
{existing_locs}
{existing_events}
ТЕКСТ ДЛЯ АНАЛИЗА:
---
{text}
---
ИНСТРУКЦИИ:
1. Извлеки ТОЛЬКО НОВЫЕ сущности, которых еще нет в списке известных
2. Для персонажей укажи: name, description (краткое), traits (список черт), appearance (если описана), role (protagonist/antagonist/supporting/minor)
3. Для локаций укажи: name, description, atmosphere (если есть), type (city/building/nature/interior/other)
4. Для событий укажи: title, description, importance (major/minor/background)
5. Если обнаружены новые черты для УЖЕ ИЗВЕСТНЫХ персонажей, укажи их в updated_traits
6. НЕ выдумывай информацию, которой нет в тексте
7. Если новых сущностей нет, верни пустые списки
8. Все ответы СТРОГО НА РУССКОМ ЯЗЫКЕ
ОТВЕТЬ СТРОГО В ФОРМАТЕ JSON (без markdown, без пояснений, только чистый JSON):
{{
"new_characters": [
{{"name": "...", "description": "...", "traits": ["...", "..."], "appearance": "...", "role": "..."}}
],
"new_locations": [
{{"name": "...", "description": "...", "atmosphere": "...", "type": "..."}}
],
"new_events": [
{{"title": "...", "description": "...", "importance": "..."}}
],
"updated_traits": [
{{"character_name": "...", "new_traits": ["...", "..."]}}
]
}}"""
return prompt
def _parse_json_response(self, response_text: str) -> Dict[str, Any]:
"""Парсит JSON из ответа модели, обрабатывая возможные markdown обертки"""
# Убираем возможные markdown обертки
cleaned = response_text.strip()
# Убираем ```json и ```
if cleaned.startswith("```"):
cleaned = re.sub(r"^```(?:json)?\s*", "", cleaned)
cleaned = re.sub(r"\s*```$", "", cleaned)
# Пытаемся найти JSON в тексте
json_match = re.search(r"\{[\s\S]*\}", cleaned)
if json_match:
cleaned = json_match.group(0)
try:
return json.loads(cleaned)
except json.JSONDecodeError as e:
raise ValueError(
f"Failed to parse JSON from LLM response: {e}\n"
f"Raw response: {response_text[:500]}"
)
async def extract_entities(
self,
text: str,
existing_bible: Optional[StoryBible] = None,
provider: str = "openrouter",
model: Optional[str] = None,
) -> Dict[str, Any]:
"""Извлекает сущности из текста"""
prompt = self._build_extraction_prompt(text, existing_bible)
# Используем пресет editor для точности (низкая температура)
generated_text = await self.llm_router.generate(
prompt=prompt,
provider=provider,
model=model,
preset="editor",
)
parsed = self._parse_json_response(generated_text)
# Валидация и нормализация ответа
result = {
"new_characters": parsed.get("new_characters", []),
"new_locations": parsed.get("new_locations", []),
"new_events": parsed.get("new_events", []),
"updated_traits": parsed.get("updated_traits", []),
}
return result
# Singleton instance
entity_extractor = EntityExtractor()
+239
View File
@@ -0,0 +1,239 @@
"""
Сервис экспорта документов в различные форматы (EPUB, FB2, PDF, DOCX) через Pandoc.
"""
import os
import shutil
import subprocess
import tempfile
from typing import List
from fastapi import HTTPException
from models.document import DocumentNode
from services.document_service import document_service
from services.project_service import project_service
class ExportService:
def __init__(self):
# Проверка наличия pandoc при инициализации
try:
subprocess.run(["pandoc", "--version"], capture_output=True, check=True)
self.available = True
except FileNotFoundError:
print("WARNING: Pandoc not found. Export functionality will be limited.")
self.available = False
def _get_document_and_children(
self, documents: List[DocumentNode], target_id: str
) -> List[DocumentNode]:
"""Рекурсивно получает документ и всех его потомков, отсортированных по order."""
result: List[DocumentNode] = []
doc_map = {d.id: d for d in documents}
def collect_children(parent_id: str):
children = [d for d in documents if d.parent_id == parent_id]
children.sort(key=lambda x: x.order)
for child in children:
result.append(child)
collect_children(child.id)
if target_id in doc_map:
result.append(doc_map[target_id])
collect_children(target_id)
return result
def _build_book_html(
self,
project_id: str,
user_id: str,
document_id: str | None = None,
base_path: str = "", # Абсолютный путь для относительных ресурсов
) -> str:
"""Собирает HTML-контент книги или конкретного документа с изображениями."""
documents = document_service.get_all_documents(project_id, user_id)
project_dir = project_service.get_project_dir(project_id, user_id)
images_dir = project_dir / "images"
# Проверяем наличие обложки
cover_path = images_dir / "cover.png"
has_cover = cover_path.exists()
if document_id:
target_docs = self._get_document_and_children(documents, document_id)
else:
# Экспортируем всё: берем корневые документы (книги) и всех потомков
tree = document_service.get_documents_tree(project_id, user_id)
target_docs = self._flatten_tree(tree)
# Собираем все PNG-файлы изображений
all_images = []
if images_dir.exists():
all_images = [
f.name
for f in images_dir.iterdir()
if f.is_file() and f.suffix.lower() == ".png"
]
# Определяем ID всех глав для поиска привязанных изображений
chapter_ids = {d.id for d in documents if d.type == "chapter"}
# "Бесхозные" изображения — не cover.png и не {chapter_id}.png
# (старые файлы с UUID, сгенерированные до введения конвенции)
orphan_images = [
img
for img in all_images
if img != "cover.png" and img.removesuffix(".png") not in chapter_ids
]
orphan_index = (
0 # Индекс для последовательного использования orphan-изображений
)
# Формируем префикс пути: абсолютный путь если задан, иначе относительный
img_prefix = f"{base_path}/images" if base_path else "images"
html_parts: List[str] = []
# Добавляем обложку, если есть и это полная книга (не один документ)
if has_cover and not document_id:
html_parts.append(
'<div style="text-align: center; page-break-after: always;">'
)
html_parts.append(
f'<img src="{img_prefix}/cover.png" alt="Cover" style="max-width: 100%; height: auto;" />'
)
html_parts.append("</div>")
html_parts.append("<br/><br/>")
for doc in target_docs:
if doc.type == "book":
html_parts.append(f"<h1>{doc.title}</h1>")
elif doc.type == "chapter":
# Ищем изображение главы: сначала по конвенции {chapter_id}.png
chapter_image_path = images_dir / f"{doc.id}.png"
has_chapter_image = chapter_image_path.exists()
chapter_image_filename = f"{doc.id}.png" if has_chapter_image else None
# Fallback: если нет файла по конвенции, берём "бесхозное" изображение
if not has_chapter_image and orphan_index < len(orphan_images):
chapter_image_filename = orphan_images[orphan_index]
orphan_index += 1
html_parts.append(f"<h2>{doc.title}</h2>")
# Вставляем изображение главы, если есть
if chapter_image_filename:
html_parts.append(
'<div style="text-align: center; margin: 20px 0;">'
)
html_parts.append(
f'<img src="{img_prefix}/{chapter_image_filename}" alt="{doc.title}" style="max-width: 100%; height: auto;" />'
)
html_parts.append("</div>")
elif doc.type == "scene" and doc.content:
html_parts.append(f"<h3>{doc.title}</h3>")
html_parts.append(doc.content)
html_parts.append("<br/><br/>")
return "\n".join(html_parts)
def _flatten_tree(self, tree: List[dict]) -> List[DocumentNode]:
"""Разворачивает дерево документов в плоский список в порядке обхода."""
result: List[DocumentNode] = []
for node in tree:
result.append(DocumentNode(**node))
for child in node.get("children", []):
result.extend(self._flatten_tree([child]))
return result
def export_document(
self, project_id: str, user_id: str, document_id: str | None, format: str
) -> bytes:
"""Экспортирует документ (или всю книгу) в указанный формат."""
if not self.available:
raise HTTPException(
status_code=503,
detail="Pandoc is not installed. Export functionality is unavailable.",
)
valid_formats = ["epub", "fb2", "pdf", "docx"]
if format not in valid_formats:
raise HTTPException(
status_code=400,
detail=f"Unsupported format. Choose from {valid_formats}",
)
# Получаем проект для метаданных
project = project_service.get_project(project_id)
title = project.title if project else "Без названия"
# Собираем HTML
html_content = self._build_book_html(project_id, user_id, document_id)
# Получаем путь к папке проекта для копирования изображений
project_dir = project_service.get_project_dir(project_id, user_id)
images_dir = project_dir / "images"
# Создаём временную директорию
temp_dir = tempfile.mkdtemp()
temp_html = os.path.join(temp_dir, "content.html")
output_path = os.path.join(temp_dir, f"output.{format}")
# Копируем папку images во временную директорию для Pandoc
if images_dir.exists():
shutil.copytree(images_dir, os.path.join(temp_dir, "images"))
try:
# Записываем HTML
html_content = self._build_book_html(
project_id, user_id, document_id, base_path=temp_dir
)
with open(temp_html, "w", encoding="utf-8") as f:
f.write(html_content)
# Формируем команду pandoc
cmd = [
"pandoc",
temp_html,
"-o",
output_path,
"--from",
"html",
"--to",
format,
]
# Метаданные для EPUB/FB2
if format in ["epub", "fb2"]:
cmd.extend(["--metadata", f"title={title}"])
cmd.extend(["--metadata", "author=Author"])
# Указываем путь к ресурсам (изображениям)
cmd.extend(["--resource-path", temp_dir])
# PDF через WeasyPrint (не требует LaTeX)
if format == "pdf":
cmd.extend(["--pdf-engine=weasyprint"])
# Выполняем
subprocess.run(cmd, check=True, capture_output=True, text=True)
# Читаем результат
with open(output_path, "rb") as f:
file_content = f.read()
return file_content
except subprocess.CalledProcessError as e:
raise HTTPException(status_code=500, detail=f"Pandoc error: {e.stderr}")
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
finally:
# Очистка временных файлов
if os.path.exists(temp_dir):
shutil.rmtree(temp_dir)
# Singleton instance
export_service = ExportService()
+203
View File
@@ -0,0 +1,203 @@
import base64
import logging
import re
from pathlib import Path
from typing import Optional
import httpx
from config.settings import settings
from fastapi import HTTPException
from services.project_service import project_service
logger = logging.getLogger(__name__)
class ImageGenerator:
def __init__(self):
self.api_key = settings.OPENROUTER_API_KEY
self.base_url = getattr(
settings, "OPENROUTER_BASE_URL", "https://openrouter.ai/api/v1"
)
self.default_model = "qwen/qwen-image-2.1-turbo"
def _get_images_dir(self, project_id: str, user_id: str) -> Path:
"""Получить или создать директорию для изображений проекта"""
project_dir = project_service.get_project_dir(project_id, user_id)
images_dir = project_dir / "images"
images_dir.mkdir(exist_ok=True)
return images_dir
async def generate_image(
self,
prompt: str,
project_id: str,
user_id: str,
document_id: str,
document_type: str, # 'book' или 'chapter'
model: Optional[str] = None,
size: str = "1024x1024",
) -> dict:
"""
Генерирует изображение по промпту и сохраняет его в папку проекта.
Имя файла определяется по конвенции:
- document_type='book' -> cover.png (обложка книги)
- document_type='chapter' -> {document_id}.png (иллюстрация главы)
Возвращает информацию о сохранённом файле.
"""
if not self.api_key or self.api_key == "your_api_key_here":
raise ValueError("OpenRouter API key not configured")
model = model or self.default_model
# Генерация изображений использует OpenAI Images API endpoint,
# а не Chat Completions. См. официальную документацию модели.
payload = {
"model": model,
"prompt": prompt,
"n": 1,
"response_format": "b64_json",
}
# Мапим size в aspect_ratio для моделей, которые его поддерживают
aspect_ratio_map = {
"1024x1024": "1:1",
"1024x1792": "9:16",
"1792x1024": "16:9",
"512x512": "1:1",
}
if size in aspect_ratio_map:
payload["size"] = size
headers = {
"Authorization": f"Bearer {self.api_key}",
"Content-Type": "application/json",
"HTTP-Referer": "http://localhost:3000",
"X-Title": "Writer IDE",
}
try:
async with httpx.AsyncClient(timeout=120.0) as client:
response = await client.post(
f"{self.base_url}/images/generations",
json=payload,
headers=headers,
)
response.raise_for_status()
data = response.json()
# Проверяем наличие ошибки в ответе (некоторые провайдеры
# возвращают 200 с полем error вместо HTTP error status)
if "error" in data:
error_info = data["error"]
error_msg = (
error_info.get("message", str(error_info))
if isinstance(error_info, dict)
else str(error_info)
)
raise ValueError(f"API returned error: {error_msg}")
# OpenAI Images API формат: {"data": [{"b64_json": "..."}]}
images_data = data.get("data", [])
if not images_data:
raise ValueError(
f"No image data in response. Keys: {list(data.keys())}"
)
img_data = images_data[0]
image_url = None
image_base64 = None
if isinstance(img_data, dict):
image_base64 = img_data.get("b64_json")
image_url = img_data.get("url")
elif isinstance(img_data, str):
# Некоторые провайдеры могут вернуть URL напрямую в data
image_url = img_data if img_data.startswith("http") else None
# Фоллбэк: пробуем извлечь из text content (если провайдер
# использует chat/completions формат поверх images/generations)
if not image_url and not image_base64:
content = (
data.get("choices", [{}])[0].get("message", {}).get("content", "")
)
if content:
base64_match = re.search(
r"data:image/[^;]+;base64,([A-Za-z0-9+/=]+)", content
)
if base64_match:
image_base64 = base64_match.group(1)
if not image_url and not image_base64:
logger.warning(
"Could not extract image. Response keys: %s",
list(data.keys()),
)
raise ValueError(
f"Could not extract image from response. Available keys: {list(data.keys())}"
)
# Сохраняем изображение
images_dir = self._get_images_dir(project_id, user_id)
# Определяем имя файла по конвенции
if document_type == "book":
filename = "cover.png" # Обложка книги
else: # chapter
filename = f"{document_id}.png" # Изображение главы
filepath = images_dir / filename
if image_url:
async with httpx.AsyncClient(timeout=60.0) as client:
img_response = await client.get(image_url)
img_response.raise_for_status()
image_bytes = img_response.content
filepath.write_bytes(image_bytes)
data_url = f"data:image/png;base64,{base64.b64encode(image_bytes).decode('ascii')}"
elif image_base64:
image_bytes = base64.b64decode(image_base64)
filepath.write_bytes(image_bytes)
# Определяем MIME тип по первым байтам изображения
mime = "image/png" # default
if image_bytes[:2] == b"\xff\xd8":
mime = "image/jpeg"
elif image_bytes[:8] == b"\x89PNG\r\n\x1a\n":
mime = "image/png"
elif image_bytes[:6] in (b"GIF87a", b"GIF89a"):
mime = "image/gif"
elif image_bytes[:4] == b"RIFF" and image_bytes[8:12] == b"WEBP":
mime = "image/webp"
data_url = f"data:{mime};base64,{image_base64}"
else:
# Должно быть обработано выше, но на всякий случай
raise ValueError("No image data available to encode")
return {
"image_id": document_id,
"filename": filename,
"path": f"/api/images/{project_id}/{filename}",
"prompt": prompt,
"model": model,
"data_url": data_url,
"document_id": document_id,
"document_type": document_type,
}
except httpx.HTTPStatusError as e:
raise HTTPException(
status_code=502,
detail=f"Image generation API error: {e.response.status_code} - {e.response.text[:200]}",
)
except httpx.TimeoutException:
raise HTTPException(status_code=504, detail="Image generation timed out")
except HTTPException:
raise
except Exception as e:
raise HTTPException(
status_code=500, detail=f"Image generation failed: {str(e)}"
)
# Singleton
image_generator = ImageGenerator()
+126
View File
@@ -0,0 +1,126 @@
from typing import Any, Dict, Optional
import httpx
from config.settings import settings
from services.model_config import model_config
class LLMRouter:
def __init__(self):
self.ollama_base_url = settings.OLLAMA_BASE_URL
self.openrouter_base_url = settings.OPENROUTER_BASE_URL
async def generate_ollama(
self,
prompt: str,
model: Optional[str] = None,
preset: str = "creative_draft",
options: Optional[Dict[str, Any]] = None,
) -> str:
"""Генерация через локальную Ollama с пресетами"""
from services.presets import get_preset
# Используем модель из конфига, если не указана
model = model or model_config.get_default_model("ollama")
# Валидация модели
if not model_config.validate_model("ollama", model):
raise ValueError(f"Model {model} not available for Ollama")
# Получаем базовые параметры из пресета
preset_options = get_preset(preset, "ollama")
# Объединяем с пользовательскими опциями (пользовательские переопределяют пресет)
merged_options = {**preset_options, **(options or {})}
async with httpx.AsyncClient() as client:
payload = {
"model": model,
"prompt": prompt,
"stream": False,
"options": merged_options,
}
response = await client.post(
f"{self.ollama_base_url}/api/generate", json=payload, timeout=60.0
)
response.raise_for_status()
return response.json()["response"]
async def generate_openrouter(
self,
prompt: str,
model: Optional[str] = None,
api_key: Optional[str] = None,
preset: str = "creative_draft",
options: Optional[Dict[str, Any]] = None,
) -> str:
"""Генерация через OpenRouter API с пресетами"""
from services.presets import get_preset
# Используем ключ из settings, если не передан явно
actual_api_key = api_key or settings.OPENROUTER_API_KEY
# Валидация API ключа
if not actual_api_key or actual_api_key == "your_api_key_here":
raise ValueError(
"OpenRouter API key not configured. "
"Please set OPENROUTER_API_KEY in backend/.env file"
)
# Используем модель из конфига, если не указана
model = model or model_config.get_default_model("openrouter")
# Валидация модели
if not model_config.validate_model("openrouter", model):
raise ValueError(f"Model {model} not available for OpenRouter")
# Получаем базовые параметры из пресета
preset_options = get_preset(preset, "openrouter")
# Объединяем с пользовательскими опциями
merged_options = {**preset_options, **(options or {})}
async with httpx.AsyncClient() as client:
payload = {
"model": model,
"messages": [{"role": "user", "content": prompt}],
}
# Добавляем объединённые опции (включая frequency_penalty, presence_penalty и temperature)
if merged_options:
payload.update(merged_options)
headers = {
"Authorization": f"Bearer {actual_api_key}",
"Content-Type": "application/json",
"HTTP-Referer": "http://localhost:3000", # Для OpenRouter
"X-Title": "Writer IDE", # Для OpenRouter
}
response = await client.post(
f"{self.openrouter_base_url}/chat/completions",
json=payload,
headers=headers,
timeout=60.0,
)
response.raise_for_status()
return response.json()["choices"][0]["message"]["content"]
async def generate(
self,
prompt: str,
provider: str = "ollama",
model: Optional[str] = None,
api_key: Optional[str] = None,
preset: str = "creative_draft",
options: Optional[Dict[str, Any]] = None,
) -> str:
"""Универсальный метод генерации"""
if provider == "ollama":
return await self.generate_ollama(prompt, model, preset, options)
elif provider == "openrouter":
return await self.generate_openrouter(
prompt, model, api_key, preset, options
)
else:
raise ValueError(f"Unknown provider: {provider}")
# Singleton instance
llm_router = LLMRouter()
+47
View File
@@ -0,0 +1,47 @@
import json
from pathlib import Path
from typing import Any, Dict, List, Optional
class ModelConfig:
"""Класс для работы с конфигурацией моделей AI провайдеров"""
def __init__(self, config_path: str = "config/models.json"):
self.config_path = Path(config_path)
self.config = self._load_config()
def _load_config(self) -> Dict[str, Any]:
"""Загрузить конфигурацию из JSON файла"""
if not self.config_path.exists():
raise FileNotFoundError(f"Config file not found: {self.config_path}")
with open(self.config_path, "r", encoding="utf-8") as f:
return json.load(f)
def get_available_models(self, provider: str) -> List[Dict[str, Any]]:
"""Получить список доступных моделей для провайдера"""
if provider not in self.config:
raise ValueError(f"Unknown provider: {provider}")
return self.config[provider]["available_models"]
def get_default_model(self, provider: str) -> str:
"""Получить модель по умолчанию для провайдера"""
if provider not in self.config:
raise ValueError(f"Unknown provider: {provider}")
return self.config[provider]["default_model"]
def get_model_info(self, provider: str, model_id: str) -> Optional[Dict[str, Any]]:
"""Получить информацию о конкретной модели"""
models = self.get_available_models(provider)
for model in models:
if model["id"] == model_id:
return model
return None
def validate_model(self, provider: str, model_id: str) -> bool:
"""Проверить, существует ли модель для провайдера"""
return self.get_model_info(provider, model_id) is not None
# Singleton instance
model_config = ModelConfig()
+85
View File
@@ -0,0 +1,85 @@
from typing import Any, Literal
PresetName = Literal["creative_draft", "editor", "brainstorm"]
# Пресеты для Ollama (параметры, которые понимает Ollama API)
OLLAMA_PRESETS: dict[str, dict[str, Any]] = {
"creative_draft": {
"temperature": 0.85,
"top_p": 0.92,
"top_k": 50,
"repeat_penalty": 1.15,
},
"editor": {
"temperature": 0.3,
"top_p": 0.85,
"top_k": 40,
"repeat_penalty": 1.0,
},
"brainstorm": {
"temperature": 1.0,
"top_p": 0.95,
"top_k": 80,
"repeat_penalty": 1.2,
},
}
# Пресеты для OpenRouter (параметры OpenAI-совместимого API)
OPENROUTER_PRESETS: dict[str, dict[str, Any]] = {
"creative_draft": {
"temperature": 0.85,
"top_p": 0.92,
"frequency_penalty": 0.3,
"presence_penalty": 0.4,
},
"editor": {
"temperature": 0.3,
"top_p": 0.85,
"frequency_penalty": 0.0,
"presence_penalty": 0.0,
},
"brainstorm": {
"temperature": 1.0,
"top_p": 0.95,
"frequency_penalty": 0.5,
"presence_penalty": 0.6,
},
}
def get_preset(preset_name: str, provider: str) -> dict[str, Any]:
"""Получить параметры пресета для указанного провайдера.
Args:
preset_name: Название пресета (creative_draft, editor, brainstorm)
provider: Провайдер ("ollama" или "openrouter")
Returns:
Параметры пресета как словарь
Raises:
ValueError: Если провайдер неизвестен или пресет не найден
"""
if provider == "ollama":
return OLLAMA_PRESETS.get(preset_name, OLLAMA_PRESETS["creative_draft"])
elif provider == "openrouter":
return OPENROUTER_PRESETS.get(preset_name, OPENROUTER_PRESETS["creative_draft"])
else:
raise ValueError(f"Unknown provider: {provider}")
def list_presets(provider: str | None = None) -> dict[str, dict[str, dict[str, Any]]]:
"""Вернуть все доступные пресеты.
Args:
provider: Опционально фильтровать по провайдеру
Returns:
Словарь всех доступных пресетов
"""
if provider == "ollama":
return OLLAMA_PRESETS
elif provider == "openrouter":
return OPENROUTER_PRESETS
else:
return {"ollama": OLLAMA_PRESETS, "openrouter": OPENROUTER_PRESETS}
+196
View File
@@ -0,0 +1,196 @@
import json
import shutil
from datetime import datetime
from pathlib import Path
from config.settings import settings
from models.bible import StoryBible
from models.project import Project
from models.user import User
from services.auth_service import auth_service
class ProjectService:
def __init__(self):
self.data_dir = Path(settings.DATA_DIR)
self.projects_dir = self.data_dir / "projects"
self.projects_dir.mkdir(parents=True, exist_ok=True)
self.projects_file = self.data_dir / "projects.json"
self.projects = self._load_projects()
# Миграция: перенос старых проектов в структуру с пользователями
self._migrate_old_data()
def _load_projects(self) -> list[Project]:
"""Загрузить список проектов"""
if self.projects_file.exists():
with open(self.projects_file, "r", encoding="utf-8") as f:
data = json.load(f)
return [Project(**p) for p in data]
return []
def _save_projects(self):
"""Сохранить список проектов"""
with open(self.projects_file, "w", encoding="utf-8") as f:
json.dump(
[p.model_dump() for p in self.projects],
f,
ensure_ascii=False,
indent=2,
)
def _migrate_old_data(self):
"""Миграция старых проектов в структуру data/users/{user_id}/projects/"""
old_projects_dir = self.data_dir / "projects"
users_dir = self.data_dir / "users"
users_file = self.data_dir / "users.json"
default_user_id = "default-user-0000-0000-0000-000000000000"
# Если есть старые проекты, но нет пользователей — создаём дефолтного и перемещаем
if old_projects_dir.exists() and not users_dir.exists():
print("Migrating old projects to a default user...")
default_user = User(
id=default_user_id,
username="admin",
email="admin@local",
hashed_password=auth_service.get_password_hash("admin"),
)
auth_service.users.append(default_user)
auth_service._save_users()
# Перемещаем папку проектов
new_user_dir = users_dir / default_user.id
new_user_dir.mkdir(parents=True, exist_ok=True)
old_projects_dir.rename(new_user_dir / "projects")
# Обновляем user_id у существующих проектов
for i, project in enumerate(self.projects):
self.projects[i].user_id = default_user.id
self._save_projects()
print("Migration complete. Default user: admin / admin")
# Если проекты уже перемещены, но users.json отсутствует — восстанавливаем
if users_dir.exists() and not users_file.exists():
has_projects_without_user = any(
p.user_id == "" or p.user_id == default_user_id for p in self.projects
)
if has_projects_without_user or any(users_dir.iterdir()):
print("Restoring default user (users.json was missing)...")
default_user = User(
id=default_user_id,
username="admin",
email="admin@local",
hashed_password=auth_service.get_password_hash("admin"),
)
auth_service.users.append(default_user)
auth_service._save_users()
# Обновляем user_id у проектов без привязки
for i, project in enumerate(self.projects):
if not project.user_id:
self.projects[i].user_id = default_user.id
self._save_projects()
print("Default user restored: admin / admin")
# Если папка projects уже переехала, обновляем ссылку
if users_dir.exists():
new_projects_dir = self.data_dir / "users"
# Проверяем что old_projects_dir больше не существует (уже перемещён)
if not old_projects_dir.exists():
self.projects_dir = new_projects_dir
def get_project_dir(self, project_id: str, user_id: str) -> Path:
"""Получить директорию проекта с учётом пользователя"""
# Определяем базу: если есть users/{user_id}, используем новую структуру
user_projects_base = self.data_dir / "users" / user_id / "projects"
if user_projects_base.exists():
project_dir = user_projects_base / project_id
else:
# Fallback для обратной совместимости
project_dir = self.data_dir / "projects" / project_id
if not project_dir.exists():
raise ValueError(f"Project directory not found: {project_id}")
return project_dir
def get_all_projects(self, user_id: str | None = None) -> list[Project]:
"""Получить все проекты (опционально фильтруя по пользователю)"""
if user_id:
return [p for p in self.projects if p.user_id == user_id]
return self.projects
def get_project(self, project_id: str) -> Project | None:
"""Получить проект по ID"""
for project in self.projects:
if project.id == project_id:
return project
return None
def create_project(
self, title: str, user_id: str, description: str = ""
) -> Project:
"""Создать новый проект"""
project = Project(title=title, user_id=user_id, description=description)
self.projects.append(project)
self._save_projects()
# Создаем директорию проекта в папке пользователя
project_dir = self.data_dir / "users" / user_id / "projects" / project.id
project_dir.mkdir(parents=True, exist_ok=True)
# Инициализируем пустые файлы
(project_dir / "documents.json").write_text("[]", encoding="utf-8")
# Создаем пустую Story Bible
empty_bible = StoryBible()
(project_dir / "story_bible.json").write_text(
json.dumps(empty_bible.model_dump(), ensure_ascii=False, indent=2),
encoding="utf-8",
)
return project
def update_project(
self,
project_id: str,
title: str | None = None,
description: str | None = None,
) -> Project | None:
"""Обновить проект"""
for i, project in enumerate(self.projects):
if project.id == project_id:
if title is not None:
self.projects[i].title = title
if description is not None:
self.projects[i].description = description
self.projects[i].updated_at = datetime.now().isoformat()
self._save_projects()
return self.projects[i]
return None
def delete_project(self, project_id: str, user_id: str) -> bool:
"""Удалить проект и все его данные"""
for i, project in enumerate(self.projects):
if project.id == project_id:
# Проверяем принадлежность
if project.user_id != user_id:
raise ValueError("Project does not belong to this user")
self.projects.pop(i)
self._save_projects()
# Удаляем директорию проекта
project_dir = (
self.data_dir / "users" / user_id / "projects" / project_id
)
if project_dir.exists():
shutil.rmtree(project_dir)
return True
return False
# Singleton instance
project_service = ProjectService()
+20
View File
@@ -0,0 +1,20 @@
#!/bin/bash
# Скрипт для сборки и тестирования Docker-образа backend локально
echo "🔨 Building backend Docker image..."
docker build -t writer-ide-backend:local .
if [ $? -ne 0 ]; then
echo "❌ Build failed!"
exit 1
fi
echo "✅ Build successful!"
echo ""
echo "🔍 Checking installed tools..."
docker run --rm writer-ide-backend:local pandoc --version
docker run --rm writer-ide-backend:local python -c "import weasyprint; print('WeasyPrint version:', weasyprint.__version__)"
echo ""
echo "🚀 To run the container:"
echo "docker run -p 8000:8000 -v /path/to/data:/app/data --env-file .env.docker writer-ide-backend:local"
+64
View File
@@ -0,0 +1,64 @@
version: '3.8'
services:
# Backend (FastAPI)
backend:
build:
context: ./backend
dockerfile: Dockerfile
container_name: writer-ide-backend
restart: unless-stopped
ports:
- "8000:8000"
env_file:
- .env
environment:
- DATA_DIR=/app/data
- OLLAMA_BASE_URL=${OLLAMA_BASE_URL:-http://host.docker.internal:11434}
- ENVIRONMENT=production
volumes:
# Монтируем HDD с данными в контейнер
- ${DATA_VOLUME_PATH:-/mnt/data/writer-ide}:/app/data
# Монтируем .env файл (опционально, если нужен hot-reload)
- ./backend/.env:/app/.env:ro
extra_hosts:
# Для доступа к Ollama на хосте (Linux)
- "host.docker.internal:host-gateway"
networks:
- writer-ide-network
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8000/api/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
# Frontend (Next.js)
frontend:
build:
context: ./frontend
dockerfile: Dockerfile
container_name: writer-ide-frontend
restart: unless-stopped
ports:
- "3000:3000"
environment:
- NEXT_PUBLIC_API_URL=${NEXT_PUBLIC_API_URL:-http://localhost:8000/api}
- NODE_ENV=production
depends_on:
backend:
condition: service_healthy
networks:
- writer-ide-network
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:3000"]
interval: 30s
timeout: 10s
retries: 3
start_period: 60s
networks:
writer-ide-network:
driver: bridge
# Volumes не определяем явно, так как используем bind mount с HDD
+38
View File
@@ -0,0 +1,38 @@
# Node modules (устанавливаются в Docker)
node_modules/
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Next.js build output (собирается в Docker)
.next/
out/
# Git
.git
.gitignore
# IDE
.vscode/
.idea/
*.swp
*.swo
# Docker
Dockerfile
docker-compose*.yml
.dockerignore
# Документация
*.md
!README.md
# Тесты
tests/
.pytest_cache/
.coverage
# Прочее
.env*
!.env.example
*.log
+41
View File
@@ -0,0 +1,41 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
+9
View File
@@ -0,0 +1,9 @@
<!-- BEGIN:nextjs-agent-rules -->
## This is NOT the Next.js you know
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices.
This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean.
<!-- END:nextjs-agent-rules -->
+55
View File
@@ -0,0 +1,55 @@
# Этап 1: Установка зависимостей
FROM node:20-alpine AS deps
WORKDIR /app
# Копируем package.json и package-lock.json
COPY package.json package-lock.json* ./
# Устанавливаем зависимости
RUN npm ci --only=production
# Этап 2: Сборка приложения
FROM node:20-alpine AS builder
WORKDIR /app
# Копируем зависимости из предыдущего этапа
COPY --from=deps /app/node_modules ./node_modules
COPY . .
# Переменные окружения для сборки (если нужны)
ENV NEXT_TELEMETRY_DISABLED=1
# Сборка Next.js
RUN npm run build
# Этап 3: Production runtime
FROM node:20-alpine AS runner
WORKDIR /app
# Переменные окружения
ENV NODE_ENV=production \
NEXT_TELEMETRY_DISABLED=1 \
PORT=3000 \
HOSTNAME="0.0.0.0"
# Создаём группу и пользователя для безопасности
RUN addgroup --system --gid 1001 nodejs \
&& adduser --system --uid 1001 nextjs
# Копируем собранные файлы из builder
# Standalone output содержит только необходимое для запуска
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
COPY --from=builder /app/public ./public
# Устанавливаем владельца файлов
RUN chown -R nextjs:nodejs /app
# Переключаемся на непривилегированного пользователя
USER nextjs
# Открываем порт
EXPOSE 3000
# Команда запуска
CMD ["node", "server.js"]
+36
View File
@@ -0,0 +1,36 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
## Getting Started
First, run the development server:
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
## Learn More
To learn more about Next.js, take a look at the following resources:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
## Deploy on Vercel
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
+18
View File
@@ -0,0 +1,18 @@
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";
const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
// Override default ignores of eslint-config-next.
globalIgnores([
// Default ignores of eslint-config-next:
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
]),
]);
export default eslintConfig;
+7
View File
@@ -0,0 +1,7 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
output: 'standalone', // Критично для Docker!
reactStrictMode: true,
}
module.exports = nextConfig
+17
View File
@@ -0,0 +1,17 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
cacheComponents: true,
partialPrefetching: true,
turbopack: {
rules: {
"*.css": {
loaders: ["@tailwindcss/turbopack"],
as: "*.css",
},
},
},
};
export default nextConfig;
+7619
View File
File diff suppressed because it is too large. Load diff
+38
View File
@@ -0,0 +1,38 @@
{
"name": "frontend",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint"
},
"dependencies": {
"@tailwindcss/typography": "^0.5.20",
"@tiptap/extension-blockquote": "^3.32.2",
"@tiptap/extension-bubble-menu": "^3.32.2",
"@tiptap/extension-color": "^3.32.2",
"@tiptap/extension-font-family": "^3.32.2",
"@tiptap/extension-text-style": "^3.32.2",
"@tiptap/extension-underline": "^3.32.2",
"@tiptap/react": "^3.32.2",
"@tiptap/starter-kit": "^3.32.2",
"axios": "^1.21.0",
"lucide-react": "^1.67.0",
"next": "16.4.4",
"react": "19.3.0",
"react-dom": "19.3.0",
"zustand": "^5.0.16"
},
"devDependencies": {
"@tailwindcss/turbopack": "^4",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "16.4.4",
"tailwindcss": "^4",
"typescript": "^5"
}
}
+1
View File
@@ -0,0 +1 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 391 B

+1
View File
@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

+1
View File
@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 128 B

+1
View File
@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>

After

Width:  |  Height:  |  Size: 385 B

+5
View File
@@ -0,0 +1,5 @@
import DiffTest from '@/components/test/DiffTest';
export default function DiffTestPage() {
return <DiffTest />;
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

+27
View File
@@ -0,0 +1,27 @@
@import "tailwindcss";
@plugin "@tailwindcss/typography";
:root {
--background: #ffffff;
--foreground: #171717;
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
}
@media (prefers-color-scheme: dark) {
:root {
--background: #0a0a0a;
--foreground: #ededed;
}
}
body {
background: var(--background);
color: var(--foreground);
font-family: Arial, Helvetica, sans-serif;
}
+29
View File
@@ -0,0 +1,29 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
};
export default function RootLayout({ children }: LayoutProps<"/">) {
return (
<html
lang="en"
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
>
<body className="min-h-full flex flex-col">{children}</body>
</html>
);
}
+42
View File
@@ -0,0 +1,42 @@
'use client';
import { useAppStore } from '@/stores/appStore';
import AuthScreen from '@/components/auth/AuthScreen';
import LeftPanel from '@/components/sidebar/LeftPanel';
import StoryBiblePanel from '@/components/sidebar/StoryBiblePanel';
import { Editor } from '@/components/editor/Editor';
import ModelSettingsPanel from '@/components/settings/ModelSettings';
export default function Home() {
const { isAuthenticated, isLoadingAuth } = useAppStore();
if (isLoadingAuth) {
return null; // AuthScreen показывает спиннер при checkAuth
}
if (!isAuthenticated) {
return <AuthScreen />;
}
return (
<div className="flex h-screen bg-gray-900 overflow-hidden">
{/* Left panel - Projects + Document Tree */}
<div className="w-80 border-r border-gray-700 flex-shrink-0">
<LeftPanel />
</div>
{/* Middle panel - Story Bible */}
<div className="w-80 border-r border-gray-700 flex-shrink-0">
<StoryBiblePanel />
</div>
{/* Main content - Editor */}
<div className="flex-1 flex flex-col min-w-0">
<Editor />
</div>
{/* Model settings button */}
<ModelSettingsPanel />
</div>
);
}
+11
View File
@@ -0,0 +1,11 @@
import AITest from '@/components/test/AITest';
export default function TestPage() {
return (
<div className="min-h-screen bg-gray-900 p-8">
<div className="max-w-2xl mx-auto">
<AITest />
</div>
</div>
);
}
+135
View File
@@ -0,0 +1,135 @@
'use client';
import { useState } from 'react';
import { useAppStore } from '@/stores/appStore';
import { BookOpen, Loader2 } from 'lucide-react';
export default function AuthScreen() {
const { login, register, isLoadingAuth } = useAppStore();
const [isLogin, setIsLogin] = useState(true);
const [username, setUsername] = useState('');
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
setIsSubmitting(true);
try {
if (isLogin) {
await login(username, password);
} else {
await register(username, email, password);
}
} catch (err: any) {
setError(err.message || 'Ошибка авторизации');
} finally {
setIsSubmitting(false);
}
};
if (isLoadingAuth) {
return (
<div className="flex h-screen items-center justify-center bg-gray-900 text-gray-100">
<Loader2 className="w-8 h-8 animate-spin text-blue-500" />
</div>
);
}
return (
<div className="flex h-screen items-center justify-center bg-gray-900 p-4">
<div className="w-full max-w-md bg-gray-800 rounded-xl shadow-2xl border border-gray-700 overflow-hidden">
<div className="p-8 text-center border-b border-gray-700">
<BookOpen className="w-12 h-12 text-blue-500 mx-auto mb-4" />
<h1 className="text-2xl font-bold text-gray-100">Writer IDE</h1>
<p className="text-gray-400 mt-2">Ваше пространство для творчества</p>
</div>
<div className="p-8">
<div className="flex mb-6 bg-gray-700 rounded-lg p-1">
<button
onClick={() => { setIsLogin(true); setError(''); }}
className={`flex-1 py-2 rounded-md text-sm font-medium transition-colors ${
isLogin ? 'bg-blue-600 text-white' : 'text-gray-400 hover:text-gray-200'
}`}
>
Вход
</button>
<button
onClick={() => { setIsLogin(false); setError(''); }}
className={`flex-1 py-2 rounded-md text-sm font-medium transition-colors ${
!isLogin ? 'bg-blue-600 text-white' : 'text-gray-400 hover:text-gray-200'
}`}
>
Регистрация
</button>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-300 mb-1">Имя пользователя</label>
<input
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
className="w-full p-2.5 bg-gray-700 border border-gray-600 rounded-lg text-gray-100 focus:ring-2 focus:ring-blue-500 focus:border-transparent outline-none"
required
/>
</div>
{!isLogin && (
<div>
<label className="block text-sm font-medium text-gray-300 mb-1">Email</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="w-full p-2.5 bg-gray-700 border border-gray-600 rounded-lg text-gray-100 focus:ring-2 focus:ring-blue-500 focus:border-transparent outline-none"
required
/>
</div>
)}
<div>
<label className="block text-sm font-medium text-gray-300 mb-1">Пароль</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full p-2.5 bg-gray-700 border border-gray-600 rounded-lg text-gray-100 focus:ring-2 focus:ring-blue-500 focus:border-transparent outline-none"
required
/>
</div>
{error && (
<div className="p-3 bg-red-900/30 border border-red-800 rounded-lg text-red-300 text-sm">
{error}
</div>
)}
<button
type="submit"
disabled={isSubmitting}
className="w-full py-2.5 bg-blue-600 hover:bg-blue-700 text-white font-medium rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
>
{isSubmitting ? <Loader2 className="w-4 h-4 animate-spin" /> : null}
{isLogin ? 'Войти' : 'Создать аккаунт'}
</button>
</form>
<p className="text-center text-xs text-gray-500 mt-6">
{isLogin ? 'Нет аккаунта? ' : 'Уже есть аккаунт? '}
<button
onClick={() => { setIsLogin(!isLogin); setError(''); }}
className="text-blue-400 hover:text-blue-300"
>
{isLogin ? 'Зарегистрироваться' : 'Войти'}
</button>
</p>
</div>
</div>
</div>
);
}
@@ -0,0 +1,71 @@
'use client';
import { Check, X } from 'lucide-react';
interface AIDiffViewProps {
originalText: string;
aiText: string;
onAccept: () => void;
onReject: () => void;
}
export default function AIDiffView({ originalText, aiText, onAccept, onReject }: AIDiffViewProps) {
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-gray-800 rounded-lg shadow-2xl max-w-5xl w-full max-h-[90vh] overflow-hidden flex flex-col">
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-gray-700">
<h2 className="text-xl font-bold text-gray-100">Предпросмотр изменений</h2>
<div className="flex gap-2">
<button
onClick={onAccept}
className="flex items-center gap-2 px-4 py-2 bg-green-600 hover:bg-green-700 text-white rounded-lg transition-colors"
>
<Check className="w-4 h-4" />
Принять
</button>
<button
onClick={onReject}
className="flex items-center gap-2 px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-lg transition-colors"
>
<X className="w-4 h-4" />
Отклонить
</button>
</div>
</div>
{/* Content */}
<div className="flex-1 overflow-auto p-6 grid grid-cols-2 gap-6">
{/* Original */}
<div className="flex flex-col">
<div className="flex items-center gap-2 mb-3">
<div className="w-3 h-3 rounded-full bg-red-500" />
<h3 className="text-sm font-semibold text-gray-300 uppercase">Оригинал</h3>
</div>
<div className="flex-1 p-4 bg-red-900/20 border border-red-800/30 rounded-lg text-gray-100 whitespace-pre-wrap leading-relaxed">
{originalText}
</div>
</div>
{/* AI Suggestion */}
<div className="flex flex-col">
<div className="flex items-center gap-2 mb-3">
<div className="w-3 h-3 rounded-full bg-green-500" />
<h3 className="text-sm font-semibold text-gray-300 uppercase">Предложение AI</h3>
</div>
<div className="flex-1 p-4 bg-green-900/20 border border-green-800/30 rounded-lg text-gray-100 whitespace-pre-wrap leading-relaxed">
{aiText}
</div>
</div>
</div>
{/* Footer hint */}
<div className="p-4 border-t border-gray-700 bg-gray-850">
<p className="text-sm text-gray-400 text-center">
Нажмите "Принять" для замены выделенного текста или "Отклонить" для отмены
</p>
</div>
</div>
</div>
);
}
+116
View File
@@ -0,0 +1,116 @@
'use client';
import { useState } from 'react';
import { Editor } from '@tiptap/react';
import { BubbleMenu } from '@tiptap/react/menus';
import { Sparkles, RefreshCw, Expand, Minimize, MessageSquare, PlusCircle } from 'lucide-react';
interface AIMenuProps {
editor: Editor | null;
onAction: (action: string, selectedText: string) => void;
}
type AIAction = 'rewrite' | 'expand' | 'simplify' | 'custom' | 'add_to_bible';
export default function AIMenu({ editor, onAction }: AIMenuProps) {
const [showDropdown, setShowDropdown] = useState(false);
if (!editor) return null;
const handleAction = (action: AIAction) => {
const { from, to } = editor.state.selection;
const selectedText = editor.state.doc.textBetween(from, to, ' ');
if (!selectedText.trim()) {
return;
}
onAction(action, selectedText);
setShowDropdown(false);
};
return (
<BubbleMenu
editor={editor}
shouldShow={({ editor, from, to }) => {
return from !== to && !editor.isActive('image');
}}
>
<div className="relative inline-block">
{/* Кнопка вызова AI меню */}
<button
onClick={() => setShowDropdown(!showDropdown)}
className="flex items-center gap-2 px-3 py-2 bg-purple-600 hover:bg-purple-700 text-white rounded-lg shadow-lg transition-colors text-sm font-medium"
title="AI действия"
>
<Sparkles className="w-4 h-4" />
AI
</button>
{/* Dropdown с действиями */}
{showDropdown && (
<div className="absolute top-full left-0 mt-2 w-56 bg-gray-800 border border-gray-700 rounded-lg shadow-xl z-50">
<div className="p-2 space-y-1">
<button
onClick={() => handleAction('rewrite')}
className="w-full flex items-center gap-3 px-3 py-2 text-left text-gray-200 hover:bg-gray-700 rounded-md transition-colors"
>
<RefreshCw className="w-4 h-4 text-blue-400" />
<div>
<div className="text-sm font-medium">Переписать</div>
<div className="text-xs text-gray-400">Сделать более живым</div>
</div>
</button>
<button
onClick={() => handleAction('expand')}
className="w-full flex items-center gap-3 px-3 py-2 text-left text-gray-200 hover:bg-gray-700 rounded-md transition-colors"
>
<Expand className="w-4 h-4 text-green-400" />
<div>
<div className="text-sm font-medium">Расширить</div>
<div className="text-xs text-gray-400">Добавить деталей</div>
</div>
</button>
<button
onClick={() => handleAction('simplify')}
className="w-full flex items-center gap-3 px-3 py-2 text-left text-gray-200 hover:bg-gray-700 rounded-md transition-colors"
>
<Minimize className="w-4 h-4 text-yellow-400" />
<div>
<div className="text-sm font-medium">Упростить</div>
<div className="text-xs text-gray-400">Сделать короче</div>
</div>
</button>
<div className="border-t border-gray-700 my-1" />
<button
onClick={() => handleAction('custom')}
className="w-full flex items-center gap-3 px-3 py-2 text-left text-gray-200 hover:bg-gray-700 rounded-md transition-colors"
>
<MessageSquare className="w-4 h-4 text-purple-400" />
<div>
<div className="text-sm font-medium">Свой промпт...</div>
<div className="text-xs text-gray-400">Указать задачу</div>
</div>
</button>
<button
onClick={() => handleAction('add_to_bible')}
className="w-full flex items-center gap-3 px-3 py-2 text-left text-gray-200 hover:bg-gray-700 rounded-md transition-colors"
>
<PlusCircle className="w-4 h-4 text-pink-400" />
<div>
<div className="text-sm font-medium">Добавить в Story Bible</div>
<div className="text-xs text-gray-400">Извлечь сущности</div>
</div>
</button>
</div>
</div>
)}
</div>
</BubbleMenu>
);
}
@@ -0,0 +1,237 @@
'use client';
import { useState } from 'react';
import { X, User, MapPin, Calendar, Check } from 'lucide-react';
interface ExtractedEntity {
name: string;
description: string;
traits?: string[];
appearance?: string;
role?: string;
atmosphere?: string;
type?: string;
importance?: string;
title?: string;
}
interface AddToBibleModalProps {
extractedEntities: {
new_characters: ExtractedEntity[];
new_locations: ExtractedEntity[];
new_events: ExtractedEntity[];
};
onConfirm: (selected: {
characters: ExtractedEntity[];
locations: ExtractedEntity[];
events: ExtractedEntity[];
}) => void;
onClose: () => void;
}
export default function AddToBibleModal({ extractedEntities, onConfirm, onClose }: AddToBibleModalProps) {
const [selectedChars, setSelectedChars] = useState<Set<number>>(
new Set(extractedEntities.new_characters.map((_, i) => i))
);
const [selectedLocs, setSelectedLocs] = useState<Set<number>>(
new Set(extractedEntities.new_locations.map((_, i) => i))
);
const [selectedEvents, setSelectedEvents] = useState<Set<number>>(
new Set(extractedEntities.new_events.map((_, i) => i))
);
const handleConfirm = () => {
onConfirm({
characters: extractedEntities.new_characters.filter((_, i) => selectedChars.has(i)),
locations: extractedEntities.new_locations.filter((_, i) => selectedLocs.has(i)),
events: extractedEntities.new_events.filter((_, i) => selectedEvents.has(i)),
});
};
const totalSelected = selectedChars.size + selectedLocs.size + selectedEvents.size;
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-gray-800 rounded-lg shadow-2xl max-w-2xl w-full max-h-[90vh] overflow-hidden flex flex-col">
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-gray-700">
<h2 className="text-xl font-bold text-gray-100">Добавить в Story Bible</h2>
<button onClick={onClose} className="text-gray-400 hover:text-gray-200">
<X className="w-5 h-5" />
</button>
</div>
{/* Content */}
<div className="flex-1 overflow-auto p-6 space-y-6">
{/* Characters */}
{extractedEntities.new_characters.length > 0 && (
<div>
<h3 className="text-lg font-semibold text-gray-100 flex items-center gap-2 mb-3">
<User className="w-5 h-5 text-blue-400" />
Персонажи ({extractedEntities.new_characters.length})
</h3>
<div className="space-y-2">
{extractedEntities.new_characters.map((char, idx) => (
<label
key={idx}
className={`flex items-start gap-3 p-3 rounded-lg border cursor-pointer transition-colors ${
selectedChars.has(idx)
? 'bg-blue-900/20 border-blue-600'
: 'bg-gray-700/30 border-gray-600 hover:bg-gray-700/50'
}`}
>
<input
type="checkbox"
checked={selectedChars.has(idx)}
onChange={(e) => {
const newSet = new Set(selectedChars);
if (e.target.checked) {
newSet.add(idx);
} else {
newSet.delete(idx);
}
setSelectedChars(newSet);
}}
className="mt-1"
/>
<div className="flex-1">
<div className="font-semibold text-gray-100">{char.name}</div>
<div className="text-sm text-gray-400 mt-1">{char.description}</div>
{char.traits && char.traits.length > 0 && (
<div className="flex flex-wrap gap-1 mt-2">
{char.traits.map((trait, i) => (
<span key={i} className="px-2 py-0.5 bg-blue-900/30 text-blue-300 text-xs rounded">
{trait}
</span>
))}
</div>
)}
</div>
</label>
))}
</div>
</div>
)}
{/* Locations */}
{extractedEntities.new_locations.length > 0 && (
<div>
<h3 className="text-lg font-semibold text-gray-100 flex items-center gap-2 mb-3">
<MapPin className="w-5 h-5 text-green-400" />
Локации ({extractedEntities.new_locations.length})
</h3>
<div className="space-y-2">
{extractedEntities.new_locations.map((loc, idx) => (
<label
key={idx}
className={`flex items-start gap-3 p-3 rounded-lg border cursor-pointer transition-colors ${
selectedLocs.has(idx)
? 'bg-green-900/20 border-green-600'
: 'bg-gray-700/30 border-gray-600 hover:bg-gray-700/50'
}`}
>
<input
type="checkbox"
checked={selectedLocs.has(idx)}
onChange={(e) => {
const newSet = new Set(selectedLocs);
if (e.target.checked) {
newSet.add(idx);
} else {
newSet.delete(idx);
}
setSelectedLocs(newSet);
}}
className="mt-1"
/>
<div className="flex-1">
<div className="font-semibold text-gray-100">{loc.name}</div>
<div className="text-sm text-gray-400 mt-1">{loc.description}</div>
{loc.atmosphere && (
<div className="text-xs text-gray-500 mt-2 italic">{loc.atmosphere}</div>
)}
</div>
</label>
))}
</div>
</div>
)}
{/* Events */}
{extractedEntities.new_events.length > 0 && (
<div>
<h3 className="text-lg font-semibold text-gray-100 flex items-center gap-2 mb-3">
<Calendar className="w-5 h-5 text-purple-400" />
События ({extractedEntities.new_events.length})
</h3>
<div className="space-y-2">
{extractedEntities.new_events.map((event, idx) => (
<label
key={idx}
className={`flex items-start gap-3 p-3 rounded-lg border cursor-pointer transition-colors ${
selectedEvents.has(idx)
? 'bg-purple-900/20 border-purple-600'
: 'bg-gray-700/30 border-gray-600 hover:bg-gray-700/50'
}`}
>
<input
type="checkbox"
checked={selectedEvents.has(idx)}
onChange={(e) => {
const newSet = new Set(selectedEvents);
if (e.target.checked) {
newSet.add(idx);
} else {
newSet.delete(idx);
}
setSelectedEvents(newSet);
}}
className="mt-1"
/>
<div className="flex-1">
<div className="font-semibold text-gray-100">{event.name || event.title}</div>
<div className="text-sm text-gray-400 mt-1">{event.description}</div>
</div>
</label>
))}
</div>
</div>
)}
{/* Empty state */}
{extractedEntities.new_characters.length === 0 &&
extractedEntities.new_locations.length === 0 &&
extractedEntities.new_events.length === 0 && (
<div className="text-center text-gray-400 py-8">
<p>Новых сущностей не обнаружено</p>
<p className="text-sm mt-1">Попробуйте выделить другой текст</p>
</div>
)}
</div>
{/* Footer */}
<div className="p-4 border-t border-gray-700 flex items-center justify-between">
<p className="text-sm text-gray-400">
Выбрано: {totalSelected} {totalSelected === 1 ? 'сущность' : totalSelected <= 4 ? 'сущности' : 'сущностей'}
</p>
<div className="flex gap-2">
<button
onClick={onClose}
className="px-4 py-2 bg-gray-700 hover:bg-gray-600 text-gray-200 rounded-lg"
>
Отмена
</button>
<button
onClick={handleConfirm}
disabled={totalSelected === 0}
className="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg disabled:bg-gray-600 disabled:cursor-not-allowed flex items-center gap-2"
>
<Check className="w-4 h-4" />
Добавить
</button>
</div>
</div>
</div>
</div>
);
}
@@ -0,0 +1,56 @@
'use client';
import { useEditor } from '@tiptap/react';
import { BubbleMenu } from '@tiptap/extension-bubble-menu';
import { Editor } from '@tiptap/react';
import { ReactNode, useEffect, useRef } from 'react';
interface BubbleMenuWrapperProps {
editor: Editor | null;
children: ReactNode;
shouldShow?: (props: { editor: Editor; from: number; to: number }) => boolean;
tippyOptions?: {
duration?: number;
moveTransition?: string;
};
}
export function BubbleMenuWrapper({
editor,
children,
shouldShow,
tippyOptions,
}: BubbleMenuWrapperProps) {
const bubbleMenuRef = useRef<HTMLDivElement>(null);
// Создаем BubbleMenu extension
const bubbleMenuExtension = BubbleMenu.configure({
shouldShow: shouldShow || ({ from, to }) => from !== to,
tippyOptions: {
duration: 100,
moveTransition: 'transform 0.15s ease-out',
...tippyOptions,
},
});
// Добавляем extension к редактору (если еще не добавлен)
useEffect(() => {
if (!editor) return;
const hasBubbleMenu = editor.options.extensions.some(
(ext: { name: string }) => ext.name === 'bubbleMenu'
);
if (!hasBubbleMenu) {
editor.extendGlobalExtension?.(bubbleMenuExtension);
}
}, [editor, bubbleMenuExtension]);
if (!editor) return null;
return (
<div ref={bubbleMenuRef}>
{children}
</div>
);
}
+480
View File
@@ -0,0 +1,480 @@
'use client';
import { useEditor, EditorContent } from '@tiptap/react';
import type { Editor as TiptapEditor } from '@tiptap/core';
import { Toolbar } from './Toolbar';
import AIMenu from './AIMenu';
import AddToBibleModal from './AddToBibleModal';
import StarterKit from '@tiptap/starter-kit';
import { TextStyle } from '@tiptap/extension-text-style';
import { Color } from '@tiptap/extension-color';
// StarterKit уже включает underline и blockquote, поэтому не добавляем их отдельно
import { FontFamily } from '@tiptap/extension-font-family';
import { FontSize } from '@/lib/tiptap-extensions/FontSize';
import { useEffect, useRef, useCallback, useState } from 'react';
import { useAppStore } from '@/stores/appStore';
import { generateText, autoUpdateBible, extractEntities } from '@/lib/ai-api';
import AIDiffView from './AIDiffView';
import { Loader2, AlertCircle } from 'lucide-react';
// Расширяем Window для глобального callback
declare global {
interface Window {
saveCurrentEditorContent?: () => Promise<void>;
}
}
interface ExtractedEntity {
name: string;
description: string;
traits?: string[];
appearance?: string;
role?: string;
atmosphere?: string;
type?: string;
importance?: string;
title?: string;
}
interface ExtractedEntitiesData {
new_characters: ExtractedEntity[];
new_locations: ExtractedEntity[];
new_events: ExtractedEntity[];
}
export function Editor() {
const {
documents,
activeDocumentId,
activeProjectId,
updateDocumentContent,
loadStoryBible,
autoUpdateBible: autoUpdateEnabled,
isSaving
} = useAppStore();
// Находим активный документ
const activeDocument = documents.find(d => d.id === activeDocumentId);
const debounceTimerRef = useRef<NodeJS.Timeout | null>(null);
const bibleUpdateTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const editorRef = useRef<TiptapEditor | null>(null);
// AI workflow state
const [selectedText, setSelectedText] = useState('');
const [aiGeneratedText, setAiGeneratedText] = useState('');
const [showDiffView, setShowDiffView] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [selectionRange, setSelectionRange] = useState<{ from: number; to: number } | null>(null);
// State для модального окна добавления в Story Bible
const [showAddToBibleModal, setShowAddToBibleModal] = useState(false);
const [extractedEntities, setExtractedEntities] = useState<{
new_characters: ExtractedEntity[];
new_locations: ExtractedEntity[];
new_events: ExtractedEntity[];
} | null>(null);
// Функция для извлечения чистого текста из HTML
const getPlainText = (html: string): string => {
const div = document.createElement('div');
div.innerHTML = html;
return div.textContent || div.innerText || '';
};
// Функция для debounced обновления контента с сохранением на backend
const debouncedUpdate = useCallback(
(html: string) => {
if (!activeProjectId || !activeDocumentId) return;
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current);
}
debounceTimerRef.current = setTimeout(async () => {
await updateDocumentContent(activeProjectId, activeDocumentId, html);
// Автообновление Story Bible (с задержкой после сохранения)
if (autoUpdateEnabled) {
if (bibleUpdateTimeoutRef.current) {
clearTimeout(bibleUpdateTimeoutRef.current);
}
bibleUpdateTimeoutRef.current = setTimeout(async () => {
try {
const plainText = getPlainText(html);
if (plainText.trim().length > 50) {
const result = await autoUpdateBible(
activeProjectId,
plainText,
autoUpdateEnabled
);
if (result.updated) {
console.log('Story Bible updated:', result.message);
// Перезагружаем Story Bible в store
loadStoryBible(activeProjectId);
}
}
} catch (error) {
console.error('Auto-update Bible error:', error);
}
}, 3000); // Задержка 3 секунды после сохранения
}
}, 1000); // Задержка 1 секунда для сохранения
},
[activeProjectId, activeDocumentId, updateDocumentContent, autoUpdateEnabled, loadStoryBible]
);
// Функция для немедленного сохранения текущего контента
const saveCurrentContent = useCallback(async (ed: TiptapEditor | null) => {
const currentEditor = ed || editorRef.current;
if (currentEditor && activeProjectId && activeDocumentId) {
const currentContent = currentEditor.getHTML();
// Очищаем таймер debounce
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current);
debounceTimerRef.current = null;
}
// Немедленно сохраняем
try {
await updateDocumentContent(activeProjectId, activeDocumentId, currentContent);
console.log('[Editor] Content saved before switching:', currentContent.substring(0, 100));
} catch (error) {
console.error('[Editor] Failed to save before switching:', error);
}
}
}, [activeProjectId, activeDocumentId, updateDocumentContent]);
// Очистка таймеров при размонтировании
useEffect(() => {
return () => {
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current);
}
if (bibleUpdateTimeoutRef.current) {
clearTimeout(bibleUpdateTimeoutRef.current);
}
};
}, []);
// Обработчик действий AI
const handleAIAction = async (action: string, text: string) => {
if (!editor) return;
const { from, to } = editor.state.selection;
setSelectedText(text);
setSelectionRange({ from, to });
// Специальная обработка для add_to_bible
if (action === 'add_to_bible') {
setIsLoading(true);
setError(null);
try {
const result = await extractEntities(text);
setExtractedEntities(result.entities);
setShowAddToBibleModal(true);
} catch (err) {
setError(err instanceof Error ? err.message : 'Ошибка при извлечении сущностей');
console.error('Extract entities error:', err);
} finally {
setIsLoading(false);
}
return;
}
setIsLoading(true);
setError(null);
try {
let prompt = '';
switch (action) {
case 'rewrite':
prompt = `Перепиши следующий текст, сделай его более живым, образным и кинематографичным. Сохрани основной смысл, но улучши стиль:\n\n${text}`;
break;
case 'expand':
prompt = `Расширь следующий текст, добавь больше деталей, описаний, сенсорных деталей (запахи, звуки, ощущения). Увеличь объем примерно в 1.5 раза:\n\n${text}`;
break;
case 'simplify':
prompt = `Упрости следующий текст, сделай его более кратким и понятным. Убери лишние слова и сложные конструкции, сохрани основной смысл:\n\n${text}`;
break;
case 'custom':
prompt = `Улучши следующий текст:\n\n${text}`;
break;
default:
prompt = `Улучши следующий текст:\n\n${text}`;
}
const generatedText = await generateText({
prompt,
preset: 'creative_draft',
taskType: 'generation',
selected_text: text,
});
setAiGeneratedText(generatedText);
setShowDiffView(true);
} catch (err) {
setError(err instanceof Error ? err.message : 'Произошла ошибка при генерации');
console.error('AI generation error:', err);
} finally {
setIsLoading(false);
}
};
const editor = useEditor({
extensions: [
StarterKit, // включает underline и blockquote
TextStyle,
Color,
FontFamily,
FontSize,
],
content: activeDocument?.content || '',
onUpdate: ({ editor: ed }) => {
// Sync editor ref
editorRef.current = ed;
if (activeProjectId && activeDocumentId) {
const content = ed.getHTML();
console.log('[Editor] onUpdate - content length:', content.length);
console.log('[Editor] onUpdate - content preview:', content.substring(0, 150));
debouncedUpdate(content);
}
},
editorProps: {
attributes: {
class: 'prose prose-invert max-w-none focus:outline-none font-serif text-lg leading-relaxed',
},
},
immediatelyRender: false,
onCreate: ({ editor: ed }) => {
editorRef.current = ed;
},
});
// Регистрируем глобальный callback для сохранения
useEffect(() => {
if (editor) {
editorRef.current = editor;
// Создаём обёртку без параметров для глобального доступа
window.saveCurrentEditorContent = () => saveCurrentContent(editor);
return () => {
delete window.saveCurrentEditorContent;
};
}
}, [editor, saveCurrentContent]);
// Синхронизация редактора при смене активного документа
useEffect(() => {
if (!editor || !activeDocument) return;
console.log('=== Loading document ===');
console.log('[Editor] Document ID:', activeDocument.id);
console.log('[Editor] Document title:', activeDocument.title);
console.log('[Editor] Content length:', activeDocument.content?.length || 0);
console.log('[Editor] Content preview:', activeDocument.content?.substring(0, 200));
// Проверяем, что контент действительно изменился
const currentContent = editor.getHTML();
const newContent = activeDocument.content || '';
// Загружаем только если контент действительно другой
if (currentContent !== newContent) {
console.log('[Editor] Content differs, loading new content');
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(editor.commands as any).setContent(newContent, false, { preserveWhitespace: 'full' });
} else {
console.log('[Editor] Content is the same, skipping reload');
}
}, [activeDocumentId, editor, activeDocument]);
// Если нет активного документа или это не сцена
if (!activeDocument || activeDocument.type !== 'scene') {
return (
<div className="flex-1 flex items-center justify-center text-gray-400">
<div className="text-center">
<p className="text-lg">Выберите сцену для редактирования</p>
<p className="text-sm mt-1">Кликните на сцену в дереве документов</p>
</div>
</div>
);
}
// Обработчики для AIDiffView
const handleAcceptChanges = () => {
if (editor && selectionRange && aiGeneratedText) {
editor
.chain()
.focus()
.setTextSelection({ from: selectionRange.from, to: selectionRange.to })
.deleteRange({ from: selectionRange.from, to: selectionRange.to })
.insertContent(aiGeneratedText)
.run();
}
setShowDiffView(false);
setAiGeneratedText('');
setSelectedText('');
setSelectionRange(null);
};
const handleRejectChanges = () => {
setShowDiffView(false);
setAiGeneratedText('');
setSelectedText('');
setSelectionRange(null);
};
// Обработчик подтверждения добавления в Story Bible
const handleAddToBibleConfirm = async (selected: {
characters: ExtractedEntity[];
locations: ExtractedEntity[];
events: ExtractedEntity[];
}) => {
// Получаем данные из store
const state = useAppStore.getState();
const { activeProjectId, storyBible, updateStoryBible } = state;
if (!activeProjectId || !storyBible) return;
try {
// Создаем копию текущей Bible
const updatedBible = JSON.parse(JSON.stringify(storyBible));
// Добавляем выбранные персонажи
selected.characters.forEach(char => {
updatedBible.characters.push({
id: crypto.randomUUID(),
name: char.name,
description: char.description || '',
traits: char.traits || [],
appearance: char.appearance || '',
role: char.role || 'supporting'
});
});
// Добавляем выбранные локации
selected.locations.forEach(loc => {
updatedBible.locations.push({
id: crypto.randomUUID(),
name: loc.name,
description: loc.description || '',
atmosphere: loc.atmosphere || '',
type: loc.type || 'other'
});
});
// Добавляем выбранные события
selected.events.forEach(event => {
updatedBible.events.push({
id: crypto.randomUUID(),
title: event.title || event.name || '',
description: event.description || '',
importance: event.importance || 'minor'
});
});
// Сохраняем обновленную Bible через API
await updateStoryBible(activeProjectId, updatedBible);
// Закрываем модальное окно
setShowAddToBibleModal(false);
setExtractedEntities(null);
// Логируем результат
const totalAdded = selected.characters.length + selected.locations.length + selected.events.length;
console.log(`Добавлено в Story Bible: ${totalAdded} сущностей`);
} catch (error) {
console.error('Failed to add to Bible:', error);
setError('Ошибка при добавлении в Story Bible');
}
};
// Проверка на наличие активного проекта
if (!activeProjectId) {
return (
<div className="flex-1 flex items-center justify-center text-gray-400">
<div className="text-center">
<p className="text-lg">Выберите проект</p>
<p className="text-sm mt-1">Или создайте новый в левой панели</p>
</div>
</div>
);
}
// Проверка на наличие активного документа
if (!activeDocument) {
return (
<div className="flex-1 flex items-center justify-center text-gray-400">
<div className="text-center">
<p className="text-lg">Нет документов в проекте</p>
<p className="text-sm mt-1">Создайте документ через Story Bible или добавьте его позже</p>
</div>
</div>
);
}
return (
<div className="flex flex-col h-full overflow-hidden">
{/* Индикатор сохранения */}
<div className="flex justify-end px-4 py-2 bg-gray-800 border-b border-gray-700 shrink-0">
<span className={`text-xs ${isSaving ? 'text-yellow-400' : 'text-green-400'}`}>
{isSaving ? 'Сохранение...' : 'Сохранено'}
</span>
</div>
<Toolbar editor={editor} />
{/* Скроллируемая область редактора */}
<div className="flex-1 overflow-auto">
<div className="prose prose-invert max-w-3xl mx-auto p-8">
<EditorContent editor={editor} />
</div>
</div>
<AIMenu editor={editor} onAction={handleAIAction} />
{/* Diff view для предпросмотра AI изменений */}
{showDiffView && (
<AIDiffView
originalText={selectedText}
aiText={aiGeneratedText}
onAccept={handleAcceptChanges}
onReject={handleRejectChanges}
/>
)}
{/* Модальное окно добавления в Story Bible */}
{showAddToBibleModal && extractedEntities && (
<AddToBibleModal
extractedEntities={extractedEntities}
onConfirm={handleAddToBibleConfirm}
onClose={() => {
setShowAddToBibleModal(false);
setExtractedEntities(null);
}}
/>
)}
{/* Индикатор загрузки */}
{isLoading && (
<div className="fixed bottom-4 right-4 flex items-center gap-2 px-4 py-2 bg-purple-600 text-white rounded-lg shadow-lg z-50">
<Loader2 className="w-4 h-4 animate-spin" />
<span className="text-sm">AI генерирует...</span>
</div>
)}
{/* Индикатор ошибки */}
{error && (
<div className="fixed bottom-4 right-4 flex items-center gap-2 px-4 py-2 bg-red-600 text-white rounded-lg shadow-lg z-50">
<AlertCircle className="w-4 h-4" />
<span className="text-sm">{error}</span>
<button onClick={() => setError(null)} className="ml-2 text-white/70 hover:text-white">
✕
</button>
</div>
)}
</div>
);
}
@@ -0,0 +1,127 @@
'use client';
import { useState } from 'react';
import { X, Download, Loader2, FileText, BookOpen } from 'lucide-react';
import { exportDocument } from '@/lib/api';
interface ExportModalProps {
projectId: string;
documentId: string;
documentTitle: string;
documentType: 'book' | 'chapter';
onClose: () => void;
}
export default function ExportModal({ projectId, documentId, documentTitle, documentType, onClose }: ExportModalProps) {
const [selectedFormat, setSelectedFormat] = useState<'epub' | 'fb2' | 'pdf' | 'docx'>('epub');
const [isExporting, setIsExporting] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleExport = async () => {
setIsExporting(true);
setError(null);
try {
await exportDocument(projectId, documentId, selectedFormat);
onClose();
} catch (err) {
setError(err instanceof Error ? err.message : 'Ошибка при экспорте');
} finally {
setIsExporting(false);
}
};
const formats = [
{ id: 'epub' as const, name: 'EPUB', desc: 'Стандарт для электронных книг (Apple Books, Kindle)', icon: BookOpen },
{ id: 'fb2' as const, name: 'FB2', desc: 'Популярный формат в СНГ (FBReader, LitRes)', icon: FileText },
{ id: 'pdf' as const, name: 'PDF', desc: 'Для печати и фиксированного форматирования', icon: FileText },
{ id: 'docx' as const, name: 'DOCX', desc: 'Для передачи на редактуру (Word, LibreOffice)', icon: FileText },
];
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-gray-800 rounded-lg shadow-2xl max-w-md w-full">
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-gray-700">
<h2 className="text-xl font-bold text-gray-100">Экспорт: {documentTitle}</h2>
<button onClick={onClose} className="text-gray-400 hover:text-gray-200" disabled={isExporting}>
<X className="w-5 h-5" />
</button>
</div>
{/* Content */}
<div className="p-6 space-y-4">
<p className="text-sm text-gray-400">
Выберите формат для экспорта {documentType === 'book' ? 'всей книги' : 'этой главы и всех вложенных сцен'}.
</p>
<div className="space-y-2">
{formats.map((fmt) => {
const Icon = fmt.icon;
return (
<label
key={fmt.id}
className={`flex items-center gap-3 p-3 rounded-lg border cursor-pointer transition-colors ${
selectedFormat === fmt.id
? 'bg-blue-900/20 border-blue-600'
: 'bg-gray-700/30 border-gray-600 hover:bg-gray-700/50'
}`}
>
<input
type="radio"
name="format"
value={fmt.id}
checked={selectedFormat === fmt.id}
onChange={() => setSelectedFormat(fmt.id)}
className="text-blue-600 focus:ring-blue-500"
disabled={isExporting}
/>
<div className="flex-1">
<div className="flex items-center gap-2">
<Icon className="w-4 h-4 text-gray-300" />
<span className="font-semibold text-gray-100">{fmt.name}</span>
</div>
<p className="text-xs text-gray-400 mt-1">{fmt.desc}</p>
</div>
</label>
);
})}
</div>
{error && (
<div className="p-3 bg-red-900/20 border border-red-800 rounded-lg text-red-300 text-sm">
{error}
</div>
)}
</div>
{/* Footer */}
<div className="p-4 border-t border-gray-700 flex gap-2">
<button
onClick={onClose}
disabled={isExporting}
className="flex-1 px-4 py-2 bg-gray-700 hover:bg-gray-600 text-gray-200 rounded-lg disabled:opacity-50"
>
Отмена
</button>
<button
onClick={handleExport}
disabled={isExporting}
className="flex-1 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg disabled:bg-blue-800 disabled:cursor-not-allowed flex items-center justify-center gap-2"
>
{isExporting ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
Генерация...
</>
) : (
<>
<Download className="w-4 h-4" />
Скачать
</>
)}
</button>
</div>
</div>
</div>
);
}
@@ -0,0 +1,203 @@
'use client';
import { useState } from 'react';
import { X, Loader2, Sparkles, Download } from 'lucide-react';
import { generateImage, GenerateImageResponse } from '@/lib/api';
interface GenerateImageModalProps {
projectId: string;
documentId: string;
documentTitle: string;
documentType: 'book' | 'chapter';
onClose: () => void;
}
export default function GenerateImageModal({
projectId,
documentId,
documentTitle,
documentType,
onClose
}: GenerateImageModalProps) {
const [prompt, setPrompt] = useState('');
const [isGenerating, setIsGenerating] = useState(false);
const [generatedImage, setGeneratedImage] = useState<GenerateImageResponse | null>(null);
const [error, setError] = useState<string | null>(null);
const suggestedPrompts = documentType === 'book'
? [
'Epic fantasy book cover, magical castle in the clouds, dramatic lighting, digital art',
'Mystery novel cover, dark city street at night, fog, noir style',
'Romance book cover, couple silhouette at sunset, warm colors, artistic'
]
: [
'Detailed illustration of a medieval tavern interior, warm lighting, fantasy style',
'Character portrait, young adventurer with a sword, determined expression, digital painting',
'Landscape illustration, mysterious forest path, magical atmosphere, concept art'
];
const handleGenerate = async () => {
if (!prompt.trim()) return;
setIsGenerating(true);
setError(null);
setGeneratedImage(null);
try {
const result = await generateImage({
prompt,
project_id: projectId,
document_id: documentId,
document_type: documentType,
});
setGeneratedImage(result);
} catch (err) {
setError(err instanceof Error ? err.message : 'Ошибка при генерации изображения');
console.error('Generate image error:', err);
} finally {
setIsGenerating(false);
}
};
const handleDownload = () => {
if (!generatedImage) return;
// Скачиваем через blob из base64 данных
const byteString = atob(generatedImage.data_url.split(',')[1]);
const mimeString = generatedImage.data_url.split(',')[0].split(':')[1].split(';')[0];
const ab = new ArrayBuffer(byteString.length);
const ia = new Uint8Array(ab);
for (let i = 0; i < byteString.length; i++) {
ia[i] = byteString.charCodeAt(i);
}
const blob = new Blob([ab], { type: mimeString });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `${documentTitle.replace(/[^a-z0-9]/gi, '_')}_${generatedImage.filename}`;
document.body.appendChild(link);
link.click();
link.remove();
URL.revokeObjectURL(url);
};
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-gray-800 rounded-lg shadow-2xl max-w-3xl w-full max-h-[90vh] overflow-hidden flex flex-col">
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-gray-700">
<h2 className="text-xl font-bold text-gray-100">
Генерация изображения: {documentTitle}
</h2>
<button onClick={onClose} className="text-gray-400 hover:text-gray-200" disabled={isGenerating}>
<X className="w-5 h-5" />
</button>
</div>
{/* Content */}
<div className="flex-1 overflow-auto p-6 space-y-6">
{/* Prompt input */}
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
Описание изображения (промпт)
</label>
<textarea
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
placeholder="Опишите, что вы хотите увидеть на изображении... (лучше на английском)"
className="w-full p-3 bg-gray-700 text-gray-200 rounded-lg border border-gray-600 focus:border-blue-500 focus:outline-none min-h-[120px] resize-none"
disabled={isGenerating}
/>
<p className="text-xs text-gray-500 mt-1">
💡 Совет: Пишите промпт на английском для лучшего результата. Будьте конкретны: стиль, освещение, настроение.
</p>
</div>
{/* Suggested prompts */}
<div>
<p className="text-sm text-gray-400 mb-2">Примеры промптов:</p>
<div className="flex flex-wrap gap-2">
{suggestedPrompts.map((suggestion, idx) => (
<button
key={idx}
onClick={() => setPrompt(suggestion)}
disabled={isGenerating}
className="px-3 py-1.5 bg-gray-700 hover:bg-gray-600 text-gray-300 text-xs rounded-lg border border-gray-600 transition-colors disabled:opacity-50"
>
{suggestion.length > 50 ? suggestion.substring(0, 50) + '...' : suggestion}
</button>
))}
</div>
</div>
{/* Generate button */}
<button
onClick={handleGenerate}
disabled={isGenerating || !prompt.trim()}
className="w-full py-3 bg-gradient-to-r from-purple-600 to-blue-600 hover:from-purple-700 hover:to-blue-700 text-white font-medium rounded-lg transition-all disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
>
{isGenerating ? (
<>
<Loader2 className="w-5 h-5 animate-spin" />
Генерация изображения... (может занять 10-30 секунд)
</>
) : (
<>
<Sparkles className="w-5 h-5" />
Сгенерировать изображение
</>
)}
</button>
{/* Error message */}
{error && (
<div className="p-4 bg-red-900/20 border border-red-800 rounded-lg text-red-300">
<p className="font-medium">Ошибка генерации</p>
<p className="text-sm mt-1">{error}</p>
</div>
)}
{/* Generated image */}
{generatedImage && (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-lg font-semibold text-gray-100">Результат</h3>
<div className="flex gap-2">
<button
onClick={handleDownload}
className="flex items-center gap-2 px-3 py-1.5 bg-blue-600 hover:bg-blue-700 text-white text-sm rounded-lg transition-colors"
>
<Download className="w-4 h-4" />
Скачать
</button>
</div>
</div>
<div className="bg-gray-900 rounded-lg overflow-hidden border border-gray-700">
<img
src={generatedImage.data_url}
alt="Generated image"
className="w-full h-auto max-h-[500px] object-contain"
/>
</div>
<div className="p-3 bg-gray-700/50 rounded-lg border border-gray-600">
<p className="text-xs text-gray-400 mb-1">Промпт:</p>
<p className="text-sm text-gray-300">{generatedImage.prompt}</p>
<p className="text-xs text-gray-500 mt-2">Модель: {generatedImage.model}</p>
</div>
</div>
)}
</div>
{/* Footer */}
<div className="p-4 border-t border-gray-700 bg-gray-850">
<p className="text-xs text-gray-500 text-center">
💰 Генерация изображений использует API OpenRouter и может стоить ~$0.02-0.05 за изображение
</p>
</div>
</div>
</div>
);
}
+132
View File
@@ -0,0 +1,132 @@
'use client';
import { type Editor } from '@tiptap/react';
import {
Bold,
Italic,
Underline as UnderlineIcon,
Quote,
Eraser,
} from 'lucide-react';
interface ToolbarProps {
editor: Editor | null;
}
export function Toolbar({ editor }: ToolbarProps) {
if (!editor) {
return null;
}
const buttons = [
{
name: 'bold',
icon: Bold,
isActive: () => editor.isActive('bold'),
onClick: () => editor.chain().focus().toggleBold().run(),
},
{
name: 'italic',
icon: Italic,
isActive: () => editor.isActive('italic'),
onClick: () => editor.chain().focus().toggleItalic().run(),
},
{
name: 'underline',
icon: UnderlineIcon,
isActive: () => editor.isActive('underline'),
onClick: () => editor.chain().focus().toggleUnderline().run(),
},
{
name: 'blockquote',
icon: Quote,
isActive: () => editor.isActive('blockquote'),
onClick: () => editor.chain().focus().toggleBlockquote().run(),
},
];
return (
<div className="flex items-center gap-2 p-2 bg-gray-800 border-b border-gray-700 flex-wrap">
{/* Группировка: Bold, Italic, Underline */}
{buttons.map(({ name, icon: Icon, isActive, onClick }) => (
<button
key={name}
onClick={onClick}
className={`p-2 rounded transition-colors ${
isActive()
? 'bg-blue-600 text-white'
: 'bg-gray-700 text-gray-300 hover:bg-gray-600'
}`}
title={name.charAt(0).toUpperCase() + name.slice(1)}
>
<Icon className="w-5 h-5" />
</button>
))}
<div className="w-px h-6 bg-gray-600 mx-2" />
{/* Группировка: Blockquote, Color, Clear */}
<button
onClick={() => editor.chain().focus().toggleBlockquote().run()}
className={`p-2 rounded transition-colors ${
editor.isActive('blockquote')
? 'bg-blue-600 text-white'
: 'bg-gray-700 text-gray-300 hover:bg-gray-600'
}`}
title="Цитата"
>
<Quote className="w-5 h-5" />
</button>
{/* Цвет текста */}
<div className="relative">
<input
type="color"
onChange={(e) => editor.chain().focus().setColor(e.target.value).run()}
className="p-1 rounded w-8 h-8 cursor-pointer bg-gray-700 text-gray-300 hover:bg-gray-600"
title="Цвет текста"
/>
</div>
{/* Очистить форматирование */}
<button
onClick={() => editor.chain().focus().unsetAllMarks().run()}
className={`p-2 rounded transition-colors ${
editor.isActive('bold') || editor.isActive('italic') || editor.isActive('underline')
? 'bg-blue-600 text-white'
: 'bg-gray-700 text-gray-300 hover:bg-gray-600'
}`}
title="Очистить форматирование"
>
<Eraser className="w-5 h-5" />
</button>
<div className="w-px h-6 bg-gray-600 mx-2" />
{/* Выбор шрифта */}
<select
onChange={(e) => editor.chain().focus().setFontFamily(e.target.value).run()}
className="bg-gray-700 text-gray-200 border border-gray-600 rounded px-2 py-1 text-sm"
title="Шрифт"
>
<option value="Georgia, serif">Serif</option>
<option value="Inter, sans-serif">Sans-serif</option>
<option value="'Courier New', monospace">Monospace</option>
</select>
{/* Размер шрифта */}
<select
onChange={(e) => editor.chain().focus().setFontSize(e.target.value).run()}
className="bg-gray-700 text-gray-200 border border-gray-600 rounded px-2 py-1 text-sm"
title="Размер шрифта"
>
<option value="14px">14px</option>
<option value="16px">16px</option>
<option value="18px">18px</option>
<option value="20px">20px</option>
<option value="24px">24px</option>
<option value="28px">28px</option>
</select>
</div>
);
}
@@ -0,0 +1,203 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { useAppStore } from '@/stores/appStore';
import { getAvailableModels, ModelInfo } from '@/lib/ai-api';
import { Settings, X, Loader2 } from 'lucide-react';
export default function ModelSettingsPanel() {
const { modelSettings, updateModelSetting, autoUpdateBible, setAutoUpdateBible } = useAppStore();
const [isOpen, setIsOpen] = useState(false);
const [availableModels, setAvailableModels] = useState<{
ollama: ModelInfo[];
openrouter: ModelInfo[];
}>({ ollama: [], openrouter: [] });
const [loading, setLoading] = useState(false);
const loadModels = useCallback(async () => {
setLoading(true);
try {
const [ollamaModels, openrouterModels] = await Promise.all([
getAvailableModels('ollama'),
getAvailableModels('openrouter'),
]);
setAvailableModels({
ollama: ollamaModels.models || [],
openrouter: openrouterModels.models || [],
});
} catch (error) {
console.error('Failed to load models:', error);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
if (isOpen) {
loadModels();
}
}, [isOpen, loadModels]);
const taskLabels = {
generation: {
label: 'Генерация текста',
description: 'Основная модель для переписывания, расширения и улучшения текста',
},
background: {
label: 'Фоновая обработка',
description: 'Модель для извлечения сущностей и обновления Story Bible',
},
analysis: {
label: 'Анализ текста',
description: 'Модель для проверки логики и консистентности',
},
};
if (!isOpen) {
return (
<button
onClick={() => setIsOpen(true)}
className="fixed bottom-4 left-4 p-3 bg-gray-700 hover:bg-gray-600 text-white rounded-full shadow-lg transition-colors z-40"
title="Настройки моделей"
>
<Settings className="w-5 h-5" />
</button>
);
}
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-gray-800 rounded-lg shadow-2xl max-w-2xl w-full max-h-[90vh] overflow-auto">
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-gray-700">
<h2 className="text-xl font-bold text-gray-100">Настройки моделей AI</h2>
<button onClick={() => setIsOpen(false)} className="text-gray-400 hover:text-gray-200">
<X className="w-5 h-5" />
</button>
</div>
{/* Content */}
<div className="p-6 space-y-6">
{loading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="w-6 h-6 animate-spin text-blue-400" />
<span className="ml-2 text-gray-400">Загрузка списка моделей...</span>
</div>
) : (
<>
{(Object.keys(taskLabels) as Array<keyof typeof taskLabels>).map((taskType) => (
<div
key={taskType}
className="p-4 bg-gray-700/50 rounded-lg border border-gray-600"
>
<div className="mb-3">
<h3 className="text-lg font-semibold text-gray-100">
{taskLabels[taskType].label}
</h3>
<p className="text-sm text-gray-400 mt-1">
{taskLabels[taskType].description}
</p>
</div>
<div className="grid grid-cols-2 gap-4">
{/* Provider */}
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
Провайдер
</label>
<select
value={modelSettings[taskType].provider}
onChange={(e) => {
const provider = e.target.value as 'ollama' | 'openrouter';
const defaultModel =
availableModels[provider][0]?.id || '';
updateModelSetting(taskType, provider, defaultModel);
}}
className="w-full p-2 bg-gray-700 text-gray-200 rounded border border-gray-600"
>
<option value="ollama">Ollama (Локально)</option>
<option value="openrouter">OpenRouter (Облако)</option>
</select>
</div>
{/* Model */}
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
Модель
</label>
<select
value={modelSettings[taskType].model}
onChange={(e) =>
updateModelSetting(
taskType,
modelSettings[taskType].provider,
e.target.value
)
}
className="w-full p-2 bg-gray-700 text-gray-200 rounded border border-gray-600"
>
{availableModels[modelSettings[taskType].provider]?.length > 0 ? (
availableModels[modelSettings[taskType].provider]?.map((model) => (
<option key={model.id} value={model.id}>
{model.name}
</option>
))
) : (
<option value="">Нет доступных моделей</option>
)}
</select>
</div>
</div>
{/* Model description */}
{availableModels[modelSettings[taskType].provider]?.find(
(m) => m.id === modelSettings[taskType].model
)?.description && (
<p className="text-xs text-gray-500 mt-2 italic">
{availableModels[modelSettings[taskType].provider]?.find(
(m) => m.id === modelSettings[taskType].model
)?.description}
</p>
)}
</div>
))}
{/* Автоматическое обновление Story Bible */}
<div className="p-4 bg-gray-700/50 rounded-lg border border-gray-600">
<div className="flex items-center justify-between">
<div>
<h3 className="text-lg font-semibold text-gray-100">
Автоматическое обновление Story Bible
</h3>
<p className="text-sm text-gray-400 mt-1">
При сохранении сцены AI автоматически извлекает новых персонажей, локации и события
</p>
</div>
<button
onClick={() => setAutoUpdateBible(!autoUpdateBible)}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
autoUpdateBible ? 'bg-blue-600' : 'bg-gray-600'
}`}
>
<span
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
autoUpdateBible ? 'translate-x-6' : 'translate-x-1'
}`}
/>
</button>
</div>
</div>
</>
)}
</div>
{/* Footer */}
<div className="p-4 border-t border-gray-700 bg-gray-850">
<p className="text-sm text-gray-400 text-center">
Настройки сохраняются автоматически в localStorage браузера
</p>
</div>
</div>
</div>
);
}
@@ -0,0 +1,357 @@
'use client';
import { useState, useEffect } from 'react';
import { useAppStore, DocumentTreeNode } from '@/stores/appStore';
import { Book, FileText, ChevronRight, ChevronDown, Plus, Trash2, Edit2, Download, Image as ImageIcon } from 'lucide-react';
import ExportModal from '../editor/ExportModal';
import GenerateImageModal from '../editor/GenerateImageModal';
interface TreeNodeProps {
node: DocumentTreeNode;
level: number;
onContextMenu: (e: React.MouseEvent, node: DocumentTreeNode) => void;
}
function TreeNode({ node, level, onContextMenu }: TreeNodeProps) {
const { activeDocumentId, switchToDocument } = useAppStore();
const [isExpanded, setIsExpanded] = useState(true);
const hasChildren = node.children && node.children.length > 0;
const isActive = activeDocumentId === node.id;
const handleClick = async () => {
if (node.type === 'scene') {
// Сначала сохраняем текущий документ (через глобальный callback)
if (window.saveCurrentEditorContent) {
await window.saveCurrentEditorContent();
}
// Затем безопасно переключаем
await switchToDocument(node.id);
} else if (hasChildren) {
setIsExpanded(!isExpanded);
}
};
const getIcon = () => {
switch (node.type) {
case 'book':
return <Book className="w-4 h-4 text-blue-400" />;
case 'chapter':
return <FileText className="w-4 h-4 text-green-400" />;
case 'scene':
return <FileText className="w-4 h-4 text-gray-400" />;
default:
return <FileText className="w-4 h-4" />;
}
};
return (
<div>
<div
onClick={handleClick}
onContextMenu={(e) => onContextMenu(e, node)}
className={`flex items-center gap-2 px-2 py-1.5 cursor-pointer hover:bg-gray-700/50 rounded transition-colors ${
isActive ? 'bg-blue-900/30 border-l-2 border-blue-500' : ''
}`}
style={{ paddingLeft: `${level * 16 + 8}px` }}
>
{hasChildren ? (
isExpanded ? (
<ChevronDown className="w-4 h-4 text-gray-400" />
) : (
<ChevronRight className="w-4 h-4 text-gray-400" />
)
) : (
<div className="w-4" />
)}
{getIcon()}
<span className="text-sm text-gray-200 flex-1 truncate">{node.title}</span>
</div>
{hasChildren && isExpanded && (
<div>
{node.children.map((child) => (
<TreeNode
key={child.id}
node={child}
level={level + 1}
onContextMenu={onContextMenu}
/>
))}
</div>
)}
</div>
);
}
export default function DocumentTree() {
const { activeProjectId, documentsTree, loadDocumentsTree, createDocument, deleteDocument, updateDocument } = useAppStore();
const [contextMenu, setContextMenu] = useState<{ x: number; y: number; node: DocumentTreeNode } | null>(null);
const [showCreateModal, setShowCreateModal] = useState(false);
const [showExportModal, setShowExportModal] = useState(false);
const [showGenerateImageModal, setShowGenerateImageModal] = useState(false);
const [exportTarget, setExportTarget] = useState<{ id: string; title: string; type: 'book' | 'chapter' } | null>(null);
const [imageTarget, setImageTarget] = useState<{ id: string; title: string; type: 'book' | 'chapter' } | null>(null);
const [createParentId, setCreateParentId] = useState<string | null>(null);
const [createType, setCreateType] = useState<'book' | 'chapter' | 'scene'>('book');
const [newTitle, setNewTitle] = useState('');
useEffect(() => {
if (activeProjectId) {
loadDocumentsTree(activeProjectId);
}
}, [activeProjectId, loadDocumentsTree]);
const handleContextMenu = (e: React.MouseEvent, node: DocumentTreeNode) => {
e.preventDefault();
setContextMenu({ x: e.clientX, y: e.clientY, node });
};
const handleCloseContextMenu = () => {
setContextMenu(null);
};
const handleExport = () => {
if (!contextMenu) return;
setExportTarget({
id: contextMenu.node.id,
title: contextMenu.node.title,
type: contextMenu.node.type as 'book' | 'chapter',
});
setShowExportModal(true);
handleCloseContextMenu();
};
const handleGenerateImage = () => {
if (!contextMenu) return;
setImageTarget({
id: contextMenu.node.id,
title: contextMenu.node.title,
type: contextMenu.node.type as 'book' | 'chapter',
});
setShowGenerateImageModal(true);
handleCloseContextMenu();
};
const handleCreate = (parentType: string) => {
let type: 'book' | 'chapter' | 'scene';
if (parentType === 'root') {
type = 'book';
} else if (parentType === 'book') {
type = 'chapter';
} else {
type = 'scene';
}
setCreateType(type);
setCreateParentId(parentType === 'root' ? null : contextMenu?.node.id || null);
setShowCreateModal(true);
handleCloseContextMenu();
};
const handleCreateSubmit = async () => {
if (!newTitle.trim() || !activeProjectId) return;
await createDocument(activeProjectId, newTitle, createType, createParentId || undefined);
setShowCreateModal(false);
setNewTitle('');
};
const handleDelete = async () => {
if (!contextMenu || !activeProjectId) return;
if (confirm(`Удалить "${contextMenu.node.title}" и все вложенные элементы?`)) {
await deleteDocument(activeProjectId, contextMenu.node.id);
}
handleCloseContextMenu();
};
const handleRename = async () => {
if (!contextMenu || !activeProjectId) return;
const newName = prompt('Новое название:', contextMenu.node.title);
if (newName && newName !== contextMenu.node.title) {
await updateDocument(activeProjectId, contextMenu.node.id, { title: newName });
await loadDocumentsTree(activeProjectId);
}
handleCloseContextMenu();
};
if (!activeProjectId) {
return <div className="p-4 text-gray-400">Выберите проект</div>;
}
return (
<div className="h-full flex flex-col" onClick={handleCloseContextMenu}>
{/* Header — фиксированный */}
<div className="p-4 border-b border-gray-700 flex items-center justify-between flex-shrink-0">
<h2 className="text-lg font-bold text-gray-100">Структура книги</h2>
<button
onClick={() => handleCreate('root')}
className="p-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition-colors"
title="Создать книгу"
>
<Plus className="w-4 h-4" />
</button>
</div>
{/* Tree — скроллируемая область */}
<div className="flex-1 overflow-auto min-h-0 p-2">
{documentsTree.length === 0 ? (
<div className="text-center text-gray-400 py-8">
<Book className="w-12 h-12 mx-auto mb-2 opacity-50" />
<p>Нет книг</p>
<p className="text-sm mt-1">Создайте первую книгу</p>
</div>
) : (
<div>
{documentsTree.map((node) => (
<TreeNode
key={node.id}
node={node}
level={0}
onContextMenu={handleContextMenu}
/>
))}
</div>
)}
</div>
{/* Context Menu */}
{contextMenu && (
<div
className="fixed bg-gray-800 border border-gray-700 rounded-lg shadow-xl py-1 z-50"
style={{ left: contextMenu.x, top: contextMenu.y }}
>
{contextMenu.node.type === 'book' && (
<button
onClick={() => handleCreate('book')}
className="w-full px-4 py-2 text-left text-sm text-gray-200 hover:bg-gray-700 flex items-center gap-2"
>
<Plus className="w-4 h-4" />
Добавить главу
</button>
)}
{contextMenu.node.type === 'chapter' && (
<button
onClick={() => handleCreate('chapter')}
className="w-full px-4 py-2 text-left text-sm text-gray-200 hover:bg-gray-700 flex items-center gap-2"
>
<Plus className="w-4 h-4" />
Добавить сцену
</button>
)}
<button
onClick={handleRename}
className="w-full px-4 py-2 text-left text-sm text-gray-200 hover:bg-gray-700 flex items-center gap-2"
>
<Edit2 className="w-4 h-4" />
Переименовать
</button>
<div className="border-t border-gray-700 my-1" />
{(contextMenu.node.type === 'book' || contextMenu.node.type === 'chapter') && (
<button
onClick={handleExport}
className="w-full px-4 py-2 text-left text-sm text-gray-200 hover:bg-gray-700 flex items-center gap-2"
>
<Download className="w-4 h-4" />
Экспортировать
</button>
)}
{(contextMenu.node.type === 'book' || contextMenu.node.type === 'chapter') && (
<button
onClick={handleGenerateImage}
className="w-full px-4 py-2 text-left text-sm text-gray-200 hover:bg-gray-700 flex items-center gap-2"
>
<ImageIcon className="w-4 h-4" />
Сгенерировать изображение
</button>
)}
<button
onClick={handleDelete}
className="w-full px-4 py-2 text-left text-sm text-red-400 hover:bg-gray-700 flex items-center gap-2"
>
<Trash2 className="w-4 h-4" />
Удалить
</button>
</div>
)}
{/* Create Modal */}
{showCreateModal && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-gray-800 rounded-lg shadow-2xl max-w-md w-full">
<div className="p-4 border-b border-gray-700">
<h2 className="text-xl font-bold text-gray-100">
Создать {createType === 'book' ? 'книгу' : createType === 'chapter' ? 'главу' : 'сцену'}
</h2>
</div>
<div className="p-4">
<input
type="text"
value={newTitle}
onChange={(e) => setNewTitle(e.target.value)}
placeholder="Название..."
className="w-full p-2 bg-gray-700 text-gray-200 rounded border border-gray-600 focus:border-blue-500 focus:outline-none"
autoFocus
onKeyDown={(e) => {
if (e.key === 'Enter') handleCreateSubmit();
}}
/>
</div>
<div className="p-4 border-t border-gray-700 flex gap-2">
<button
onClick={handleCreateSubmit}
disabled={!newTitle.trim()}
className="flex-1 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg disabled:bg-gray-600 disabled:cursor-not-allowed"
>
Создать
</button>
<button
onClick={() => {
setShowCreateModal(false);
setNewTitle('');
}}
className="flex-1 px-4 py-2 bg-gray-700 hover:bg-gray-600 text-gray-200 rounded-lg"
>
Отмена
</button>
</div>
</div>
</div>
)}
{/* Export Modal */}
{showExportModal && exportTarget && activeProjectId && (
<ExportModal
projectId={activeProjectId}
documentId={exportTarget.id}
documentTitle={exportTarget.title}
documentType={exportTarget.type}
onClose={() => {
setShowExportModal(false);
setExportTarget(null);
}}
/>
)}
{/* Generate Image Modal */}
{showGenerateImageModal && imageTarget && activeProjectId && (
<GenerateImageModal
projectId={activeProjectId}
documentId={imageTarget.id}
documentTitle={imageTarget.title}
documentType={imageTarget.type}
onClose={() => {
setShowGenerateImageModal(false);
setImageTarget(null);
}}
/>
)}
</div>
);
}
@@ -0,0 +1,16 @@
export function FileExplorer() {
return (
<div className="h-full bg-gray-800 border-r border-gray-700 flex flex-col">
<div className="p-4 border-b border-gray-700">
<h2 className="text-sm font-semibold uppercase text-gray-400">
File Explorer
</h2>
</div>
<div className="flex-1 p-4">
<div className="text-gray-500 text-sm">
Дерево файлов проекта...
</div>
</div>
</div>
);
}
@@ -0,0 +1,30 @@
'use client';
import { useAppStore } from '@/stores/appStore';
import ProjectManager from './ProjectManager';
import DocumentTree from './DocumentTree';
export default function LeftPanel() {
const { activeProjectId } = useAppStore();
return (
<div className="h-full flex flex-col bg-gray-800">
{/* Верхняя часть - ProjectManager (фиксированная высота) */}
<div className="flex-shrink-0 border-b border-gray-700" style={{ maxHeight: '40%' }}>
<ProjectManager />
</div>
{/* Нижняя часть - DocumentTree (занимает оставшееся пространство) */}
<div className="flex-1 overflow-hidden min-h-0">
{activeProjectId ? (
<DocumentTree />
) : (
<div className="p-4 text-center text-gray-400">
<p className="text-sm">Выберите проект</p>
<p className="text-xs mt-1">для просмотра структуры</p>
</div>
)}
</div>
</div>
);
}
@@ -0,0 +1,174 @@
'use client';
import { useState, useEffect } from 'react';
import { useAppStore } from '@/stores/appStore';
import { Plus, Trash2, Folder, LogOut } from 'lucide-react';
export default function ProjectManager() {
const {
projects,
activeProjectId,
loadProjects,
setActiveProject,
createProject,
deleteProject,
logout,
user
} = useAppStore();
const [showCreateModal, setShowCreateModal] = useState(false);
const [newProjectTitle, setNewProjectTitle] = useState('');
const [newProjectDescription, setNewProjectDescription] = useState('');
useEffect(() => {
loadProjects();
}, []);
const handleCreateProject = async () => {
if (!newProjectTitle.trim()) return;
const project = await createProject(newProjectTitle, newProjectDescription);
await setActiveProject(project.id);
setShowCreateModal(false);
setNewProjectTitle('');
setNewProjectDescription('');
};
const handleDeleteProject = async (projectId: string, e: React.MouseEvent) => {
e.stopPropagation();
if (confirm('Вы уверены, что хотите удалить этот проект? Все данные будут потеряны.')) {
await deleteProject(projectId);
}
};
return (
<div className="flex flex-col" style={{ height: '100%' }}>
{/* Header */}
<div className="p-4 border-b border-gray-700 flex items-center justify-between">
<div>
<h2 className="text-lg font-bold text-gray-100">Проекты</h2>
{user && <p className="text-xs text-gray-400">{user.username}</p>}
</div>
<div className="flex gap-2">
<button
onClick={() => setShowCreateModal(true)}
className="p-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition-colors"
title="Создать проект"
>
<Plus className="w-4 h-4" />
</button>
<button
onClick={logout}
className="p-2 bg-gray-700 hover:bg-red-600 text-gray-300 hover:text-white rounded-lg transition-colors"
title="Выйти"
>
<LogOut className="w-4 h-4" />
</button>
</div>
</div>
{/* Projects list */}
<div className="overflow-auto p-4" style={{ maxHeight: 'calc(100% - 60px)' }}>
{projects.length === 0 ? (
<div className="text-center text-gray-400 py-8">
<Folder className="w-12 h-12 mx-auto mb-2 opacity-50" />
<p>Нет проектов</p>
<p className="text-sm mt-1">Создайте первый проект</p>
</div>
) : (
<div className="space-y-2">
{projects.map((project) => (
<div
key={project.id}
onClick={() => setActiveProject(project.id)}
className={`p-3 rounded-lg border cursor-pointer transition-colors ${
activeProjectId === project.id
? 'bg-blue-900/30 border-blue-600'
: 'bg-gray-700/50 border-gray-600 hover:bg-gray-700'
}`}
>
<div className="flex items-start justify-between">
<div className="flex-1">
<h3 className="font-semibold text-gray-100 flex items-center gap-2">
<Folder className="w-4 h-4" />
{project.title}
</h3>
{project.description && (
<p className="text-sm text-gray-400 mt-1">{project.description}</p>
)}
<p className="text-xs text-gray-500 mt-2">
Создан: {new Date(project.created_at).toLocaleDateString('ru-RU')}
</p>
</div>
<button
onClick={(e) => handleDeleteProject(project.id, e)}
className="p-1 text-gray-400 hover:text-red-400 transition-colors"
title="Удалить проект"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</div>
))}
</div>
)}
</div>
{/* Create project modal */}
{showCreateModal && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-gray-800 rounded-lg shadow-2xl max-w-md w-full">
<div className="p-4 border-b border-gray-700">
<h2 className="text-xl font-bold text-gray-100">Новый проект</h2>
</div>
<div className="p-4 space-y-4">
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
Название проекта *
</label>
<input
type="text"
value={newProjectTitle}
onChange={(e) => setNewProjectTitle(e.target.value)}
placeholder="Например: Мой первый роман"
className="w-full p-2 bg-gray-700 text-gray-200 rounded border border-gray-600 focus:border-blue-500 focus:outline-none"
autoFocus
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
Описание (опционально)
</label>
<textarea
value={newProjectDescription}
onChange={(e) => setNewProjectDescription(e.target.value)}
placeholder="Краткое описание проекта..."
className="w-full p-2 bg-gray-700 text-gray-200 rounded border border-gray-600 focus:border-blue-500 focus:outline-none min-h-[100px]"
/>
</div>
</div>
<div className="p-4 border-t border-gray-700 flex gap-2">
<button
onClick={handleCreateProject}
disabled={!newProjectTitle.trim()}
className="flex-1 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg disabled:bg-gray-600 disabled:cursor-not-allowed"
>
Создать
</button>
<button
onClick={() => {
setShowCreateModal(false);
setNewProjectTitle('');
setNewProjectDescription('');
}}
className="flex-1 px-4 py-2 bg-gray-700 hover:bg-gray-600 text-gray-200 rounded-lg"
>
Отмена
</button>
</div>
</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,547 @@
'use client';
import { useState, useEffect } from 'react';
import { useAppStore } from '@/stores/appStore';
import { storyBibleApi, type Character, type Location, type Event, type StoryThread } from '@/lib/api';
import { Users, MapPin, Calendar, GitBranch, Plus, Edit2, Trash2, X, Sparkles } from 'lucide-react';
type TabType = 'characters' | 'locations' | 'events' | 'threads';
interface AddEditModalProps {
type: TabType;
item: Character | Location | Event | StoryThread | null;
onSave: (data: unknown) => Promise<void>;
onClose: () => void;
}
export default function StoryBiblePanel() {
const { storyBible, activeProjectId, loadStoryBible, setStoryBible } = useAppStore();
const [activeTab, setActiveTab] = useState<TabType>('characters');
const [showAddModal, setShowAddModal] = useState(false);
const [editingItem, setEditingItem] = useState<Character | Location | Event | StoryThread | null>(null);
useEffect(() => {
if (activeProjectId && !storyBible) {
loadStoryBible(activeProjectId);
}
}, [activeProjectId, storyBible, loadStoryBible]);
const handleAdd = async (data: unknown) => {
if (!activeProjectId) return;
try {
let updatedBible: StoryBible;
const { getProjectStoryBible, updateProjectStoryBible } = storyBibleApi;
// Получаем текущую Bible
updatedBible = await getProjectStoryBible(activeProjectId);
// Добавляем новый элемент (используем Date для генерации ID)
const newItem = {
id: new Date().toISOString().replace(/[:.]/g, '-'),
...data,
};
switch (activeTab) {
case 'characters':
updatedBible.characters = [...updatedBible.characters, newItem as Character];
break;
case 'locations':
updatedBible.locations = [...updatedBible.locations, newItem as Location];
break;
case 'events':
updatedBible.events = [...updatedBible.events, newItem as Event];
break;
case 'threads':
updatedBible.threads = [...updatedBible.threads, newItem as StoryThread];
break;
}
// Сохраняем обратно
await updateProjectStoryBible(activeProjectId, updatedBible);
setStoryBible(updatedBible);
setShowAddModal(false);
} catch (error) {
console.error('Failed to add item:', error);
}
};
const handleDelete = async (id: string) => {
if (!storyBible || !activeProjectId) return;
const updatedBible = { ...storyBible };
switch (activeTab) {
case 'characters':
updatedBible.characters = updatedBible.characters.filter((c: Character) => c.id !== id);
break;
case 'locations':
updatedBible.locations = updatedBible.locations.filter((l: Location) => l.id !== id);
break;
case 'events':
updatedBible.events = updatedBible.events.filter((e: Event) => e.id !== id);
break;
case 'threads':
updatedBible.threads = updatedBible.threads.filter((t: StoryThread) => t.id !== id);
break;
}
try {
const { updateProjectStoryBible } = storyBibleApi;
await updateProjectStoryBible(activeProjectId, updatedBible);
setStoryBible(updatedBible);
} catch (error) {
console.error('Failed to delete item:', error);
}
};
const handleEdit = async (data: unknown) => {
if (!activeProjectId || !editingItem) return;
try {
const { getProjectStoryBible, updateProjectStoryBible } = storyBibleApi;
let updatedBible = await getProjectStoryBible(activeProjectId);
switch (activeTab) {
case 'characters':
updatedBible.characters = updatedBible.characters.map((c: Character) =>
c.id === (editingItem as Character).id ? { ...c, ...(data as Partial<Character>) } : c
);
break;
case 'locations':
updatedBible.locations = updatedBible.locations.map((l: Location) =>
l.id === (editingItem as Location).id ? { ...l, ...(data as Partial<Location>) } : l
);
break;
case 'events':
updatedBible.events = updatedBible.events.map((e: Event) =>
e.id === (editingItem as Event).id ? { ...e, ...(data as Partial<Event>) } : e
);
break;
case 'threads':
updatedBible.threads = updatedBible.threads.map((t: StoryThread) =>
t.id === (editingItem as StoryThread).id ? { ...t, ...(data as Partial<StoryThread>) } : t
);
break;
}
await updateProjectStoryBible(activeProjectId, updatedBible);
setStoryBible(updatedBible);
setShowAddModal(false);
setEditingItem(null);
} catch (error) {
console.error('Failed to edit item:', error);
}
};
const handleCleanup = async () => {
if (!activeProjectId) return;
if (confirm('Очистить дубликаты в Story Bible? Это действие нельзя отменить.')) {
try {
const result = await storyBibleApi.cleanupDuplicates(activeProjectId);
alert(result.message);
await loadStoryBible(activeProjectId);
} catch (error) {
console.error('Cleanup failed:', error);
alert('Ошибка при очистке дубликатов');
}
}
};
// Если нет активного проекта, показываем сообщение
if (!activeProjectId) {
return (
<div className="h-full flex items-center justify-center text-gray-400">
<div className="text-center">
<p className="text-sm">Выберите проект</p>
<p className="text-xs mt-1">для просмотра Story Bible</p>
</div>
</div>
);
}
// Если нет Story Bible после загрузки проекта
if (!storyBible) {
return <div className="p-4 text-gray-400">Загрузка Story Bible...</div>;
}
const tabs = [
{
id: 'characters' as TabType,
label: 'Персонажи',
icon: Users,
count: storyBible.characters.length,
},
{ id: 'locations' as TabType, label: 'Локации', icon: MapPin, count: storyBible.locations.length },
{ id: 'events' as TabType, label: 'События', icon: Calendar, count: storyBible.events.length },
{ id: 'threads' as TabType, label: 'Нити', icon: GitBranch, count: storyBible.threads.length },
];
return (
<div className="h-full flex flex-col bg-gray-800">
{/* Header */}
<div className="p-4 border-b border-gray-700 flex items-center justify-between">
<h2 className="text-lg font-bold text-gray-100">Story Bible</h2>
<button
onClick={handleCleanup}
className="p-2 bg-yellow-600 hover:bg-yellow-700 text-white rounded-lg transition-colors"
title="Очистить дубликаты"
>
<Sparkles className="w-4 h-4" />
</button>
</div>
{/* Tabs */}
<div className="flex border-b border-gray-700">
{tabs.map((tab) => {
const Icon = tab.icon;
return (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`flex-1 flex flex-col items-center gap-1 p-3 text-xs transition-colors ${
activeTab === tab.id
? 'bg-gray-700 text-blue-400 border-b-2 border-blue-400'
: 'text-gray-400 hover:bg-gray-700/50'
}`}
>
<Icon className="w-4 h-4" />
<span>{tab.label}</span>
<span className="text-xs opacity-60">{tab.count}</span>
</button>
);
})}
</div>
{/* Content */}
<div className="flex-1 overflow-auto p-4">
<div className="space-y-3">
{activeTab === 'characters' &&
storyBible.characters.map((char: Character) => (
<div key={char.id} className="p-3 bg-gray-700/50 rounded-lg border border-gray-600">
<div className="flex items-start justify-between">
<div className="flex-1">
<h3 className="font-semibold text-gray-100">{char.name}</h3>
<p className="text-sm text-gray-400 mt-1">{char.description}</p>
{char.traits.length > 0 && (
<div className="flex flex-wrap gap-1 mt-2">
{char.traits.map((trait: string, idx: number) => (
<span
key={idx}
className="px-2 py-0.5 bg-blue-900/30 text-blue-300 text-xs rounded"
>
{trait}
</span>
))}
</div>
)}
</div>
<div className="flex gap-1 ml-2">
<button
onClick={() => { setEditingItem(char); setShowAddModal(true); }}
className="p-1 text-gray-400 hover:text-blue-400"
>
<Edit2 className="w-4 h-4" />
</button>
<button
onClick={() => handleDelete(char.id)}
className="p-1 text-gray-400 hover:text-red-400"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</div>
</div>
))}
{activeTab === 'locations' &&
storyBible.locations.map((loc: Location) => (
<div key={loc.id} className="p-3 bg-gray-700/50 rounded-lg border border-gray-600">
<div className="flex items-start justify-between">
<div className="flex-1">
<h3 className="font-semibold text-gray-100">{loc.name}</h3>
<p className="text-sm text-gray-400 mt-1">{loc.description}</p>
{loc.atmosphere && <p className="text-xs text-gray-500 mt-2 italic">{loc.atmosphere}</p>}
</div>
<div className="flex gap-1 ml-2">
<button onClick={() => { setEditingItem(loc); setShowAddModal(true); }} className="p-1 text-gray-400 hover:text-blue-400">
<Edit2 className="w-4 h-4" />
</button>
<button onClick={() => handleDelete(loc.id)} className="p-1 text-gray-400 hover:text-red-400">
<Trash2 className="w-4 h-4" />
</button>
</div>
</div>
</div>
))}
{activeTab === 'events' &&
storyBible.events.map((event: Event) => (
<div key={event.id} className="p-3 bg-gray-700/50 rounded-lg border border-gray-600">
<div className="flex items-start justify-between">
<div className="flex-1">
<h3 className="font-semibold text-gray-100">{event.title}</h3>
<p className="text-sm text-gray-400 mt-1">{event.description}</p>
{event.chapter && <p className="text-xs text-gray-500 mt-2">{event.chapter}</p>}
</div>
<div className="flex gap-1 ml-2">
<button onClick={() => { setEditingItem(event); setShowAddModal(true); }} className="p-1 text-gray-400 hover:text-blue-400">
<Edit2 className="w-4 h-4" />
</button>
<button onClick={() => handleDelete(event.id)} className="p-1 text-gray-400 hover:text-red-400">
<Trash2 className="w-4 h-4" />
</button>
</div>
</div>
</div>
))}
{activeTab === 'threads' &&
storyBible.threads.map((thread: StoryThread) => (
<div key={thread.id} className="p-3 bg-gray-700/50 rounded-lg border border-gray-600">
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-2">
<h3 className="font-semibold text-gray-100">{thread.title}</h3>
<span
className={`px-2 py-0.5 text-xs rounded ${
thread.status === 'active'
? 'bg-green-900/30 text-green-300'
: thread.status === 'resolved'
? 'bg-blue-900/30 text-blue-300'
: 'bg-gray-900/30 text-gray-400'
}`}
>
{thread.status === 'active'
? 'Активна'
: thread.status === 'resolved'
? 'Завершена'
: 'Отменена'}
</span>
</div>
<p className="text-sm text-gray-400 mt-1">{thread.description}</p>
</div>
<div className="flex gap-1 ml-2">
<button onClick={() => { setEditingItem(thread); setShowAddModal(true); }} className="p-1 text-gray-400 hover:text-blue-400">
<Edit2 className="w-4 h-4" />
</button>
<button onClick={() => handleDelete(thread.id)} className="p-1 text-gray-400 hover:text-red-400">
<Trash2 className="w-4 h-4" />
</button>
</div>
</div>
</div>
))}
</div>
</div>
{/* Add button */}
<div className="p-4 border-t border-gray-700">
<button
onClick={() => { setEditingItem(null); setShowAddModal(true); }}
className="w-full flex items-center justify-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition-colors"
>
<Plus className="w-4 h-4" />
Добавить
</button>
</div>
{/* Add/Edit Modal */}
{showAddModal && (
<AddEditModal
type={activeTab}
item={editingItem}
onSave={editingItem ? handleEdit : handleAdd}
onClose={() => {
setShowAddModal(false);
setEditingItem(null);
}}
/>
)}
</div>
);
}
// Модальное окно для добавления/редактирования
function AddEditModal({ type, item, onSave, onClose }: AddEditModalProps) {
const [formData, setFormData] = useState<unknown>(item || {});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
onSave(formData);
};
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-gray-800 rounded-lg shadow-2xl max-w-md w-full max-h-[90vh] overflow-auto">
<div className="flex items-center justify-between p-4 border-b border-gray-700">
<h2 className="text-xl font-bold text-gray-100">{item ? 'Редактировать' : 'Добавить'}</h2>
<button onClick={onClose} className="text-gray-400 hover:text-gray-200">
<X className="w-5 h-5" />
</button>
</div>
<form onSubmit={handleSubmit} className="p-4 space-y-4">
{type === 'characters' && (
<>
<input
type="text"
placeholder="Имя"
value={(formData as Character).name || ''}
onChange={(e) => setFormData({ ...(formData as Character), name: e.target.value })}
className="w-full p-2 bg-gray-700 text-gray-200 rounded border border-gray-600"
required
/>
<textarea
placeholder="Описание"
value={(formData as Character).description || ''}
onChange={(e) => setFormData({ ...(formData as Character), description: e.target.value })}
className="w-full p-2 bg-gray-700 text-gray-200 rounded border border-gray-600 min-h-[80px]"
/>
<input
type="text"
placeholder="Черты характера (через запятую)"
value={(formData as Character).traits?.join(', ') || ''}
onChange={(e) =>
setFormData({ ...(formData as Character), traits: e.target.value.split(',').map((t: string) => t.trim()) })
}
className="w-full p-2 bg-gray-700 text-gray-200 rounded border border-gray-600"
/>
<textarea
placeholder="Внешность"
value={(formData as Character).appearance || ''}
onChange={(e) => setFormData({ ...(formData as Character), appearance: e.target.value })}
className="w-full p-2 bg-gray-700 text-gray-200 rounded border border-gray-600 min-h-[60px]"
/>
<select
value={(formData as Character).role || 'supporting'}
onChange={(e) => setFormData({ ...(formData as Character), role: e.target.value })}
className="w-full p-2 bg-gray-700 text-gray-200 rounded border border-gray-600"
>
<option value="protagonist">Протагонист</option>
<option value="antagonist">Антагонист</option>
<option value="supporting">Второстепенный</option>
<option value="minor">Эпизодический</option>
</select>
</>
)}
{type === 'locations' && (
<>
<input
type="text"
placeholder="Название"
value={(formData as Location).name || ''}
onChange={(e) => setFormData({ ...(formData as Location), name: e.target.value })}
className="w-full p-2 bg-gray-700 text-gray-200 rounded border border-gray-600"
required
/>
<textarea
placeholder="Описание"
value={(formData as Location).description || ''}
onChange={(e) => setFormData({ ...(formData as Location), description: e.target.value })}
className="w-full p-2 bg-gray-700 text-gray-200 rounded border border-gray-600 min-h-[80px]"
/>
<textarea
placeholder="Атмосфера"
value={(formData as Location).atmosphere || ''}
onChange={(e) => setFormData({ ...(formData as Location), atmosphere: e.target.value })}
className="w-full p-2 bg-gray-700 text-gray-200 rounded border border-gray-600 min-h-[60px]"
/>
<select
value={(formData as Location).type || 'other'}
onChange={(e) => setFormData({ ...(formData as Location), type: e.target.value })}
className="w-full p-2 bg-gray-700 text-gray-200 rounded border border-gray-600"
>
<option value="city">Город</option>
<option value="building">Здание</option>
<option value="nature">Природа</option>
<option value="interior">Интерьер</option>
<option value="other">Другое</option>
</select>
</>
)}
{type === 'events' && (
<>
<input
type="text"
placeholder="Название"
value={(formData as Event).title || ''}
onChange={(e) => setFormData({ ...(formData as Event), title: e.target.value })}
className="w-full p-2 bg-gray-700 text-gray-200 rounded border border-gray-600"
required
/>
<textarea
placeholder="Описание"
value={(formData as Event).description || ''}
onChange={(e) => setFormData({ ...(formData as Event), description: e.target.value })}
className="w-full p-2 bg-gray-700 text-gray-200 rounded border border-gray-600 min-h-[80px]"
/>
<input
type="text"
placeholder="Глава"
value={(formData as Event).chapter || ''}
onChange={(e) => setFormData({ ...(formData as Event), chapter: e.target.value })}
className="w-full p-2 bg-gray-700 text-gray-200 rounded border border-gray-600"
/>
<select
value={(formData as Event).importance || 'minor'}
onChange={(e) => setFormData({ ...(formData as Event), importance: e.target.value })}
className="w-full p-2 bg-gray-700 text-gray-200 rounded border border-gray-600"
>
<option value="major">Важное</option>
<option value="minor">Незначительное</option>
<option value="background">Фоновое</option>
</select>
</>
)}
{type === 'threads' && (
<>
<input
type="text"
placeholder="Название"
value={(formData as StoryThread).title || ''}
onChange={(e) => setFormData({ ...(formData as StoryThread), title: e.target.value })}
className="w-full p-2 bg-gray-700 text-gray-200 rounded border border-gray-600"
required
/>
<textarea
placeholder="Описание"
value={(formData as StoryThread).description || ''}
onChange={(e) => setFormData({ ...(formData as StoryThread), description: e.target.value })}
className="w-full p-2 bg-gray-700 text-gray-200 rounded border border-gray-600 min-h-[80px]"
/>
<select
value={(formData as StoryThread).status || 'active'}
onChange={(e) => setFormData({ ...(formData as StoryThread), status: e.target.value })}
className="w-full p-2 bg-gray-700 text-gray-200 rounded border border-gray-600"
>
<option value="active">Активна</option>
<option value="resolved">Завершена</option>
<option value="abandoned">Отменена</option>
</select>
</>
)}
<div className="flex gap-2 pt-4">
<button
type="submit"
className="flex-1 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg"
>
Сохранить
</button>
<button
type="button"
onClick={onClose}
className="flex-1 px-4 py-2 bg-gray-700 hover:bg-gray-600 text-gray-200 rounded-lg"
>
Отмена
</button>
</div>
</form>
</div>
</div>
);
}
@@ -0,0 +1,14 @@
export function StoryExplorer() {
return (
<div className="h-full bg-gray-800 border-r border-gray-700 flex flex-col">
<div className="p-4 border-b border-gray-700">
<h2 className="text-sm font-semibold uppercase text-gray-400">
Story Explorer
</h2>
</div>
<div className="flex-1 p-4">
<div className="text-gray-500 text-sm">Дерево сюжета...</div>
</div>
</div>
);
}
+90
View File
@@ -0,0 +1,90 @@
'use client';
import { useState } from 'react';
import { generateText, Preset, Provider } from '@/lib/ai-api';
export default function AITest() {
const [prompt, setPrompt] = useState('');
const [result, setResult] = useState('');
const [loading, setLoading] = useState(false);
const [preset, setPreset] = useState<Preset>('creative_draft');
const [provider, setProvider] = useState<Provider>('ollama');
const handleGenerate = async () => {
if (!prompt.trim()) return;
setLoading(true);
setResult('');
try {
const text = await generateText({
prompt,
preset,
provider,
});
setResult(text);
} catch (error) {
setResult(`Ошибка: ${error instanceof Error ? error.message : 'Неизвестная ошибка'}`);
} finally {
setLoading(false);
}
};
return (
<div className="p-4 bg-gray-800 rounded-lg space-y-4">
<h2 className="text-xl font-bold text-gray-100">AI Test Component</h2>
<div className="space-y-2">
<label className="text-sm text-gray-400">Provider:</label>
<select
value={provider}
onChange={(e) => setProvider(e.target.value as Provider)}
className="w-full p-2 bg-gray-700 text-gray-200 rounded border border-gray-600"
>
<option value="ollama">Ollama (Local)</option>
<option value="openrouter">OpenRouter (Cloud)</option>
</select>
</div>
<div className="space-y-2">
<label className="text-sm text-gray-400">Preset:</label>
<select
value={preset}
onChange={(e) => setPreset(e.target.value as Preset)}
className="w-full p-2 bg-gray-700 text-gray-200 rounded border border-gray-600"
>
<option value="creative_draft">Creative Draft (temp 0.85)</option>
<option value="editor">Editor (temp 0.3)</option>
<option value="brainstorm">Brainstorm (temp 1.0)</option>
</select>
</div>
<div className="space-y-2">
<label className="text-sm text-gray-400">Prompt:</label>
<textarea
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
placeholder="Введите промпт..."
className="w-full p-2 bg-gray-700 text-gray-200 rounded border border-gray-600 min-h-[100px]"
/>
</div>
<button
onClick={handleGenerate}
disabled={loading || !prompt.trim()}
className="w-full p-2 bg-blue-600 text-white rounded hover:bg-blue-700 disabled:bg-gray-600 disabled:cursor-not-allowed"
>
{loading ? 'Генерация...' : 'Сгенерировать'}
</button>
{result && (
<div className="space-y-2">
<label className="text-sm text-gray-400">Результат:</label>
<div className="p-3 bg-gray-900 text-gray-100 rounded border border-gray-700 whitespace-pre-wrap">
{result}
</div>
</div>
)}
</div>
);
}
+36
View File
@@ -0,0 +1,36 @@
'use client';
import { useState } from 'react';
import AIDiffView from '../editor/AIDiffView';
export default function DiffTest() {
const [showDiff, setShowDiff] = useState(false);
if (!showDiff) {
return (
<div className="p-8 bg-gray-900 min-h-screen">
<button
onClick={() => setShowDiff(true)}
className="px-4 py-2 bg-blue-600 text-white rounded-lg"
>
Показать Diff View
</button>
</div>
);
}
return (
<AIDiffView
originalText="Это оригинальный текст, который нужно улучшить. Он содержит базовую информацию, но lacks деталей и образности."
aiText="Это оригинальный текст, требующий существенного улучшения. Он содержит лишь поверхностную информацию, лишённую живой образности и глубины, которые могли бы сделать его по-настоящему увлекательным для читателя."
onAccept={() => {
console.log('Accepted!');
setShowDiff(false);
}}
onReject={() => {
console.log('Rejected!');
setShowDiff(false);
}}
/>
);
}
+197
View File
@@ -0,0 +1,197 @@
import axios from 'axios';
import type { StoryBible } from '@/lib/api';
// Типы для запроса
export type Provider = 'ollama' | 'openrouter';
export type Preset = 'creative_draft' | 'editor' | 'brainstorm';
export type TaskType = 'generation' | 'background' | 'analysis';
export interface ModelInfo {
id: string;
name: string;
description?: string;
models?: ModelInfo[];
}
export interface GenerateRequest {
prompt: string;
provider?: Provider;
model?: string;
api_key?: string;
preset?: Preset;
options?: Record<string, unknown>;
story_bible?: StoryBible; // Story Bible для контекста
selected_text?: string; // Выделенный текст для анализа
taskType?: TaskType;
}
// Типы для ответа
export interface GenerateResponse {
generated_text: string;
provider: string;
model: string;
preset: string;
}
// API клиент
const aiApi = axios.create({
baseURL: 'http://localhost:8000/api',
headers: {
'Content-Type': 'application/json',
},
});
// Интерсептор для автоматического добавления токена авторизации
aiApi.interceptors.request.use((config) => {
const token = typeof window !== 'undefined' ? localStorage.getItem('auth_token') : null;
if (token && config.headers) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
/**
* Получение списка доступных моделей для провайдера
*/
export async function getAvailableModels(
provider: Provider
): Promise<ModelInfo> {
try {
const response = await aiApi.get<{ models: ModelInfo[] }>(
'/ai/models',
{ params: { provider } }
);
return {
id: provider,
name: provider === 'ollama' ? 'Ollama Models' : 'OpenRouter Models',
models: response.data.models || [],
};
} catch (error) {
console.error(`Failed to load models from ${provider}:`, error);
return {
id: provider,
name: provider === 'ollama' ? 'Ollama Models' : 'OpenRouter Models',
models: [],
};
}
}
/**
* Генерация текста через AI с использованием настроек из store
*/
export async function generateText(
request: Omit<GenerateRequest, 'provider' | 'model'> & { provider?: Provider; model?: string }
): Promise<string> {
const { useAppStore } = await import('@/stores/appStore');
const { modelSettings, storyBible } = useAppStore.getState();
// Если provider и model явно указаны, используем их
if (request.provider && request.model) {
try {
const response = await aiApi.post<GenerateResponse>('/ai/generate', {
...request,
story_bible: storyBible,
});
return response.data.generated_text;
} catch (error) {
if (axios.isAxiosError(error)) {
throw new Error(`AI generation failed: ${error.response?.data?.detail || error.message}`);
}
throw error;
}
}
// Иначе используем настройки из store
const taskType = (request as Record<string, unknown>).taskType || 'generation';
const settings = modelSettings[taskType as keyof typeof modelSettings];
try {
const response = await aiApi.post<GenerateResponse>('/ai/generate', {
...request,
provider: settings.provider,
model: settings.model,
story_bible: storyBible,
});
return response.data.generated_text;
} catch (error) {
if (axios.isAxiosError(error)) {
throw new Error(`AI generation failed: ${error.response?.data?.detail || error.message}`);
}
throw error;
}
}
/**
* Упрощенная функция для быстрого вызова
*/
export async function quickGenerate(
prompt: string,
preset: Preset = 'creative_draft',
provider: Provider = 'ollama',
model?: string
): Promise<string> {
return generateText({
prompt,
preset,
provider,
model,
});
}
// ─── Auto-update Story Bible ───
export interface AutoUpdateBibleResponse {
updated: boolean;
new_characters: number;
new_locations: number;
new_events: number;
message: string;
}
export async function autoUpdateBible(
projectId: string,
sceneText: string,
enabled: boolean = true
): Promise<AutoUpdateBibleResponse> {
try {
const response = await aiApi.post<AutoUpdateBibleResponse>('/ai/auto-update-bible', {
project_id: projectId,
scene_text: sceneText,
auto_update_enabled: enabled,
});
return response.data;
} catch (error) {
if (axios.isAxiosError(error)) {
throw new Error(`Auto-update failed: ${error.response?.data?.detail || error.message}`);
}
throw error;
}
}
// ─── Extract Entities ───
export interface ExtractEntitiesResponse {
entities: {
new_characters: any[];
new_locations: any[];
new_events: any[];
};
model_used: string;
provider: string;
}
export async function extractEntities(text: string): Promise<ExtractEntitiesResponse> {
try {
const response = await aiApi.post<ExtractEntitiesResponse>('/ai/extract-entities', {
text,
existing_bible: null, // Можно передать текущую Bible для дедупликации
});
return response.data;
} catch (error) {
if (axios.isAxiosError(error)) {
throw new Error(`Extract entities failed: ${error.response?.data?.detail || error.message}`);
}
throw error;
}
}
+471
View File
@@ -0,0 +1,471 @@
import axios from 'axios';
import type { DocumentNode, DocumentTreeNode, ModelSettings } from '@/stores/appStore';
// Конфигурация API
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000/api';
// Типы
export interface User {
id: string;
username: string;
email: string;
}
export interface AuthResponse {
access_token: string;
token_type: string;
user_id: string;
username: string;
}
// Типы для конфигурации моделей
export interface ModelInfo {
id: string;
name: string;
description: string;
context_length?: number;
pricing?: string;
}
export interface ModelsResponse {
provider: string;
default_model: string;
models: ModelInfo[];
}
export type Provider = 'ollama' | 'openrouter';
// Типы для Project
export interface Project {
id: string;
title: string;
description: string;
created_at: string;
updated_at: string;
}
// Типы для Story Bible
export interface Character {
id: string;
name: string;
description: string;
traits: string[];
appearance: string;
role: 'protagonist' | 'antagonist' | 'supporting' | 'minor';
}
export interface Location {
id: string;
name: string;
description: string;
atmosphere: string;
type: 'city' | 'building' | 'nature' | 'interior' | 'other';
}
export interface Event {
id: string;
title: string;
description: string;
chapter?: string;
importance: 'major' | 'minor' | 'background';
}
export interface StoryThread {
id: string;
title: string;
description: string;
status: 'active' | 'resolved' | 'abandoned';
related_characters: string[];
related_events: string[];
}
export interface StoryBible {
characters: Character[];
locations: Location[];
events: Event[];
threads: StoryThread[];
last_updated?: string;
}
export interface CleanupDuplicatesResponse {
removed_characters: number;
removed_locations: number;
removed_events: number;
message: string;
}
// Экспортируем все типы Story Bible
export type { Character, Location, Event, StoryThread, StoryBible };
const apiClient = axios.create({
baseURL: API_BASE_URL,
headers: {
'Content-Type': 'application/json',
},
});
// Интерсептор для автоматического добавления токена
apiClient.interceptors.request.use((config) => {
const token = typeof window !== 'undefined' ? localStorage.getItem('auth_token') : null;
if (token && config.headers) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
interface ApiProjectResponse {
id: string;
title: string;
description: string;
created_at: string;
updated_at: string;
}
interface ApiProjectCreate {
title: string;
description: string;
}
// API функции для работы с проектами
export const projectsApi = {
/**
* Получить все проекты
*/
async getAllProjects(): Promise<Project[]> {
const response = await apiClient.get<ApiProjectResponse[]>('/projects');
return response.data.map((p) => ({
...p,
created_at: p.created_at,
updated_at: p.updated_at,
}));
},
/**
* Создать новый проект
*/
async createProject(project: Omit<ApiProjectCreate, 'id'>): Promise<Project> {
const response = await apiClient.post<ApiProjectResponse>('/projects', project);
return {
...response.data,
created_at: response.data.created_at,
updated_at: response.data.updated_at,
};
},
/**
* Обновить проект
*/
async updateProject(
projectId: string,
updates: Partial<Omit<ApiProjectCreate, 'id'>>
): Promise<Project> {
const response = await apiClient.put<ApiProjectResponse>(`/projects/${projectId}`, updates);
return {
...response.data,
created_at: response.data.created_at,
updated_at: response.data.updated_at,
};
},
/**
* Удалить проект
*/
async deleteProject(projectId: string): Promise<void> {
await apiClient.delete(`/projects/${projectId}`);
},
};
// API функции для работы с документами (иерархические)
export const documentsApi = {
/**
* Получить документы в виде дерева
*/
async getDocumentsTree(projectId: string): Promise<DocumentTreeNode[]> {
const response = await apiClient.get<DocumentTreeNode[]>(
`/projects/${projectId}/documents/tree`
);
return response.data;
},
/**
* Получить все документы (плоский список)
*/
async getProjectDocuments(projectId: string): Promise<DocumentNode[]> {
const response = await apiClient.get<DocumentNode[]>(
`/projects/${projectId}/documents`
);
return response.data;
},
/**
* Получить документ по ID
*/
async getDocument(projectId: string, documentId: string): Promise<DocumentNode> {
const response = await apiClient.get<DocumentNode>(
`/projects/${projectId}/documents/${documentId}`
);
return response.data;
},
/**
* Создать документ в проекте
*/
async createDocument(
projectId: string,
title: string,
type: 'book' | 'chapter' | 'scene',
parentId?: string,
content: string = ''
): Promise<DocumentNode> {
const response = await apiClient.post<DocumentNode>(
`/projects/${projectId}/documents`,
{
title,
type,
parent_id: parentId,
content,
}
);
return response.data;
},
/**
* Обновить документ (включая перенос)
*/
async updateDocument(
projectId: string,
documentId: string,
updates: {
title?: string;
content?: string;
parent_id?: string;
order?: number;
}
): Promise<DocumentNode> {
const response = await apiClient.put<DocumentNode>(
`/projects/${projectId}/documents/${documentId}`,
updates
);
return response.data;
},
/**
* Удалить документ с каскадом
*/
async deleteDocument(projectId: string, documentId: string): Promise<void> {
await apiClient.delete(`/projects/${projectId}/documents/${documentId}`);
},
/**
* Получить прямых детей узла
*/
async getChildren(projectId: string, parentId: string): Promise<DocumentNode[]> {
const response = await apiClient.get<DocumentNode[]>(
`/projects/${projectId}/documents/${parentId}/children`
);
return response.data;
},
// --- Legacy функции для обратной совместимости ---
/**
* Обновить контент документа (legacy)
*/
async updateDocumentContent(
projectId: string,
documentId: string,
content: string
): Promise<DocumentNode> {
const updatedDoc = await documentsApi.updateDocument(projectId, documentId, {
content,
});
return updatedDoc;
},
};
// API функции для Story Bible
export const storyBibleApi = {
/**
* Получить Story Bible проекта
*/
async getProjectStoryBible(projectId: string): Promise<StoryBible> {
const response = await apiClient.get<StoryBible>(`/projects/${projectId}/bible`);
return response.data;
},
/**
* Обновить Story Bible проекта
*/
async updateProjectStoryBible(
projectId: string,
bible: StoryBible
): Promise<StoryBible> {
const response = await apiClient.put<StoryBible>(`/projects/${projectId}/bible`, bible);
return response.data;
},
/**
* Очистить дубликаты в Story Bible
*/
async cleanupDuplicates(projectId: string): Promise<CleanupDuplicatesResponse> {
const response = await apiClient.post<CleanupDuplicatesResponse>(
`/projects/${projectId}/bible/cleanup-duplicates`
);
return response.data;
},
};
const apiClientFactory = () => apiClient;
export default apiClientFactory;
/**
* Экспорт документа в выбранный формат (epub, fb2, pdf, docx)
*/
export async function exportDocument(
projectId: string,
documentId: string,
format: 'epub' | 'fb2' | 'pdf' | 'docx'
): Promise<void> {
try {
const response = await apiClient.post(`/export`,
{ project_id: projectId, document_id: documentId, format },
{ responseType: 'blob' }
);
// Создаем временную ссылку для скачивания
const url = window.URL.createObjectURL(new Blob([response.data]));
const link = document.createElement('a');
link.href = url;
// Определяем расширение файла
const extension = format === 'docx' ? 'docx' : format;
link.setAttribute('download', `export.${extension}`);
document.body.appendChild(link);
link.click();
link.remove();
window.URL.revokeObjectURL(url);
} catch (error) {
if (axios.isAxiosError(error)) {
throw new Error(`Export failed: ${error.response?.data?.detail || error.message}`);
}
throw error;
}
}
/**
* Получить список доступных моделей для провайдера
*/
export async function getAvailableModels(provider: Provider): Promise<ModelsResponse> {
try {
const response = await apiClient.get<ModelsResponse>(`/ai/models?provider=${provider}`);
return response.data;
} catch (error) {
if (axios.isAxiosError(error)) {
throw new Error(`Failed to fetch models: ${error.response?.data?.detail || error.message}`);
}
throw error;
}
}
/**
* Получить данные модели для конкретного endpoint (generation, background, analysis)
*/
export async function getModelForTask(taskType: keyof ModelSettings): Promise<ModelsResponse> {
try {
// Получаем настройки из localStorage
if (typeof window !== 'undefined') {
const savedSettings = localStorage.getItem('modelSettings');
if (savedSettings) {
const settings: ModelSettings = JSON.parse(savedSettings);
const modelSetting = settings[taskType];
if (modelSetting) {
const response = await apiClient.get<ModelsResponse>(
`/ai/models?provider=${modelSetting.provider}`
);
// Обновляем default_model на выбранный пользователем
return {
...response.data,
default_model: modelSetting.model,
};
}
}
}
// Если нет настроек, используем дефолтные
const response = await apiClient.get<ModelsResponse>(`/ai/models?provider=ollama`);
return response.data;
} catch (error) {
if (axios.isAxiosError(error)) {
throw new Error(`Failed to fetch model for ${taskType}: ${error.response?.data?.detail || error.message}`);
}
throw error;
}
}
// === ФУНКЦИИ ГЕНЕРАЦИИ ИЗОБРАЖЕНИЙ ===
export interface GenerateImageRequest {
prompt: string;
project_id: string;
document_id: string; // ID документа (книги или главы)
document_type: 'book' | 'chapter'; // Тип документа
model?: string;
size?: string;
}
export interface GenerateImageResponse {
image_id: string;
filename: string;
path: string;
prompt: string;
model: string;
data_url: string; // base64 изображение для немедленного отображения
document_id: string;
document_type: string;
}
/**
* Сгенерировать изображение
*/
export async function generateImage(request: GenerateImageRequest): Promise<GenerateImageResponse> {
const response = await apiClient.post<GenerateImageResponse>('/ai/generate-image', request);
return response.data;
}
/**
* Получить URL изображения
*/
export function getImageUrl(projectId: string, filename: string): string {
const baseUrl = process.env.NEXT_PUBLIC_API_URL?.replace('/api', '') || 'http://localhost:8000';
return `${baseUrl}/api/images/${projectId}/${filename}`;
}
// === ФУНКЦИИ АВТОРИЗАЦИИ ===
/**
* Войти в систему
*/
export async function login(username: string, password: string): Promise<AuthResponse> {
const response = await apiClient.post<AuthResponse>('/auth/login', { username, password });
return response.data;
}
/**
* Зарегистрировать нового пользователя
*/
export async function register(username: string, email: string, password: string): Promise<AuthResponse> {
const response = await apiClient.post<AuthResponse>('/auth/register', { username, email, password });
return response.data;
}
/**
* Получить текущего пользователя
*/
export async function fetchCurrentUser(): Promise<User> {
const response = await apiClient.get<User>('/auth/me');
return response.data;
}
@@ -0,0 +1,88 @@
import { Mark, mergeAttributes } from '@tiptap/core';
export interface FontSizeOptions {
types: string[];
HTMLAttributes: Record<string, unknown>;
}
declare module '@tiptap/core' {
interface Commands<ReturnType> {
fontSize: {
setFontSize: (size: string) => ReturnType;
unsetFontSize: () => ReturnType;
};
}
}
export const FontSize = Mark.create<FontSizeOptions>({
name: 'fontSize',
addOptions() {
return {
types: ['fontSize'],
HTMLAttributes: {},
};
},
addAttributes() {
return {
fontSize: {
default: null as string | null,
parseHTML: element => element.getAttribute('data-font-size'),
renderHTML: attributes => {
if (!attributes.fontSize) {
return {};
}
return {
'data-font-size': attributes.fontSize,
};
},
},
};
},
parseHTML() {
return [
{
tag: 'span[data-font-size]',
},
];
},
renderHTML({ mark, HTMLAttributes }) {
const fontSize = mark.attrs.fontSize;
if (!fontSize) {
return {};
}
return [
'span',
mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, {
style: `font-size: ${fontSize}`,
}),
0,
];
},
addCommands() {
return {
setFontSize: (size: string) => {
return ({ chain }) => {
return chain()
.setMark(this.name, { fontSize: size })
.run();
};
},
unsetFontSize: () => {
return ({ chain }) => {
return chain()
.setMark(this.name, { fontSize: null })
.run();
};
},
};
},
addKeyboardShortcuts() {
return {};
},
});
+422
View File
@@ -0,0 +1,422 @@
import { create } from 'zustand';
import { projectsApi, documentsApi, storyBibleApi, type StoryBible, type Project, type User, login, register, fetchCurrentUser } from '@/lib/api';
// Типы данных
export type DocumentType = 'book' | 'chapter' | 'scene';
export interface DocumentNode {
id: string;
project_id: string;
parent_id: string | null;
title: string;
content: string;
type: 'book' | 'chapter' | 'scene';
order: number;
created_at: string;
updated_at: string;
}
export interface DocumentTreeNode extends Omit<DocumentNode, 'children'> {
children: DocumentTreeNode[];
}
// Для обратной совместимости
export type Document = DocumentNode;
export interface ModelSettings {
generation: {
provider: 'ollama' | 'openrouter';
model: string;
};
background: {
provider: 'ollama' | 'openrouter';
model: string;
};
analysis: {
provider: 'ollama' | 'openrouter';
model: string;
};
}
export interface AppState {
// Авторизация
user: User | null;
isAuthenticated: boolean;
isLoadingAuth: boolean;
// Проекты
projects: Project[];
activeProjectId: string | null;
// Документы (иерархические)
documentsTree: DocumentTreeNode[];
documents: DocumentNode[]; // Плоский список для быстрого доступа
activeDocumentId: string | null;
// Story Bible
storyBible: StoryBible | null;
// Настройки
isLoading: boolean;
isSaving: boolean;
modelSettings: ModelSettings;
autoUpdateBible: boolean;
// Actions для проектов
setUser: (user: User | null) => void;
login: (username: string, password: string) => Promise<void>;
register: (username: string, email: string, password: string) => Promise<void>;
logout: () => void;
checkAuth: () => Promise<void>;
loadProjects: () => Promise<void>;
setActiveProject: (projectId: string) => Promise<void>;
createProject: (title: string, description?: string) => Promise<Project>;
deleteProject: (projectId: string) => Promise<void>;
// Actions для документов (иерархические)
loadDocumentsTree: (projectId: string) => Promise<void>;
setActiveDocument: (documentId: string) => void;
switchToDocument: (documentId: string) => Promise<void>;
createDocument: (
projectId: string,
title: string,
type: 'book' | 'chapter' | 'scene',
parentId?: string,
content?: string
) => Promise<DocumentNode>;
updateDocument: (
projectId: string,
documentId: string,
updates: { title?: string; content?: string; parent_id?: string; order?: number }
) => Promise<void>;
updateDocumentContent: (
projectId: string,
documentId: string,
content: string
) => Promise<void>;
deleteDocument: (projectId: string, documentId: string) => Promise<void>;
// Actions для Story Bible (теперь требуют projectId)
loadStoryBible: (projectId: string) => Promise<void>;
setStoryBible: (bible: StoryBible) => void;
updateStoryBible: (projectId: string, bible: StoryBible) => Promise<void>;
// Actions для настроек
setModelSettings: (settings: ModelSettings) => void;
updateModelSetting: (
taskType: keyof ModelSettings,
provider: 'ollama' | 'openrouter',
model: string
) => void;
setAutoUpdateBible: (enabled: boolean) => void;
}
const defaultModelSettings: ModelSettings = {
generation: { provider: 'ollama', model: 'qwen2.5:1.5b' },
background: { provider: 'ollama', model: 'qwen2.5:1.5b' },
analysis: { provider: 'ollama', model: 'qwen2.5:1.5b' },
};
export const useAppStore = create<AppState>((set, get) => ({
// Авторизация
user: null,
isAuthenticated: false,
isLoadingAuth: true,
// Проекты
projects: [],
activeProjectId: null,
// Документы (иерархические)
documentsTree: [],
documents: [], // Плоский список для быстрого доступа
activeDocumentId: null,
// Story Bible
storyBible: null,
// Загрузка/сохранение
isLoading: false,
isSaving: false,
// Настройки моделей
modelSettings: defaultModelSettings,
autoUpdateBible: true,
// === МЕТОДЫ АВТОРИЗАЦИИ ===
setUser: (user) => set({ user, isAuthenticated: !!user }),
login: async (username, password) => {
const response = await login(username, password);
localStorage.setItem('auth_token', response.access_token);
const user = await fetchCurrentUser();
set({ user, isAuthenticated: true });
await get().loadProjects(); // Загружаем проекты после входа
},
register: async (username, email, password) => {
const response = await register(username, email, password);
localStorage.setItem('auth_token', response.access_token);
const user = await fetchCurrentUser();
set({ user, isAuthenticated: true });
await get().loadProjects();
},
logout: () => {
localStorage.removeItem('auth_token');
set({ user: null, isAuthenticated: false, projects: [], activeProjectId: null });
},
checkAuth: async () => {
set({ isLoadingAuth: true });
const token = localStorage.getItem('auth_token');
if (token) {
try {
const user = await fetchCurrentUser();
set({ user, isAuthenticated: true });
await get().loadProjects();
} catch (error) {
console.error('Token invalid, logging out');
get().logout();
}
}
set({ isLoadingAuth: false });
},
// Загрузка проектов
loadProjects: async () => {
try {
const { getAllProjects } = projectsApi;
const projects = await getAllProjects();
set({ projects });
// Если есть проекты и нет активного, выбираем первый
if (projects.length > 0 && !get().activeProjectId) {
await get().setActiveProject(projects[0].id);
}
} catch (error) {
console.error('Failed to load projects:', error);
}
},
// Установка активного проекта
setActiveProject: async (projectId: string) => {
set({ activeProjectId: projectId });
if (typeof window !== 'undefined') {
localStorage.setItem('activeProjectId', projectId);
}
// Загружаем документы и Bible этого проекта
await Promise.all([
get().loadDocumentsTree(projectId),
get().loadStoryBible(projectId),
]);
},
// Создание проекта
createProject: async (title, description = '') => {
const { createProject: apiCreateProject } = projectsApi;
const project = await apiCreateProject({ title, description });
set((state) => ({ projects: [...state.projects, project] }));
return project;
},
// Удаление проекта
deleteProject: async (projectId) => {
const { deleteProject: apiDeleteProject } = projectsApi;
await apiDeleteProject(projectId);
set((state) => ({
projects: state.projects.filter(p => p.id !== projectId),
activeProjectId: state.activeProjectId === projectId ? null : state.activeProjectId,
}));
},
// Загрузка дерева документов проекта
loadDocumentsTree: async (projectId) => {
set({ isLoading: true });
try {
const { getDocumentsTree, getProjectDocuments } = documentsApi;
const [tree, flat] = await Promise.all([
getDocumentsTree(projectId),
getProjectDocuments(projectId),
]);
// Выбираем активным документом первую сцену, или null
const firstScene = flat.find(d => d.type === 'scene');
set({
documentsTree: tree,
documents: flat,
activeDocumentId: firstScene?.id || null,
isLoading: false,
});
} catch (error) {
console.error('Failed to load documents tree:', error);
set({ isLoading: false });
}
},
// Установка активного документа
setActiveDocument: (documentId) => {
set({ activeDocumentId: documentId });
},
// Безопасное переключение документа с предварительным сохранением
switchToDocument: async (documentId: string) => {
const { activeDocumentId, activeProjectId, documents } = get();
// Сначала сохраняем текущий документ (если есть)
if (activeDocumentId && activeProjectId) {
const currentDoc = documents.find(d => d.id === activeDocumentId);
if (currentDoc) {
try {
// Вызываем глобальный callback из Editor для сохранения текущего контента
if (window.saveCurrentEditorContent) {
console.log('[Store] Saving current document before switch:', activeDocumentId);
await window.saveCurrentEditorContent();
}
} catch (error) {
console.error('[Store] Failed to save before switch:', error);
}
}
}
// Переключаем активный документ
set({ activeDocumentId: documentId });
},
// Создание нового документа
createDocument: async (projectId, title, type, parentId, content = '') => {
const { createDocument: apiCreate } = documentsApi;
const doc = await apiCreate(projectId, title, type, parentId, content);
await get().loadDocumentsTree(projectId);
return doc;
},
// Обновление документа
updateDocument: async (projectId, documentId, updates) => {
const { updateDocument: apiUpdate } = documentsApi;
await apiUpdate(projectId, documentId, updates);
// Обновляем в store (плоский список)
set((state) => ({
documents: state.documents.map(doc =>
doc.id === documentId ? { ...doc, ...updates, updated_at: new Date().toISOString() } : doc
),
}));
},
// Удаление документа
deleteDocument: async (projectId, documentId) => {
const { deleteDocument: apiDelete } = documentsApi;
await apiDelete(projectId, documentId);
await get().loadDocumentsTree(projectId);
// Если удалённый документ был активным, сбрасываем
if (get().activeDocumentId === documentId) {
const flat = get().documents;
const firstScene = flat.find(d => d.type === 'scene');
set({ activeDocumentId: firstScene?.id || null });
}
},
// Загрузка Story Bible проекта
loadStoryBible: async (projectId) => {
try {
const { getProjectStoryBible } = storyBibleApi;
const bible = await getProjectStoryBible(projectId);
set({ storyBible: bible });
} catch (error) {
console.error('Failed to load Story Bible:', error);
}
},
// Установка Story Bible
setStoryBible: (bible) => set({ storyBible: bible }),
// Обновление Story Bible через API
updateStoryBible: async (projectId, bible) => {
const { updateProjectStoryBible } = storyBibleApi;
await updateProjectStoryBible(projectId, bible);
set({ storyBible: bible });
},
// Управление настройками моделей
setModelSettings: (settings) => {
set({ modelSettings: settings });
// Сохраняем в localStorage
if (typeof window !== 'undefined') {
localStorage.setItem('modelSettings', JSON.stringify(settings));
}
},
updateModelSetting: (taskType, provider, model) => {
set((state) => {
const newSettings = {
...state.modelSettings,
[taskType]: { provider, model },
};
if (typeof window !== 'undefined') {
localStorage.setItem('modelSettings', JSON.stringify(newSettings));
}
return { modelSettings: newSettings };
});
},
// Обновление только контента документа (обёртка над updateDocument)
updateDocumentContent: async (projectId, documentId, content) => {
try {
const { updateDocumentContent: apiUpdate } = documentsApi;
await apiUpdate(projectId, documentId, content);
// ВАЖНО: Обновляем в store сразу, не дожидаясь перезагрузки
set((state) => ({
documents: state.documents.map(doc =>
doc.id === documentId ? { ...doc, content, updated_at: new Date().toISOString() } : doc
),
}));
console.log('[Store] Document content updated in store:', documentId);
} catch (error) {
console.error('[Store] Failed to update document:', error);
throw error;
}
},
// Переключатель автообновления Story Bible
setAutoUpdateBible: (enabled) => {
set({ autoUpdateBible: enabled });
if (typeof window !== 'undefined') {
localStorage.setItem('autoUpdateBible', JSON.stringify(enabled));
}
},
}));
// Загрузка сохранённых состояний при инициализации
if (typeof window !== 'undefined') {
const savedProjectId = localStorage.getItem('activeProjectId');
if (savedProjectId) {
useAppStore.setState({ activeProjectId: savedProjectId });
}
const savedSettings = localStorage.getItem('modelSettings');
if (savedSettings) {
try {
const parsed = JSON.parse(savedSettings);
useAppStore.setState({ modelSettings: parsed });
} catch (e) {
console.error('Failed to load model settings:', e);
}
}
const savedAutoUpdateBible = localStorage.getItem('autoUpdateBible');
if (savedAutoUpdateBible !== null) {
try {
useAppStore.setState({ autoUpdateBible: JSON.parse(savedAutoUpdateBible) });
} catch (e) {
console.error('Failed to load autoUpdateBible setting:', e);
}
}
// Проверка авторизации
useAppStore.getState().checkAuth();
}
+15
View File
@@ -0,0 +1,15 @@
#!/bin/bash
# Скрипт для сборки и тестирования Docker-образа frontend локально
echo "🔨 Building frontend Docker image..."
docker build -t writer-ide-frontend:local .
if [ $? -ne 0 ]; then
echo "❌ Build failed!"
exit 1
fi
echo "✅ Build successful!"
echo ""
echo "🚀 To run the container:"
echo "docker run -p 3000:3000 -e NEXT_PUBLIC_API_URL=http://localhost:8000/api writer-ide-frontend:local"
+34
View File
@@ -0,0 +1,34 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./src/*"]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts",
"**/*.mts"
],
"exclude": ["node_modules"]
}
Executable
+94
View File
@@ -0,0 +1,94 @@
#!/bin/bash
# Скрипт первоначальной настройки Writer IDE
set -e
echo "🚀 Writer IDE - Первоначальная настройка"
echo "========================================="
echo ""
# 1. Проверка наличия .env файла
if [ ! -f .env ]; then
echo "📝 Создаю .env файл из .env.example..."
cp .env.example .env
echo "✅ Файл .env создан. Отредактируйте его и заполните значения!"
echo ""
else
echo "✅ Файл .env уже существует"
echo ""
fi
# 2. Создание папки для данных на HDD
echo "📂 Проверка папки для данных..."
DATA_PATH=$(grep DATA_VOLUME_PATH .env | cut -d '=' -f2)
if [ -z "$DATA_PATH" ]; then
DATA_PATH="/mnt/data/writer-ide"
echo "⚠️ DATA_VOLUME_PATH не указан в .env, использую $DATA_PATH"
fi
if [ ! -d "$DATA_PATH" ]; then
echo "📁 Создаю папку $DATA_PATH..."
sudo mkdir -p "$DATA_PATH"
sudo chown -R $USER:$USER "$DATA_PATH"
echo "✅ Папка создана"
else
echo "✅ Папка $DATA_PATH уже существует"
fi
echo ""
# 3. Генерация JWT_SECRET_KEY, если не указан
JWT_KEY=$(grep JWT_SECRET_KEY .env | cut -d '=' -f2)
if [ -z "$JWT_KEY" ] || [ "$JWT_KEY" = "change-this-to-a-long-random-secret-string" ]; then
echo "🔐 Генерирую JWT_SECRET_KEY..."
NEW_KEY=$(openssl rand -hex 32)
# Заменяем в .env (используем sed с разделителем |)
sed -i.bak "s|JWT_SECRET_KEY=.*|JWT_SECRET_KEY=$NEW_KEY|" .env
rm -f .env.bak
echo "✅ JWT_SECRET_KEY сгенерирован и сохранён в .env"
else
echo "✅ JWT_SECRET_KEY уже установлен"
fi
echo ""
# 4. Проверка доступности Ollama
echo "🔍 Проверка доступности Ollama..."
OLLAMA_URL=$(grep OLLAMA_BASE_URL .env | cut -d '=' -f2)
if curl -s --max-time 5 "$OLLAMA_URL/api/tags" > /dev/null; then
echo "✅ Ollama доступна по адресу $OLLAMA_URL"
else
echo "⚠️ Ollama недоступна по адресу $OLLAMA_URL"
echo " Убедитесь, что Ollama запущена и доступна"
echo " Вы можете изменить OLLAMA_BASE_URL в .env"
fi
echo ""
# 5. Проверка Docker
echo "🐳 Проверка Docker..."
if ! docker info > /dev/null 2>&1; then
echo "❌ Docker не запущен или недоступен"
echo " Запустите Docker и повторите попытку"
exit 1
fi
echo "✅ Docker работает"
echo ""
# 6. Итоговая информация
echo "========================================="
echo "✅ Первоначальная настройка завершена!"
echo ""
echo "📋 Следующие шаги:"
echo "1. Отредактируйте .env файл:"
echo " - Укажите OPENROUTER_API_KEY"
echo " - Проверьте DATA_VOLUME_PATH (путь к HDD)"
echo " - Проверьте OLLAMA_BASE_URL"
echo " - Настройте CORS_ORIGINS для вашего домена/IP"
echo ""
echo "2. Запустите приложение:"
echo " docker-compose up -d --build"
echo ""
echo "3. Откройте в браузере:"
echo " http://localhost:3000"
echo ""
echo "4. Логи можно смотреть командой:"
echo " docker-compose logs -f"
echo "========================================="
Executable
+31
View File
@@ -0,0 +1,31 @@
#!/bin/bash
# Скрипт для запуска Writer IDE
echo "🚀 Запуск Writer IDE..."
# Проверка наличия .env
if [ ! -f .env ]; then
echo "❌ Файл .env не найден!"
echo " Запустите сначала: ./setup.sh"
exit 1
fi
# Создание папки для данных
DATA_PATH=$(grep DATA_VOLUME_PATH .env | cut -d '=' -f2)
if [ ! -d "$DATA_PATH" ]; then
echo "📁 Создаю папку для данных: $DATA_PATH"
sudo mkdir -p "$DATA_PATH"
sudo chown -R $USER:$USER "$DATA_PATH"
fi
# Запуск docker-compose
docker-compose up -d --build
echo ""
echo "✅ Writer IDE запущен!"
echo "🌐 Frontend: http://localhost:3000"
echo "🔧 Backend API: http://localhost:8000"
echo "📚 API Docs: http://localhost:8000/docs"
echo ""
echo "📊 Логи: docker-compose logs -f"
echo "🛑 Остановка: docker-compose down"
Executable
+4
View File
@@ -0,0 +1,4 @@
#!/bin/bash
echo "🛑 Остановка Writer IDE..."
docker-compose down
echo "✅ Остановлено"